From 15f619ccf76768c152eb0c495fdfb4efd3b374a0 Mon Sep 17 00:00:00 2001 From: qpismont Date: Fri, 31 Jul 2026 20:06:37 +0000 Subject: [PATCH 01/17] Move to multi crates project Starting impl devcontainer spec --- .devcontainer/Dockerfile | 11 +- .devcontainer/devcontainer.json | 1 + .dockerignore | 4 - .vscode/settings.json | 3 - .zed/settings.json | 11 ++ Cargo.lock | 17 +- Cargo.toml | 22 ++- Containerfile | 13 +- crates/devcontainer-rs/Cargo.toml | 14 ++ crates/devcontainer-rs/src/lib.rs | 175 ++++++++++++++++++ crates/herald-server/Cargo.toml | 30 +++ {src => crates/herald-server/src}/api.rs | 0 {src => crates/herald-server/src}/bot.rs | 0 .../herald-server/src}/bot_actions/mod.rs | 0 .../herald-server/src}/bot_actions/review.rs | 0 {src => crates/herald-server/src}/consts.rs | 0 {src => crates/herald-server/src}/env.rs | 0 {src => crates/herald-server/src}/errors.rs | 0 {src => crates/herald-server/src}/gitea.rs | 0 {src => crates/herald-server/src}/main.rs | 0 {src => crates/herald-server/src}/metrics.rs | 0 .../herald-server/src}/open_router.rs | 0 {src => crates/herald-server/src}/state.rs | 0 23 files changed, 271 insertions(+), 30 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .vscode/settings.json create mode 100644 .zed/settings.json create mode 100644 crates/devcontainer-rs/Cargo.toml create mode 100644 crates/devcontainer-rs/src/lib.rs create mode 100644 crates/herald-server/Cargo.toml rename {src => crates/herald-server/src}/api.rs (100%) rename {src => crates/herald-server/src}/bot.rs (100%) rename {src => crates/herald-server/src}/bot_actions/mod.rs (100%) rename {src => crates/herald-server/src}/bot_actions/review.rs (100%) rename {src => crates/herald-server/src}/consts.rs (100%) rename {src => crates/herald-server/src}/env.rs (100%) rename {src => crates/herald-server/src}/errors.rs (100%) rename {src => crates/herald-server/src}/gitea.rs (100%) rename {src => crates/herald-server/src}/main.rs (100%) rename {src => crates/herald-server/src}/metrics.rs (100%) rename {src => crates/herald-server/src}/open_router.rs (100%) rename {src => crates/herald-server/src}/state.rs (100%) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 1b89658..dd02efa 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM debian:trixie +FROM rust:1.97-trixie ARG USERNAME=dev ARG USER_UID=1000 @@ -18,11 +18,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* RUN groupadd --gid ${USER_GID:-1000} $USERNAME \ - && useradd --uid ${USER_UID:-1000} --gid ${USER_GID:-1000} -m $USERNAME + && useradd --uid ${USER_UID:-1000} --gid ${USER_GID:-1000} -m $USERNAME \ + && rustup component add clippy + + USER $USERNAME WORKDIR /home/$USERNAME - -ENV PATH="/home/${USERNAME}/.cargo/bin:${PATH}" - -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a0fc52a..b9310d6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -19,5 +19,6 @@ }, "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/herald,type=bind", "workspaceFolder": "/workspaces/herald", + "runArgs": ["--userns=keep-id", "--security-opt", "label=disable"], "appPort": [3000] } diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 89343aa..0000000 --- a/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -target/ -.env -.devcontainer/ -docs/ diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 660eb93..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "rust-analyzer.check.command": "clippy" -} \ No newline at end of file diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..d569920 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,11 @@ +{ + "lsp": { + "rust-analyzer": { + "initialization_options": { + "check": { + "command": "clippy" + } + } + } + } +} diff --git a/Cargo.lock b/Cargo.lock index 7f4d25b..3def019 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -490,6 +490,18 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "devcontainer-rs" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -787,12 +799,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] -name = "herald" -version = "1.1.0" +name = "herald-server" +version = "1.2.0" dependencies = [ "anyhow", "axum", "bytes", + "devcontainer-rs", "dotenvy", "futures-util", "hex", diff --git a/Cargo.toml b/Cargo.toml index 3340e25..0365198 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,11 @@ -[package] -name = "herald" -version = "1.1.0" -edition = "2024" +[workspace] +members = [ + "crates/herald-server", + "crates/devcontainer-rs", +] +resolver = "3" -[profile.release] -debug = 1 - -[dependencies] +[workspace.dependencies] reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } tokio = { version = "1.53", features = ["full"] } tokio-stream = "0.1" @@ -19,9 +18,9 @@ sentry-anyhow = { version = "0.48", features = ["backtrace"] } openrouter-rs = "0.12" dotenvy = "0.15" tower = "0.5" -tower-http = {version = "0.6", features = ["trace"] } +tower-http = { version = "0.6", features = ["trace"] } tracing = "0.1" -tracing-subscriber = { version = "0.3", features=["env-filter"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } axum = "0.8" anyhow = { version = "1.0", features = ["backtrace"] } thiserror = "2.0" @@ -30,3 +29,6 @@ hex = "0.4" bytes = "1.1" metrics = "0.24" metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] } + +[profile.release] +debug = 1 diff --git a/Containerfile b/Containerfile index 6508b0c..7150f27 100644 --- a/Containerfile +++ b/Containerfile @@ -1,12 +1,15 @@ -FROM rust:1.96 as builder +FROM rust:1.97-trixie as builder WORKDIR /app -COPY . . -RUN cargo build --release + +COPY Cargo.toml Cargo.lock ./ +COPY crates/ crates/ + +RUN cargo build --release --package herald-server FROM debian:trixie-slim WORKDIR /app -COPY --from=builder /app/target/release/herald . -CMD [ "./herald" ] +COPY --from=builder /app/target/release/herald-server . +CMD [ "./herald-server" ] diff --git a/crates/devcontainer-rs/Cargo.toml b/crates/devcontainer-rs/Cargo.toml new file mode 100644 index 0000000..1dd07fb --- /dev/null +++ b/crates/devcontainer-rs/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "devcontainer-rs" +version = "0.1.0" +edition = "2024" + +[dependencies] +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/devcontainer-rs/src/lib.rs b/crates/devcontainer-rs/src/lib.rs new file mode 100644 index 0000000..44c8bc3 --- /dev/null +++ b/crates/devcontainer-rs/src/lib.rs @@ -0,0 +1,175 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct DevContainerBuildSchema { + #[serde(default)] + pub dockerfile: Option, + #[serde(default)] + pub args: HashMap, +} + +#[derive(Debug, Deserialize)] +pub struct DevContainerSchema { + #[serde(default)] + pub name: Option, + pub build: DevContainerBuildSchema, + + #[serde(rename = "workspaceFolder", default)] + pub workspace_folder: Option, + + #[serde(rename = "containerEnv", default)] + pub container_env: HashMap, + + #[serde(rename = "postCreateCommand", default)] + pub post_create_command: Option, + + #[serde(rename = "postStartCommand", default)] + pub post_start_command: Option, +} + +#[derive(Debug)] +pub struct DevContainer { + /// Absolute or relative path to the Dockerfile/Containerfile to build. + pub container_file_path: PathBuf, + pub name: Option, + pub build_args: HashMap, + pub container_env: HashMap, + pub workspace_folder: Option, + pub post_create_command: Option, + pub post_start_command: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum ParseError { + #[error("failed to read devcontainer file `{path}`: {source}")] + Read { + path: PathBuf, + source: std::io::Error, + }, + + #[error("invalid devcontainer JSON in `{path}`: {source}")] + Json { + path: PathBuf, + source: serde_json::Error, + }, + + #[error("container file `{0}` does not exist or is not a regular file")] + ContainerFileNotFound(PathBuf), + + #[error("the devcontainer file path has no parent directory: `{0}`")] + InvalidDevContainerPath(PathBuf), +} + +impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer { + type Error = ParseError; + + fn try_from((schema, devcontainer_path): (DevContainerSchema, PathBuf)) -> Result { + let base_dir = devcontainer_path + .parent() + .ok_or_else(|| ParseError::InvalidDevContainerPath(devcontainer_path.clone()))?; + + let container_file_path = match schema.build.dockerfile.as_deref() { + Some(file) => base_dir.join(file), + None => first_existing_container_file(base_dir), + }; + + if !container_file_path.is_file() { + return Err(ParseError::ContainerFileNotFound(container_file_path)); + } + + Ok(Self { + container_file_path, + name: schema.name, + build_args: schema.build.args, + container_env: schema.container_env, + workspace_folder: schema.workspace_folder, + post_create_command: schema.post_create_command, + post_start_command: schema.post_start_command, + }) + } +} + +fn first_existing_container_file(base_dir: &Path) -> PathBuf { + ["Dockerfile", "Containerfile"] + .iter() + .map(|filename| base_dir.join(filename)) + .find(|path| path.is_file()) + .unwrap_or_else(|| base_dir.join("Dockerfile")) +} + +pub async fn parse(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + let contents = tokio::fs::read_to_string(&path) + .await + .map_err(|source| ParseError::Read { + path: path.clone(), + source, + })?; + + let schema = serde_json::from_str::(&contents).map_err(|source| { + ParseError::Json { + path: path.clone(), + source, + } + })?; + + DevContainer::try_from((schema, path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn resolves_configured_containerfile_relative_to_devcontainer_file() { + let dir = tempfile::tempdir().unwrap(); + let devcontainer_path = dir.path().join("devcontainer.json"); + let containerfile_path = dir.path().join("Containerfile"); + fs::write(&containerfile_path, "FROM alpine\n").unwrap(); + + let schema = DevContainerSchema { + name: Some("test".into()), + build: DevContainerBuildSchema { + dockerfile: Some("Containerfile".into()), + args: HashMap::new(), + }, + workspace_folder: None, + container_env: HashMap::new(), + post_create_command: None, + post_start_command: None, + }; + + let config = DevContainer::try_from((schema, devcontainer_path)).unwrap(); + assert_eq!(config.container_file_path, containerfile_path); + } + + #[test] + fn falls_back_to_dockerfile_before_containerfile() { + let dir = tempfile::tempdir().unwrap(); + let devcontainer_path = dir.path().join("devcontainer.json"); + let dockerfile_path = dir.path().join("Dockerfile"); + fs::write(&dockerfile_path, "FROM alpine\n").unwrap(); + fs::write(dir.path().join("Containerfile"), "FROM busybox\n").unwrap(); + + let schema = DevContainerSchema { + name: None, + build: DevContainerBuildSchema { + dockerfile: None, + args: HashMap::new(), + }, + workspace_folder: None, + container_env: HashMap::new(), + post_create_command: None, + post_start_command: None, + }; + + let config = DevContainer::try_from((schema, devcontainer_path)).unwrap(); + assert_eq!(config.container_file_path, dockerfile_path); + } +} diff --git a/crates/herald-server/Cargo.toml b/crates/herald-server/Cargo.toml new file mode 100644 index 0000000..8e015d9 --- /dev/null +++ b/crates/herald-server/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "herald-server" +version = "1.2.0" +edition = "2024" + +[dependencies] +reqwest = { workspace = true } +tokio = { workspace = true } +tokio-stream = { workspace = true } +tokio-util = { workspace = true } +futures-util = { workspace = true } +serde_json = { workspace = true } +serde = { workspace = true } +sentry = { workspace = true } +sentry-anyhow = { workspace = true } +openrouter-rs = { workspace = true } +dotenvy = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +axum = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +ring = { workspace = true } +hex = { workspace = true } +bytes = { workspace = true } +metrics = { workspace = true } +metrics-exporter-prometheus = { workspace = true } +devcontainer-rs = { path = "../devcontainer-rs" } \ No newline at end of file diff --git a/src/api.rs b/crates/herald-server/src/api.rs similarity index 100% rename from src/api.rs rename to crates/herald-server/src/api.rs diff --git a/src/bot.rs b/crates/herald-server/src/bot.rs similarity index 100% rename from src/bot.rs rename to crates/herald-server/src/bot.rs diff --git a/src/bot_actions/mod.rs b/crates/herald-server/src/bot_actions/mod.rs similarity index 100% rename from src/bot_actions/mod.rs rename to crates/herald-server/src/bot_actions/mod.rs diff --git a/src/bot_actions/review.rs b/crates/herald-server/src/bot_actions/review.rs similarity index 100% rename from src/bot_actions/review.rs rename to crates/herald-server/src/bot_actions/review.rs diff --git a/src/consts.rs b/crates/herald-server/src/consts.rs similarity index 100% rename from src/consts.rs rename to crates/herald-server/src/consts.rs diff --git a/src/env.rs b/crates/herald-server/src/env.rs similarity index 100% rename from src/env.rs rename to crates/herald-server/src/env.rs diff --git a/src/errors.rs b/crates/herald-server/src/errors.rs similarity index 100% rename from src/errors.rs rename to crates/herald-server/src/errors.rs diff --git a/src/gitea.rs b/crates/herald-server/src/gitea.rs similarity index 100% rename from src/gitea.rs rename to crates/herald-server/src/gitea.rs diff --git a/src/main.rs b/crates/herald-server/src/main.rs similarity index 100% rename from src/main.rs rename to crates/herald-server/src/main.rs diff --git a/src/metrics.rs b/crates/herald-server/src/metrics.rs similarity index 100% rename from src/metrics.rs rename to crates/herald-server/src/metrics.rs diff --git a/src/open_router.rs b/crates/herald-server/src/open_router.rs similarity index 100% rename from src/open_router.rs rename to crates/herald-server/src/open_router.rs diff --git a/src/state.rs b/crates/herald-server/src/state.rs similarity index 100% rename from src/state.rs rename to crates/herald-server/src/state.rs From 5b9d870b464053ec3cdf6bccca25b2a01cd21e74 Mon Sep 17 00:00:00 2001 From: qpismont Date: Fri, 31 Jul 2026 20:18:29 +0000 Subject: [PATCH 02/17] Dockerfile field must be present --- crates/devcontainer-rs/src/lib.rs | 79 +++++++++++-------------------- 1 file changed, 27 insertions(+), 52 deletions(-) diff --git a/crates/devcontainer-rs/src/lib.rs b/crates/devcontainer-rs/src/lib.rs index 44c8bc3..1dd6d69 100644 --- a/crates/devcontainer-rs/src/lib.rs +++ b/crates/devcontainer-rs/src/lib.rs @@ -8,7 +8,7 @@ use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct DevContainerBuildSchema { #[serde(default)] - pub dockerfile: Option, + pub dockerfile: String, #[serde(default)] pub args: HashMap, } @@ -34,7 +34,6 @@ pub struct DevContainerSchema { #[derive(Debug)] pub struct DevContainer { - /// Absolute or relative path to the Dockerfile/Containerfile to build. pub container_file_path: PathBuf, pub name: Option, pub build_args: HashMap, @@ -73,10 +72,7 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer { .parent() .ok_or_else(|| ParseError::InvalidDevContainerPath(devcontainer_path.clone()))?; - let container_file_path = match schema.build.dockerfile.as_deref() { - Some(file) => base_dir.join(file), - None => first_existing_container_file(base_dir), - }; + let container_file_path = base_dir.join(schema.build.dockerfile); if !container_file_path.is_file() { return Err(ParseError::ContainerFileNotFound(container_file_path)); @@ -94,14 +90,6 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer { } } -fn first_existing_container_file(base_dir: &Path) -> PathBuf { - ["Dockerfile", "Containerfile"] - .iter() - .map(|filename| base_dir.join(filename)) - .find(|path| path.is_file()) - .unwrap_or_else(|| base_dir.join("Dockerfile")) -} - pub async fn parse(path: impl AsRef) -> Result { let path = path.as_ref().to_path_buf(); let contents = tokio::fs::read_to_string(&path) @@ -126,50 +114,37 @@ mod tests { use super::*; use std::fs; - #[test] - fn resolves_configured_containerfile_relative_to_devcontainer_file() { - let dir = tempfile::tempdir().unwrap(); - let devcontainer_path = dir.path().join("devcontainer.json"); - let containerfile_path = dir.path().join("Containerfile"); - fs::write(&containerfile_path, "FROM alpine\n").unwrap(); - - let schema = DevContainerSchema { - name: Some("test".into()), - build: DevContainerBuildSchema { - dockerfile: Some("Containerfile".into()), - args: HashMap::new(), - }, - workspace_folder: None, - container_env: HashMap::new(), - post_create_command: None, - post_start_command: None, - }; - - let config = DevContainer::try_from((schema, devcontainer_path)).unwrap(); - assert_eq!(config.container_file_path, containerfile_path); - } - - #[test] - fn falls_back_to_dockerfile_before_containerfile() { + #[tokio::test] + async fn parses_devcontainer_file() { let dir = tempfile::tempdir().unwrap(); let devcontainer_path = dir.path().join("devcontainer.json"); let dockerfile_path = dir.path().join("Dockerfile"); + fs::write(&dockerfile_path, "FROM alpine\n").unwrap(); - fs::write(dir.path().join("Containerfile"), "FROM busybox\n").unwrap(); + fs::write( + &devcontainer_path, + r#"{ + "name": "test", + "build": { + "dockerfile": "Dockerfile", + "args": { + "VERSION": "1" + } + }, + "workspaceFolder": "/workspace", + "containerEnv": { + "RUST_LOG": "debug" + } + }"#, + ) + .unwrap(); - let schema = DevContainerSchema { - name: None, - build: DevContainerBuildSchema { - dockerfile: None, - args: HashMap::new(), - }, - workspace_folder: None, - container_env: HashMap::new(), - post_create_command: None, - post_start_command: None, - }; + let config = parse(&devcontainer_path).await.unwrap(); - let config = DevContainer::try_from((schema, devcontainer_path)).unwrap(); + assert_eq!(config.name.as_deref(), Some("test")); assert_eq!(config.container_file_path, dockerfile_path); + assert_eq!(config.build_args.get("VERSION").unwrap(), "1"); + assert_eq!(config.container_env.get("RUST_LOG").unwrap(), "debug"); + assert_eq!(config.workspace_folder.as_deref(), Some("/workspace")); } } From b3a0cb63e9095a7de643b153bd38d01cbc931184 Mon Sep 17 00:00:00 2001 From: qpismont Date: Fri, 31 Jul 2026 20:21:05 +0000 Subject: [PATCH 03/17] Update woodpecker rust job (1.96 => 1.97) --- .woodpecker/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.woodpecker/tests.yml b/.woodpecker/tests.yml index b151ff1..acb5c7b 100644 --- a/.woodpecker/tests.yml +++ b/.woodpecker/tests.yml @@ -4,12 +4,12 @@ when: steps: - name: clippy - image: rust:1.96 + image: rust:1.97 commands: - rustup component add clippy - cargo clippy - name: test - image: rust:1.96 + image: rust:1.97 commands: - cargo test From 6a21c7d6c3be5cc04cdb976b545422568383edd3 Mon Sep 17 00:00:00 2001 From: qpismont Date: Fri, 31 Jul 2026 20:25:37 +0000 Subject: [PATCH 04/17] Renforce woodpecker tests --- .woodpecker/tests.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.woodpecker/tests.yml b/.woodpecker/tests.yml index acb5c7b..7d128af 100644 --- a/.woodpecker/tests.yml +++ b/.woodpecker/tests.yml @@ -3,13 +3,27 @@ when: - push steps: + - name: fmt + image: rust:1.97 + commands: + - rustup component add rustfmt + - cargo fmt --all -- --check + - name: clippy image: rust:1.97 commands: - rustup component add clippy - - cargo clippy + - cargo clippy --workspace --all-targets --all-features -- -D warnings - name: test image: rust:1.97 commands: - - cargo test + - cargo test --workspace --all-targets + + - name: container-build + image: quay.io/buildah/stable + privileged: true + volumes: + - /data/woodpecker-builds:/data + commands: + - buildah bud -f Containerfile -t herald-ci . From f0e64e0c1d2f83e31df089b8cbae8a35a5935dc3 Mon Sep 17 00:00:00 2001 From: qpismont Date: Fri, 31 Jul 2026 20:32:59 +0000 Subject: [PATCH 05/17] Fix fmt --- .devcontainer/Dockerfile | 3 +- .zed/settings.json | 6 ++++ crates/devcontainer-rs/src/lib.rs | 4 ++- crates/herald-server/src/bot.rs | 4 ++- crates/herald-server/src/main.rs | 7 +++- crates/herald-server/src/metrics.rs | 50 ++++++++++++++++++++++------- 6 files changed, 58 insertions(+), 16 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index dd02efa..ba2efc9 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -19,7 +19,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN groupadd --gid ${USER_GID:-1000} $USERNAME \ && useradd --uid ${USER_UID:-1000} --gid ${USER_GID:-1000} -m $USERNAME \ - && rustup component add clippy + && rustup component add clippy \ + && rustup component add rustfmt diff --git a/.zed/settings.json b/.zed/settings.json index d569920..afccddd 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -1,4 +1,10 @@ { + "languages": { + "Rust": { + "format_on_save": "on", + "formatter": "language_server" + } + }, "lsp": { "rust-analyzer": { "initialization_options": { diff --git a/crates/devcontainer-rs/src/lib.rs b/crates/devcontainer-rs/src/lib.rs index 1dd6d69..81960de 100644 --- a/crates/devcontainer-rs/src/lib.rs +++ b/crates/devcontainer-rs/src/lib.rs @@ -67,7 +67,9 @@ pub enum ParseError { impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer { type Error = ParseError; - fn try_from((schema, devcontainer_path): (DevContainerSchema, PathBuf)) -> Result { + fn try_from( + (schema, devcontainer_path): (DevContainerSchema, PathBuf), + ) -> Result { let base_dir = devcontainer_path .parent() .ok_or_else(|| ParseError::InvalidDevContainerPath(devcontainer_path.clone()))?; diff --git a/crates/herald-server/src/bot.rs b/crates/herald-server/src/bot.rs index 470990f..12ba2d7 100644 --- a/crates/herald-server/src/bot.rs +++ b/crates/herald-server/src/bot.rs @@ -1,5 +1,7 @@ use crate::{ - gitea::{GiteaAPI, WebhookType}, metrics, open_router::OpenRouterClient, + gitea::{GiteaAPI, WebhookType}, + metrics, + open_router::OpenRouterClient, }; use serde::Deserialize; use std::{collections::HashSet, sync::Arc}; diff --git a/crates/herald-server/src/main.rs b/crates/herald-server/src/main.rs index 27d0554..3a5ab03 100644 --- a/crates/herald-server/src/main.rs +++ b/crates/herald-server/src/main.rs @@ -1,4 +1,9 @@ -use crate::{bot::Bot, gitea::{GiteaAPI, WebhookType}, open_router::OpenRouterClient, state::AppState}; +use crate::{ + bot::Bot, + gitea::{GiteaAPI, WebhookType}, + open_router::OpenRouterClient, + state::AppState, +}; use dotenvy::dotenv; use tokio::signal::unix::{SignalKind, signal}; diff --git a/crates/herald-server/src/metrics.rs b/crates/herald-server/src/metrics.rs index 23698d8..44d3347 100644 --- a/crates/herald-server/src/metrics.rs +++ b/crates/herald-server/src/metrics.rs @@ -1,10 +1,9 @@ use std::{net::SocketAddr, str::FromStr}; -use metrics::{Unit, describe_counter, describe_gauge, counter, gauge}; +use metrics::{Unit, counter, describe_counter, describe_gauge, gauge}; pub fn webhook_received(event_type: &str) { - counter!("herald_webhooks_received_total", "event_type" => event_type.to_string()) - .increment(1); + counter!("herald_webhooks_received_total", "event_type" => event_type.to_string()).increment(1); } pub fn webhook_duplicate(event_type: &str) { @@ -31,8 +30,7 @@ pub fn task_completed(event_type: &str) { } pub fn task_failed(event_type: &str) { - counter!("herald_bot_tasks_failed_total", "event_type" => event_type.to_string()) - .increment(1); + counter!("herald_bot_tasks_failed_total", "event_type" => event_type.to_string()).increment(1); } pub fn openrouter_cost_usd(cost: f64) { @@ -40,13 +38,41 @@ pub fn openrouter_cost_usd(cost: f64) { } pub fn describe() { - describe_counter!("herald_webhooks_received_total", Unit::Count, "Total webhooks received"); - describe_counter!("herald_webhooks_duplicate_total", Unit::Count, "Webhooks rejected as duplicates"); - describe_counter!("herald_webhooks_channel_full_total", Unit::Count, "Webhooks dropped because the bot channel was full"); - describe_gauge!("herald_bot_tasks_active", Unit::Count, "Bot tasks currently in progress"); - describe_counter!("herald_bot_tasks_completed_total", Unit::Count, "Bot tasks completed successfully"); - describe_counter!("herald_bot_tasks_failed_total", Unit::Count, "Bot tasks that failed"); - describe_counter!("herald_openrouter_cost_cents_total", Unit::Count, "Total OpenRouter cost in cents (divide by 100 for USD)"); + describe_counter!( + "herald_webhooks_received_total", + Unit::Count, + "Total webhooks received" + ); + describe_counter!( + "herald_webhooks_duplicate_total", + Unit::Count, + "Webhooks rejected as duplicates" + ); + describe_counter!( + "herald_webhooks_channel_full_total", + Unit::Count, + "Webhooks dropped because the bot channel was full" + ); + describe_gauge!( + "herald_bot_tasks_active", + Unit::Count, + "Bot tasks currently in progress" + ); + describe_counter!( + "herald_bot_tasks_completed_total", + Unit::Count, + "Bot tasks completed successfully" + ); + describe_counter!( + "herald_bot_tasks_failed_total", + Unit::Count, + "Bot tasks that failed" + ); + describe_counter!( + "herald_openrouter_cost_cents_total", + Unit::Count, + "Total OpenRouter cost in cents (divide by 100 for USD)" + ); } pub fn install(bind_addr: &str) -> anyhow::Result<()> { From 8c53bc0e200a2c6f743180973304b2cb4dd13e76 Mon Sep 17 00:00:00 2001 From: qpismont Date: Fri, 31 Jul 2026 20:33:47 +0000 Subject: [PATCH 06/17] re fix fmt lol --- crates/herald-server/src/bot_actions/review.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/herald-server/src/bot_actions/review.rs b/crates/herald-server/src/bot_actions/review.rs index c156aa2..8ca234a 100644 --- a/crates/herald-server/src/bot_actions/review.rs +++ b/crates/herald-server/src/bot_actions/review.rs @@ -4,7 +4,11 @@ use tokio_util::io::StreamReader; use tracing::instrument; use crate::{ - bot::ReviewResult, consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT}, gitea::{GiteaAPI, ReviewPayload}, metrics, open_router::OpenRouterClient, + bot::ReviewResult, + consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT}, + gitea::{GiteaAPI, ReviewPayload}, + metrics, + open_router::OpenRouterClient, }; #[instrument(skip(gitea_api, open_router_client, http_client, review_payload))] From a29051b0e458ed50269c92b74e587d9e6e97dbe0 Mon Sep 17 00:00:00 2001 From: qpismont Date: Fri, 31 Jul 2026 20:39:50 +0000 Subject: [PATCH 07/17] Fix clippy errors --- .zed/settings.json | 7 ++++++- crates/herald-server/src/bot.rs | 1 - crates/herald-server/src/gitea.rs | 7 ------- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/.zed/settings.json b/.zed/settings.json index afccddd..c873cae 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -9,7 +9,12 @@ "rust-analyzer": { "initialization_options": { "check": { - "command": "clippy" + "command": "clippy", + "extraArgs": [ + "--", + "-D", + "warnings" + ] } } } diff --git a/crates/herald-server/src/bot.rs b/crates/herald-server/src/bot.rs index 12ba2d7..0a4482c 100644 --- a/crates/herald-server/src/bot.rs +++ b/crates/herald-server/src/bot.rs @@ -20,7 +20,6 @@ pub struct ReviewResult { pub struct ReviewItem { pub filename: String, pub line: Option, - pub code: String, pub message: String, } diff --git a/crates/herald-server/src/gitea.rs b/crates/herald-server/src/gitea.rs index 8477b4b..b6b48fb 100644 --- a/crates/herald-server/src/gitea.rs +++ b/crates/herald-server/src/gitea.rs @@ -197,7 +197,6 @@ pub struct ReviewPayload { #[derive(Deserialize, Debug)] pub struct PullRequest { - pub id: u64, pub diff_url: String, pub number: u64, pub title: String, @@ -207,12 +206,10 @@ pub struct PullRequest { pub struct Comment { pub id: u64, pub body: String, - pub user: User, } #[derive(Deserialize, Debug)] pub struct User { - pub id: u64, pub login: String, } @@ -282,10 +279,8 @@ mod tests { match result.unwrap() { WebhookType::Review(payload) => { assert_eq!(payload.action, "created"); - assert_eq!(payload.pull_request.id, 42); assert_eq!(payload.comment.id, 7); assert_eq!(payload.comment.body, "@test_bot LGTM"); - assert_eq!(payload.comment.user.id, 100); } } } @@ -375,10 +370,8 @@ mod tests { let payload: ReviewPayload = serde_json::from_value(json).unwrap(); assert_eq!(payload.action, "created"); - assert_eq!(payload.pull_request.id, 99); assert_eq!(payload.comment.id, 12); assert_eq!(payload.comment.body, "Needs work"); - assert_eq!(payload.comment.user.id, 200); } #[test] From 536c55f27b0c7b86ea69e56bb7022543ae8b0f19 Mon Sep 17 00:00:00 2001 From: qpismont Date: Tue, 1 Sep 2026 10:07:39 +0000 Subject: [PATCH 08/17] update deps + fix sentry config --- .devcontainer/Dockerfile | 2 +- Cargo.lock | 865 ++++++++++++++++--------------- Cargo.toml | 10 +- crates/herald-server/src/main.rs | 14 +- 4 files changed, 452 insertions(+), 439 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ba2efc9..d28c98e 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.97-trixie +FROM rust:1.98-trixie ARG USERNAME=dev ARG USER_UID=1000 diff --git a/Cargo.lock b/Cargo.lock index 3def019..4989bb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "actix-codec" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +checksum = "31404e1443b7b7bcaa311c1af456775cc3f668e34573f1503d7ce4844327629e" dependencies = [ "bitflags", "bytes", @@ -21,9 +21,9 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.12.1" +version = "3.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93acb4a42f64936f9b8cae4a433b237599dd6eb6ed06124eb67132ef8cc90662" +checksum = "11004b0e9b44b4eb3d15e0c3132b96fb178c7e50a74758b2f17bb9cc9a7fb4f6" dependencies = [ "actix-codec", "actix-rt", @@ -34,7 +34,7 @@ dependencies = [ "bytestring", "derive_more", "encoding_rs", - "foldhash 0.1.5", + "foldhash", "futures-core", "http 0.2.12", "httparse", @@ -66,9 +66,9 @@ dependencies = [ [[package]] name = "actix-rt" -version = "2.11.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +checksum = "6a16bf2f19c2ad84842bdfe6f3665620e93197d5c607889bfa3aaac45e762fd1" dependencies = [ "futures-core", "tokio", @@ -76,9 +76,9 @@ dependencies = [ [[package]] name = "actix-server" -version = "2.6.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +checksum = "5d44ae8a6516f4ac7bfc7b61aabcd286104e96b4b24c747ce220832a016056d9" dependencies = [ "actix-rt", "actix-service", @@ -86,7 +86,7 @@ dependencies = [ "futures-core", "futures-util", "mio", - "socket2 0.5.10", + "socket2", "tokio", "tracing", ] @@ -113,9 +113,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.13.0" +version = "4.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" +checksum = "bbacab3593b6b4f7be815076fc52d60a83c873426824675417e2abdd229e2e36" dependencies = [ "actix-codec", "actix-http", @@ -129,7 +129,7 @@ dependencies = [ "cfg-if", "derive_more", "encoding_rs", - "foldhash 0.1.5", + "foldhash", "futures-core", "futures-util", "impl-more", @@ -144,7 +144,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.3", + "socket2", "time", "tracing", "url", @@ -167,27 +167,27 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "atomic-waker" @@ -205,7 +205,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.0", + "http 1.5.0", "http-body", "http-body-util", "hyper", @@ -236,7 +236,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.5.0", "http-body", "http-body-util", "mime", @@ -268,6 +268,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -276,9 +282,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block2" @@ -291,15 +297,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bytestring" @@ -312,9 +318,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "shlex", @@ -328,9 +334,20 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] [[package]] name = "convert_case" @@ -357,6 +374,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-epoch" version = "0.9.20" @@ -393,7 +419,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -404,7 +430,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -419,9 +445,9 @@ dependencies = [ [[package]] name = "der" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ "pem-rfc7468", "zeroize", @@ -432,9 +458,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derive_builder" @@ -454,7 +477,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -464,7 +487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -486,7 +509,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", "unicode-xid", ] @@ -498,7 +521,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", ] @@ -514,13 +537,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -585,15 +608,15 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "findshlibs" @@ -613,12 +636,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -651,9 +668,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -661,44 +678,44 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-io", @@ -743,12 +760,24 @@ name = "getrandom" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "wasip2", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -760,16 +789,16 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "h2" -version = "0.4.14" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.0", + "http 1.5.0", "indexmap", "slab", "tokio", @@ -789,7 +818,7 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -818,12 +847,12 @@ dependencies = [ "sentry-anyhow", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "tokio-util", "tower", - "tower-http", + "tower-http 0.7.1", "tracing", "tracing-subscriber", ] @@ -858,9 +887,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -868,23 +897,23 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http 1.4.0", + "http 1.5.0", ] [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.5.0", "http-body", "pin-project-lite", ] @@ -903,16 +932,16 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "h2", - "http 1.4.0", + "http 1.5.0", "http-body", "httparse", "httpdate", @@ -929,7 +958,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.0", + "http 1.5.0", "hyper", "hyper-util", "rustls", @@ -961,18 +990,18 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", - "http 1.4.0", + "http 1.5.0", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2", "tokio", "tower-service", "tracing", @@ -980,9 +1009,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -994,9 +1023,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1007,9 +1036,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1021,16 +1050,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1041,15 +1071,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1089,15 +1119,15 @@ dependencies = [ [[package]] name = "impl-more" -version = "0.1.9" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" +checksum = "277ff51754a3f68f12f58446c5d006aa8baa4914ea273cce24a599cfaff33d4f" [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -1105,9 +1135,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "itoa" @@ -1117,13 +1147,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -1152,9 +1181,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "linux-raw-sys" @@ -1164,9 +1193,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "local-waker" @@ -1185,9 +1214,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "loom" @@ -1225,9 +1254,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "metrics" @@ -1245,7 +1274,7 @@ version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" dependencies = [ - "base64", + "base64 0.22.1", "evmap", "http-body-util", "hyper", @@ -1255,7 +1284,7 @@ dependencies = [ "metrics", "metrics-util", "quanta", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -1271,7 +1300,7 @@ dependencies = [ "hashbrown 0.16.1", "metrics", "quanta", - "rand", + "rand 0.9.5", "rand_xoshiro", "rapidhash", "sketches-ddsketch", @@ -1304,9 +1333,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", @@ -1534,14 +1563,14 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openrouter-rs" -version = "0.12.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25918cfeef40cfd3adea348b268496ee5206eb0408f2aacfe4bdfef2bfbe3bf9" +checksum = "042920aeb3955ae7c6c26118c646f9723c4ba39e65ceb663513c6973ebfc21dc" dependencies = [ "derive_builder", "dotenvy_macro", "futures-util", - "http 1.4.0", + "http 1.5.0", "reqwest 0.12.28", "schemars", "serde", @@ -1554,9 +1583,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.80" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags", "cfg-if", @@ -1574,7 +1603,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1585,9 +1614,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.116" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -1666,7 +1695,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1677,21 +1706,21 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -1713,9 +1742,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -1737,9 +1766,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -1748,8 +1777,8 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", - "thiserror 2.0.18", + "socket2", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -1757,20 +1786,21 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -1778,23 +1808,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1806,13 +1836,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "rand" -version = "0.9.4" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -1822,7 +1869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1834,13 +1881,28 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xoshiro" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" dependencies = [ - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1872,29 +1934,29 @@ dependencies = [ [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1904,9 +1966,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1931,11 +1993,11 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", - "http 1.4.0", + "http 1.5.0", "http-body", "http-body-util", "hyper", @@ -1957,7 +2019,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", @@ -1973,17 +2035,15 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", "futures-util", - "h2", - "http 1.4.0", + "http 1.5.0", "http-body", "http-body-util", "hyper", - "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", @@ -1998,7 +2058,7 @@ dependencies = [ "tokio", "tokio-native-tls", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", @@ -2022,15 +2082,15 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -2056,9 +2116,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "once_cell", "ring", @@ -2070,9 +2130,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -2080,9 +2140,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -2091,9 +2151,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -2112,9 +2172,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -2125,14 +2185,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -2178,9 +2238,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "sentry" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "931a20b0da02350676e3d6d3c9028d58eaa448cf42a866712eec5845a505421e" +checksum = "76a88b65feb368d9dde5d531a2b59b25016e36fd6e4978432371a3ee77746ca2" dependencies = [ "cfg_aliases", "httpdate", @@ -2191,6 +2251,7 @@ dependencies = [ "sentry-contexts", "sentry-core", "sentry-debug-images", + "sentry-log", "sentry-panic", "sentry-tower", "sentry-tracing", @@ -2200,9 +2261,9 @@ dependencies = [ [[package]] name = "sentry-actix" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ffb8fd78b8f4527146013ab52293e03242770a31dcd97eee4b82b7f770ffab3" +checksum = "6b59b6298f8b1c621e7e711050f7ae9620b972215eb2822db2bfc4b061ed0cd2" dependencies = [ "actix-http", "actix-web", @@ -2213,9 +2274,9 @@ dependencies = [ [[package]] name = "sentry-anyhow" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b8bb4a03e7cf5562ea7ff658b2a254c06d2e09b2ec7383dfd5fc7025d40227b" +checksum = "34e4c4b2e5bf7bb63f8223eb4f6d24f64e2c18b4d01fdeb3d561a951e057425b" dependencies = [ "anyhow", "sentry-backtrace", @@ -2224,9 +2285,9 @@ dependencies = [ [[package]] name = "sentry-backtrace" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911ee36abf5b7fa335fccd5f54361ba9c16baea5f0c3bb361a687b6c195c21cf" +checksum = "2c51511d67ed670266a93afeaa26a08019b04ad1420cb9191da30f2338d22c48" dependencies = [ "backtrace", "regex", @@ -2235,9 +2296,9 @@ dependencies = [ [[package]] name = "sentry-contexts" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b9d7d469e9e22741c17ca23fb8b42d79861590eb7cf330f3da34fc1e4bc1bc6" +checksum = "4b757ac1f335856e8d7f5062a1fd9bc07a05a7eb3cc48247db79bf2618a490bd" dependencies = [ "hostname", "libc", @@ -2249,11 +2310,11 @@ dependencies = [ [[package]] name = "sentry-core" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "545dc562b6758d646ac19e1407f4ebc26d452111386743e03323464bc48bb2e0" +checksum = "d889520a375e5b93efb0a66def1630d21d168b0251eb55b286b03fa940ccfc84" dependencies = [ - "rand", + "rand 0.9.5", "sentry-types", "serde", "serde_json", @@ -2262,19 +2323,30 @@ dependencies = [ [[package]] name = "sentry-debug-images" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "660e9def38a573a869a182f7e90f58aaaa460f38b92b31fd1755ec537193bb48" +checksum = "656487fd5f7b7c0105b2e82171982aac64489e0cb679436038febf070f2f76d6" dependencies = [ "findshlibs", "sentry-core", ] [[package]] -name = "sentry-panic" -version = "0.48.2" +name = "sentry-log" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "772d9de150c8ca910c835353c85f434457348fdd21208f9b3da3574202b1dc5d" +checksum = "ba6c1bee99be05938885266fe5c95e0e4a2f46722cae205519f26e4ebdddd733" +dependencies = [ + "bitflags", + "log", + "sentry-core", +] + +[[package]] +name = "sentry-panic" +version = "0.49.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e0fe456a7380e9df875a1ef4df1e468d35b19b9051599845fab9d8f0c71c4ae" dependencies = [ "sentry-backtrace", "sentry-core", @@ -2282,12 +2354,12 @@ dependencies = [ [[package]] name = "sentry-tower" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2abea154597936d5df2d39fbe8aac16d584de6b3572c70c39558764d9d2efe15" +checksum = "d3481bd6aabb99f331e5f32c0fba7c2c22f41282219e7011e100263cb47e335f" dependencies = [ "axum", - "http 1.4.0", + "http 1.5.0", "pin-project", "sentry-core", "tower-layer", @@ -2297,9 +2369,9 @@ dependencies = [ [[package]] name = "sentry-tracing" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51ec9620a4d398dcdf7ee90effbf8d8691cfa24e91978bfa8565cac039d4980" +checksum = "77796d14eedbef22c2fe78e9c69fb09f14c7ad607e624d48dce92eedc17a136e" dependencies = [ "bitflags", "sentry-backtrace", @@ -2310,16 +2382,16 @@ dependencies = [ [[package]] name = "sentry-types" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "041359745a44dd2e14fe21b7510fe7ca8b5beffce6636a0b52e5bc7d5f736887" +checksum = "fc71f5ca55942d9b2901af95d5df5c5d65c164134c22a83682cac3d0b6c7ef2d" dependencies = [ "debugid", "hex", - "rand", + "rand 0.9.5", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "url", "uuid", @@ -2327,9 +2399,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -2337,40 +2409,40 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] name = "serde_derive_internals" -version = "0.29.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -2413,9 +2485,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -2441,25 +2513,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.5.10" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2496,9 +2558,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -2522,7 +2595,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2532,7 +2605,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2549,11 +2622,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -2564,37 +2637,36 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -2604,15 +2676,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -2620,9 +2692,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -2630,9 +2702,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -2655,20 +2727,20 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -2693,9 +2765,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -2704,13 +2776,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -2740,16 +2813,32 @@ dependencies = [ "bitflags", "bytes", "futures-util", - "http 1.4.0", + "http 1.5.0", "http-body", "pin-project-lite", "tower", "tower-layer", "tower-service", - "tracing", "url", ] +[[package]] +name = "tower-http" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" +dependencies = [ + "bitflags", + "bytes", + "http 1.5.0", + "http-body", + "percent-encoding", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2782,7 +2871,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2871,11 +2960,11 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" dependencies = [ - "base64", + "base64 0.23.1", "der", "log", "native-tls", @@ -2888,12 +2977,12 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" dependencies = [ - "base64", - "http 1.4.0", + "base64 0.23.1", + "http 1.5.0", "httparse", "log", ] @@ -2931,9 +3020,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.3" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "js-sys", "serde_core", @@ -2969,18 +3058,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -2991,9 +3080,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -3001,9 +3090,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3011,22 +3100,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -3046,9 +3135,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -3066,18 +3155,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -3125,16 +3214,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -3152,31 +3232,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -3185,96 +3248,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "wit-bindgen" version = "0.57.1" @@ -3283,15 +3298,15 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3306,28 +3321,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3347,21 +3362,21 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -3370,9 +3385,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -3381,17 +3396,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 0365198..672a89c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,16 +13,16 @@ tokio-util = "0.7" futures-util = "0.3" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } -sentry = { version = "0.48", features = ["tower-axum-matched-path"] } -sentry-anyhow = { version = "0.48", features = ["backtrace"] } -openrouter-rs = "0.12" +sentry = { version = "0.49", features = ["tower-axum-matched-path"] } +sentry-anyhow = { version = "0.49", features = ["backtrace"] } +openrouter-rs = "0.14" dotenvy = "0.15" tower = "0.5" -tower-http = { version = "0.6", features = ["trace"] } +tower-http = { version = "0.7", features = ["trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } axum = "0.8" -anyhow = { version = "1.0", features = ["backtrace"] } +anyhow = { version = "1", features = ["backtrace"] } thiserror = "2.0" ring = "0.17" hex = "0.4" diff --git a/crates/herald-server/src/main.rs b/crates/herald-server/src/main.rs index 3a5ab03..366eab1 100644 --- a/crates/herald-server/src/main.rs +++ b/crates/herald-server/src/main.rs @@ -36,14 +36,12 @@ fn main() -> anyhow::Result<()> { let _sentry_guard = if let Ok(sentry_dsn) = env::try_get_env("SENTRY_DSN") { info!("Initialize sentry"); - Some(sentry::init(( - sentry_dsn, - sentry::ClientOptions { - release: sentry::release_name!(), - send_default_pii: true, - ..Default::default() - }, - ))) + Some(sentry::init( + sentry::ClientOptions::new() + .dsn(&sentry_dsn) + .maybe_release(sentry::release_name!()) + .send_default_pii(true), + )) } else { warn!("SENTRY_DSN not set, sentry will not be initialized"); None From 99d1c2feefe4503b290b532e79d230a53a9845ec Mon Sep 17 00:00:00 2001 From: qpismont Date: Thu, 17 Sep 2026 13:16:15 +0000 Subject: [PATCH 09/17] Add sandbox --- .env.example | 6 +- Cargo.lock | 1 + README.md | 24 + crates/devcontainer-rs/src/container.rs | 649 ++++++++++++++++++ crates/devcontainer-rs/src/lib.rs | 102 ++- crates/herald-server/Cargo.toml | 3 +- crates/herald-server/src/bot.rs | 8 + .../herald-server/src/bot_actions/review.rs | 81 ++- crates/herald-server/src/consts.rs | 10 + crates/herald-server/src/env.rs | 15 + crates/herald-server/src/gitea.rs | 16 + crates/herald-server/src/main.rs | 16 + crates/herald-server/src/open_router.rs | 44 +- crates/herald-server/src/sandbox/agent.rs | 129 ++++ crates/herald-server/src/sandbox/mod.rs | 197 ++++++ crates/herald-server/src/sandbox/tools.rs | 248 +++++++ 16 files changed, 1537 insertions(+), 12 deletions(-) create mode 100644 crates/devcontainer-rs/src/container.rs create mode 100644 crates/herald-server/src/sandbox/agent.rs create mode 100644 crates/herald-server/src/sandbox/mod.rs create mode 100644 crates/herald-server/src/sandbox/tools.rs diff --git a/.env.example b/.env.example index 06a8c62..cabc5a0 100644 --- a/.env.example +++ b/.env.example @@ -18,5 +18,9 @@ SENTRY_DSN= RUST_LOG=info RUST_BACKTRACE=1 +METRICS_BIND_ADDR= -METRICS_BIND_ADDR= \ No newline at end of file +# Sandboxed tool execution (optional) +SANDBOX_ENABLED=false +CONTAINER_RUNTIME=docker +SANDBOX_MAX_ITERATIONS=8 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 4989bb3..02d3b25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -847,6 +847,7 @@ dependencies = [ "sentry-anyhow", "serde", "serde_json", + "tempfile", "thiserror 2.0.20", "tokio", "tokio-stream", diff --git a/README.md b/README.md index 1c1de1e..a749dfd 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,30 @@ 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` | +| `SANDBOX_ENABLED` | *(optional)* Run reviews inside a devcontainer sandbox so the model can explore the repository with tools. Defaults to `false` | +| `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` | + +## Sandboxed reviews + +When `SANDBOX_ENABLED=true`, Herald reviews pull requests inside an ephemeral +[Dev Container](https://containers.dev/). For each review it: + +1. clones the pull request head into a temporary directory, +2. builds and starts the repository's devcontainer (`devcontainer-rs`), +3. lets the model explore the repository with read-only tools (`ls`, `read_file`, + `grep`, `find`) executed inside the container, +4. posts the review 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`. + +Each sandbox is isolated: it gets its own image tag, container and network. The +container starts with network access so the `postCreateCommand` / +`postStartCommand` hooks can install dependencies (e.g. `npm install`); once the +hooks have run, the container is disconnected from the network for the rest of +the review. Every container command is bounded by a timeout, and the container, +network and image are removed when the review ends (including on failure). ## Development diff --git a/crates/devcontainer-rs/src/container.rs b/crates/devcontainer-rs/src/container.rs new file mode 100644 index 0000000..a2ce142 --- /dev/null +++ b/crates/devcontainer-rs/src/container.rs @@ -0,0 +1,649 @@ +//! Container lifecycle primitives for a parsed [`DevContainer`]. +//! +//! This module shells out to a container runtime (`docker` or `podman`) to build +//! the devcontainer image, start a container with the workspace mounted, run the +//! `postCreateCommand` / `postStartCommand` hooks and execute commands inside the +//! running container. +//! +//! It is intentionally runtime-agnostic: any binary exposing the `docker` CLI +//! surface (including `podman`) can be used via [`ContainerRuntime::new`]. +//! +//! # Isolation +//! +//! Each sandbox gets its own image tag, its own container and its own network. +//! The container starts attached to that network so the `postCreateCommand` / +//! `postStartCommand` hooks can fetch dependencies (e.g. `npm install`); once the +//! hooks have run, the container is disconnected from the network for the rest of +//! its lifetime. Every command is bounded by a timeout. + +use std::{ + path::{Path, PathBuf}, + process::Stdio, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use tokio::process::Command; + +use crate::DevContainer; + +/// Timeout applied to build/run/stop/remove operations. +const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(600); + +/// Timeout applied to commands executed inside a running container. +const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(60); + +/// Result of a command executed inside a container. +#[derive(Debug, Clone)] +pub struct ExecOutput { + /// Exit code, or `-1` if the process was terminated by a signal. + pub status: i32, + pub stdout: String, + pub stderr: String, +} + +impl ExecOutput { + pub fn success(&self) -> bool { + self.status == 0 + } + + /// Turns a non-zero exit code into a [`ContainerError::Command`]. + pub fn ensure_success(self, program: &str, args: &[String]) -> Result { + if self.success() { + return Ok(self); + } + + Err(ContainerError::Command { + program: program.to_string(), + args: args.join(" "), + status: self.status, + stderr: self.stderr.trim().to_string(), + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ContainerError { + #[error("failed to run `{program}`: {source}")] + Spawn { + program: String, + source: std::io::Error, + }, + + #[error("`{program} {args}` failed with status {status}: {stderr}")] + Command { + program: String, + args: String, + status: i32, + stderr: String, + }, + + #[error("`{program} {args}` timed out after {timeout:?}")] + Timeout { + program: String, + args: String, + timeout: Duration, + }, +} + +/// A container runtime binary exposing the `docker` CLI surface. +#[derive(Debug, Clone)] +pub struct ContainerRuntime { + program: String, + timeout: Duration, +} + +impl ContainerRuntime { + pub fn new(program: impl Into) -> Self { + Self { + program: program.into(), + timeout: DEFAULT_COMMAND_TIMEOUT, + } + } + + pub fn docker() -> Self { + Self::new("docker") + } + + pub fn podman() -> Self { + Self::new("podman") + } + + /// Overrides the timeout applied to build/run/stop/remove operations. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + pub fn program(&self) -> &str { + &self.program + } + + pub fn timeout(&self) -> Duration { + self.timeout + } + + /// Checks that the runtime binary is present and responsive. + pub async fn available(&self) -> bool { + Command::new(&self.program) + .arg("version") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .map(|status| status.success()) + .unwrap_or(false) + } + + /// Runs the runtime with the given arguments, capturing stdout/stderr. + /// + /// Only spawn failures and timeouts are reported as errors; a non-zero exit + /// code is returned in the [`ExecOutput`] so callers can decide how to react. + pub async fn run(&self, args: &[String]) -> Result { + self.run_with_timeout(args, self.timeout).await + } + + /// Like [`run`](Self::run) with an explicit timeout. + pub async fn run_with_timeout( + &self, + args: &[String], + timeout: Duration, + ) -> Result { + let output = Command::new(&self.program) + .args(args) + .stdin(Stdio::null()) + .kill_on_drop(true) + .output(); + + match tokio::time::timeout(timeout, output).await { + Ok(Ok(output)) => Ok(ExecOutput { + status: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }), + Ok(Err(source)) => Err(ContainerError::Spawn { + program: self.program.clone(), + source, + }), + Err(_) => Err(ContainerError::Timeout { + program: self.program.clone(), + args: args.join(" "), + timeout, + }), + } + } +} + +/// A running devcontainer. +#[derive(Debug, Clone)] +pub struct Container { + runtime: ContainerRuntime, + name: String, + workspace_folder: String, + remote_user: Option, + network: Option, + image: Option, +} + +impl Container { + pub fn name(&self) -> &str { + &self.name + } + + pub fn workspace_folder(&self) -> &str { + &self.workspace_folder + } + + /// Executes a command inside the container, returning its output. + /// + /// The command is passed as an argv vector (no shell), so no quoting or + /// interpolation is performed. + pub async fn exec(&self, cmd: &[&str]) -> Result { + self.exec_with_timeout(cmd, DEFAULT_EXEC_TIMEOUT).await + } + + async fn exec_with_timeout( + &self, + cmd: &[&str], + timeout: Duration, + ) -> Result { + let mut args = vec!["exec".to_string()]; + + if let Some(user) = &self.remote_user { + args.push("--user".to_string()); + args.push(user.clone()); + } + + args.push(self.name.clone()); + args.extend(cmd.iter().map(|arg| arg.to_string())); + + self.runtime.run_with_timeout(&args, timeout).await + } + + /// Executes a shell script inside the container via `sh -c`. + pub async fn exec_shell(&self, script: &str) -> Result { + self.exec(&["sh", "-c", script]).await + } + + /// Stops the container. + pub async fn stop(&self) -> Result<(), ContainerError> { + let args = vec!["stop".to_string(), self.name.clone()]; + self.runtime + .run(&args) + .await? + .ensure_success(self.runtime.program(), &args)?; + Ok(()) + } + + /// Removes the container, its network and its image. + /// + /// Network and image removal are best-effort: they may already be gone. + pub async fn remove(&self) -> Result<(), ContainerError> { + let args = vec![ + "rm".to_string(), + "-f".to_string(), + "-v".to_string(), + self.name.clone(), + ]; + self.runtime + .run(&args) + .await? + .ensure_success(self.runtime.program(), &args)?; + + if let Some(network) = &self.network { + let args = vec!["network".to_string(), "rm".to_string(), network.clone()]; + let _ = self.runtime.run(&args).await; + } + + if let Some(image) = &self.image { + let args = vec!["rmi".to_string(), image.clone()]; + let _ = self.runtime.run(&args).await; + } + + Ok(()) + } +} + +impl DevContainer { + /// Base image name derived from the devcontainer name. + pub fn image_name(&self) -> String { + let base = self.name.as_deref().unwrap_or("devcontainer"); + format!("devcontainer-rs/{}", sanitize(base)) + } + + /// Unique image tag for a single sandbox run. + /// + /// Uniqueness matters: two concurrent sandboxes (possibly for different + /// repositories sharing a devcontainer name) must not race on a shared tag. + pub fn image_tag(&self) -> String { + format!("{}:{}", self.image_name(), unique_suffix()) + } + + /// Arguments passed to `docker build` (everything after the `build` verb). + pub fn build_args(&self, image_tag: &str) -> Vec { + let context = self + .container_file_path + .parent() + .unwrap_or_else(|| Path::new(".")); + + let mut args = vec![ + "-f".to_string(), + self.container_file_path.display().to_string(), + "-t".to_string(), + image_tag.to_string(), + ]; + + for (key, value) in &self.build_args { + args.push("--build-arg".to_string()); + args.push(format!("{key}={value}")); + } + + args.push(context.display().to_string()); + args + } + + /// Arguments passed to `docker run` (everything after the `run` verb). + pub fn run_args( + &self, + workspace_dir: &Path, + container_name: &str, + image_tag: &str, + network: Option<&str>, + ) -> Vec { + let workspace_folder = self.workspace_folder(); + + let mut args = vec![ + "-d".to_string(), + "--name".to_string(), + container_name.to_string(), + "-v".to_string(), + format!("{}:{}", workspace_dir.display(), workspace_folder), + "-w".to_string(), + workspace_folder.to_string(), + ]; + + if let Some(network) = network { + args.push("--network".to_string()); + args.push(network.to_string()); + } + + if let Some(user) = &self.remote_user { + args.push("--user".to_string()); + args.push(user.clone()); + } + + for (key, value) in &self.container_env { + args.push("-e".to_string()); + args.push(format!("{key}={value}")); + } + + args.extend(self.run_args.iter().cloned()); + + args.push(image_tag.to_string()); + // Keep the container alive so we can `exec` into it. + args.push("sleep".to_string()); + args.push("infinity".to_string()); + + args + } + + /// Workspace folder inside the container, defaulting to `/workspaces/workspace`. + pub fn workspace_folder(&self) -> String { + self.workspace_folder + .clone() + .unwrap_or_else(|| "/workspaces/workspace".to_string()) + } + + /// Unique container name for this run. + pub fn container_name(&self) -> String { + let base = self.name.as_deref().unwrap_or("devcontainer"); + format!("devcontainer-rs-{}-{}", sanitize(base), unique_suffix()) + } + + /// Builds the devcontainer image under `image_tag`. + pub async fn build( + &self, + runtime: &ContainerRuntime, + image_tag: &str, + ) -> Result<(), ContainerError> { + let mut args = vec!["build".to_string()]; + args.extend(self.build_args(image_tag)); + + runtime + .run(&args) + .await? + .ensure_success(runtime.program(), &args)?; + + Ok(()) + } + + /// Builds the image, starts the container with the workspace mounted, runs + /// the `postCreateCommand` / `postStartCommand` hooks with network access, + /// then disconnects the container from the network. + /// + /// On any failure the container, network and image are cleaned up before + /// returning, so no resource is leaked. + pub async fn up( + &self, + runtime: &ContainerRuntime, + workspace_dir: &Path, + ) -> Result { + let image_tag = self.image_tag(); + self.build(runtime, &image_tag).await?; + + let name = self.container_name(); + let network = format!("{name}-net"); + + // Dedicated network so connectivity can be cut after the hooks. + let args = vec!["network".to_string(), "create".to_string(), network.clone()]; + if let Err(err) = runtime + .run(&args) + .await + .and_then(|out| out.ensure_success(runtime.program(), &args)) + { + let _ = runtime.run(&["rmi".to_string(), image_tag]).await; + return Err(err); + } + + let mut args = vec!["run".to_string()]; + args.extend(self.run_args(workspace_dir, &name, &image_tag, Some(&network))); + + if let Err(err) = runtime + .run(&args) + .await + .and_then(|out| out.ensure_success(runtime.program(), &args)) + { + let _ = runtime + .run(&["network".to_string(), "rm".to_string(), network]) + .await; + let _ = runtime.run(&["rmi".to_string(), image_tag]).await; + return Err(err); + } + + let container = Container { + runtime: runtime.clone(), + name, + workspace_folder: self.workspace_folder(), + remote_user: self.remote_user.clone(), + network: Some(network.clone()), + image: Some(image_tag), + }; + + // Hooks run with network access (dependency installation, etc.). + if let Err(err) = self.run_hooks(&container).await { + let _ = container.remove().await; + return Err(err); + } + + // Cut network access for the rest of the sandbox lifetime. + let args = vec![ + "network".to_string(), + "disconnect".to_string(), + network, + container.name.clone(), + ]; + if let Err(err) = runtime + .run(&args) + .await + .and_then(|out| out.ensure_success(runtime.program(), &args)) + { + let _ = container.remove().await; + return Err(err); + } + + Ok(container) + } + + async fn run_hooks(&self, container: &Container) -> Result<(), ContainerError> { + // Hooks may install dependencies, so they get the long command timeout + // rather than the short one used for tool execution. + let timeout = container.runtime.timeout(); + + for command in [&self.post_create_command, &self.post_start_command] + .into_iter() + .flatten() + { + let output = container + .exec_with_timeout(&["sh", "-c", command], timeout) + .await?; + output.ensure_success( + container.runtime.program(), + &["exec".to_string(), command.clone()], + )?; + } + + Ok(()) + } +} + +/// Sanitizes a string so it can be used as a docker image/container name. +fn sanitize(input: &str) -> String { + let sanitized: String = input + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect(); + + let trimmed = sanitized.trim_matches(|c| c == '-' || c == '.' || c == '_'); + if trimmed.is_empty() { + "devcontainer".to_string() + } else { + trimmed.to_string() + } +} + +/// Suffix unique to a sandbox run, combining the process id and a timestamp. +fn unique_suffix() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + + format!("{}-{}", std::process::id(), nanos) +} + +/// Lexically normalizes a path, resolving `.` and `..` without touching the +/// filesystem. Returns `None` if the path escapes its root. +pub fn normalize(path: &Path) -> Option { + use std::path::Component; + + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::RootDir => out.push("/"), + Component::CurDir => {} + Component::ParentDir => { + if !out.pop() { + return None; + } + } + Component::Normal(part) => out.push(part), + Component::Prefix(_) => return None, + } + } + + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn devcontainer(dir: &Path) -> DevContainer { + let devcontainer_path = dir.join("devcontainer.json"); + let dockerfile_path = dir.join("Dockerfile"); + + fs::write(&dockerfile_path, "FROM alpine\n").unwrap(); + fs::write( + &devcontainer_path, + r#"{ + "name": "My Project", + "build": { + "dockerfile": "Dockerfile", + "args": { "VERSION": "1" } + }, + "workspaceFolder": "/workspaces/my-project", + "containerEnv": { "RUST_LOG": "debug" }, + "remoteUser": "dev", + "runArgs": ["--userns=keep-id"] + }"#, + ) + .unwrap(); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(crate::parse(&devcontainer_path)).unwrap() + } + + #[test] + fn image_name_is_sanitized() { + let dir = tempfile::tempdir().unwrap(); + let dc = devcontainer(dir.path()); + assert_eq!(dc.image_name(), "devcontainer-rs/my-project"); + } + + #[test] + fn image_tags_are_unique() { + let dir = tempfile::tempdir().unwrap(); + let dc = devcontainer(dir.path()); + assert_ne!(dc.image_tag(), dc.image_tag()); + } + + #[test] + fn build_args_include_dockerfile_tag_build_args_and_context() { + let dir = tempfile::tempdir().unwrap(); + let dc = devcontainer(dir.path()); + let args = dc.build_args("devcontainer-rs/my-project:test"); + + assert_eq!(args[0], "-f"); + assert!(args[1].ends_with("Dockerfile")); + assert_eq!(args[2], "-t"); + assert_eq!(args[3], "devcontainer-rs/my-project:test"); + assert!(args.contains(&"--build-arg".to_string())); + assert!(args.contains(&"VERSION=1".to_string())); + assert_eq!(args.last().unwrap(), &dir.path().display().to_string()); + } + + #[test] + fn run_args_mount_workspace_and_keep_alive() { + let dir = tempfile::tempdir().unwrap(); + let dc = devcontainer(dir.path()); + let workspace = Path::new("/tmp/clone"); + let args = dc.run_args( + workspace, + "devcontainer-rs-my-project-42", + "devcontainer-rs/my-project:test", + Some("sandbox-net"), + ); + + assert!(args.contains(&"-d".to_string())); + assert!(args.contains(&"--name".to_string())); + assert!(args.contains(&"devcontainer-rs-my-project-42".to_string())); + assert!(args.contains(&"/tmp/clone:/workspaces/my-project".to_string())); + assert!(args.contains(&"--network".to_string())); + assert!(args.contains(&"sandbox-net".to_string())); + assert!(args.contains(&"--user".to_string())); + assert!(args.contains(&"dev".to_string())); + assert!(args.contains(&"RUST_LOG=debug".to_string())); + assert!(args.contains(&"--userns=keep-id".to_string())); + assert!(args.contains(&"devcontainer-rs/my-project:test".to_string())); + assert_eq!( + &args[args.len() - 2..], + &["sleep".to_string(), "infinity".to_string()] + ); + } + + #[test] + fn normalize_rejects_escaping_paths() { + assert_eq!( + normalize(Path::new("/workspaces/project/src/../main.rs")), + Some(PathBuf::from("/workspaces/project/main.rs")) + ); + assert_eq!(normalize(Path::new("/workspaces/../../etc/passwd")), None); + } + + #[tokio::test] + async fn run_captures_output() { + let runtime = ContainerRuntime::new("echo"); + let output = runtime.run(&["hello".to_string()]).await.unwrap(); + + assert!(output.success()); + assert_eq!(output.stdout.trim(), "hello"); + } + + #[tokio::test] + async fn run_times_out_and_kills_the_process() { + let runtime = ContainerRuntime::new("sleep"); + let err = runtime + .run_with_timeout(&["10".to_string()], Duration::from_millis(50)) + .await + .unwrap_err(); + + assert!(matches!(err, ContainerError::Timeout { .. })); + } +} diff --git a/crates/devcontainer-rs/src/lib.rs b/crates/devcontainer-rs/src/lib.rs index 81960de..8743f0a 100644 --- a/crates/devcontainer-rs/src/lib.rs +++ b/crates/devcontainer-rs/src/lib.rs @@ -5,6 +5,10 @@ use std::{ use serde::Deserialize; +mod container; + +pub use container::{Container, ContainerError, ContainerRuntime, ExecOutput, normalize}; + #[derive(Debug, Deserialize)] pub struct DevContainerBuildSchema { #[serde(default)] @@ -30,6 +34,12 @@ pub struct DevContainerSchema { #[serde(rename = "postStartCommand", default)] pub post_start_command: Option, + + #[serde(rename = "remoteUser", default)] + pub remote_user: Option, + + #[serde(rename = "runArgs", default)] + pub run_args: Vec, } #[derive(Debug)] @@ -41,6 +51,8 @@ pub struct DevContainer { pub workspace_folder: Option, pub post_create_command: Option, pub post_start_command: Option, + pub remote_user: Option, + pub run_args: Vec, } #[derive(Debug, thiserror::Error)] @@ -80,18 +92,72 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer { return Err(ParseError::ContainerFileNotFound(container_file_path)); } + let build_args = schema + .build + .args + .into_iter() + .map(|(k, v)| (k, substitute_local_env(&v))) + .collect(); + + let container_env = schema + .container_env + .into_iter() + .map(|(k, v)| (k, substitute_local_env(&v))) + .collect(); + Ok(Self { container_file_path, name: schema.name, - build_args: schema.build.args, - container_env: schema.container_env, + build_args, + container_env, workspace_folder: schema.workspace_folder, post_create_command: schema.post_create_command, post_start_command: schema.post_start_command, + remote_user: schema.remote_user, + run_args: schema.run_args, }) } } +/// Resolves `${localEnv:VAR}` and `${localEnv:VAR:default}` references using the +/// current process environment, as described by the devcontainer specification. +/// Unresolved variables without a default expand to an empty string. +fn substitute_local_env(input: &str) -> String { + const PREFIX: &str = "${localEnv:"; + + let mut out = String::with_capacity(input.len()); + let mut rest = input; + + while let Some(start) = rest.find(PREFIX) { + out.push_str(&rest[..start]); + let after = &rest[start + PREFIX.len()..]; + + match after.find('}') { + Some(end) => { + let inner = &after[..end]; + let (key, default) = match inner.split_once(':') { + Some((key, default)) => (key, Some(default)), + None => (inner, None), + }; + + match std::env::var(key) { + Ok(value) => out.push_str(&value), + Err(_) => out.push_str(default.unwrap_or("")), + } + + rest = &after[end + 1..]; + } + None => { + out.push_str(PREFIX); + rest = after; + } + } + } + + out.push_str(rest); + out +} + pub async fn parse(path: impl AsRef) -> Result { let path = path.as_ref().to_path_buf(); let contents = tokio::fs::read_to_string(&path) @@ -136,7 +202,9 @@ mod tests { "workspaceFolder": "/workspace", "containerEnv": { "RUST_LOG": "debug" - } + }, + "remoteUser": "dev", + "runArgs": ["--userns=keep-id"] }"#, ) .unwrap(); @@ -148,5 +216,33 @@ mod tests { assert_eq!(config.build_args.get("VERSION").unwrap(), "1"); 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] + fn substitutes_local_env_with_default() { + unsafe { std::env::set_var("DEVCONTAINER_TEST_UID", "1000") }; + + assert_eq!( + substitute_local_env("${localEnv:DEVCONTAINER_TEST_UID}"), + "1000" + ); + assert_eq!( + substitute_local_env("uid=${localEnv:DEVCONTAINER_TEST_UID}"), + "uid=1000" + ); + assert_eq!( + substitute_local_env("${localEnv:DEVCONTAINER_TEST_MISSING:fallback}"), + "fallback" + ); + assert_eq!( + substitute_local_env("${localEnv:DEVCONTAINER_TEST_MISSING}"), + "" + ); + assert_eq!( + substitute_local_env("no variables here"), + "no variables here" + ); } } diff --git a/crates/herald-server/Cargo.toml b/crates/herald-server/Cargo.toml index 8e015d9..9635007 100644 --- a/crates/herald-server/Cargo.toml +++ b/crates/herald-server/Cargo.toml @@ -27,4 +27,5 @@ hex = { workspace = true } bytes = { workspace = true } metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } -devcontainer-rs = { path = "../devcontainer-rs" } \ No newline at end of file +devcontainer-rs = { path = "../devcontainer-rs" } +tempfile = "3" \ No newline at end of file diff --git a/crates/herald-server/src/bot.rs b/crates/herald-server/src/bot.rs index 0a4482c..ae1029e 100644 --- a/crates/herald-server/src/bot.rs +++ b/crates/herald-server/src/bot.rs @@ -2,6 +2,7 @@ use crate::{ gitea::{GiteaAPI, WebhookType}, metrics, open_router::OpenRouterClient, + sandbox::SandboxConfig, }; use serde::Deserialize; use std::{collections::HashSet, sync::Arc}; @@ -31,6 +32,7 @@ pub struct Bot { http_client: reqwest::Client, max_concurrent: usize, open_router_model: String, + sandbox: SandboxConfig, actions_handled: Arc>>, } @@ -42,6 +44,7 @@ impl Bot { http_client: reqwest::Client, max_concurrent: usize, open_router_model: String, + sandbox: SandboxConfig, ) -> Self { Self { bot_name, @@ -50,6 +53,7 @@ impl Bot { http_client, max_concurrent, open_router_model, + sandbox, actions_handled: Arc::new(Mutex::new(HashSet::new())), } } @@ -113,12 +117,16 @@ impl Bot { } }; + let tools = crate::sandbox::tools::for_webhook(&webhook); + let exec_result = match webhook { WebhookType::Review(review_payload) => crate::bot_actions::review::exec_review( &self.gitea_api, &self.open_router_client, &self.http_client, &self.open_router_model, + &self.sandbox, + tools, review_payload, ), } diff --git a/crates/herald-server/src/bot_actions/review.rs b/crates/herald-server/src/bot_actions/review.rs index 8ca234a..fdb39bb 100644 --- a/crates/herald-server/src/bot_actions/review.rs +++ b/crates/herald-server/src/bot_actions/review.rs @@ -1,22 +1,33 @@ use futures_util::stream::TryStreamExt; +use openrouter_rs::types::Tool; use tokio::io::AsyncReadExt; use tokio_util::io::StreamReader; -use tracing::instrument; +use tracing::{info, instrument, warn}; use crate::{ bot::ReviewResult, - consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT}, + consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT}, gitea::{GiteaAPI, ReviewPayload}, metrics, open_router::OpenRouterClient, + sandbox::{Sandbox, SandboxConfig, agent}, }; -#[instrument(skip(gitea_api, open_router_client, http_client, review_payload))] +#[instrument(skip( + gitea_api, + open_router_client, + http_client, + sandbox_config, + tools, + review_payload +))] pub async fn exec_review( gitea_api: &GiteaAPI, open_router_client: &OpenRouterClient, http_client: &reqwest::Client, model: &str, + sandbox_config: &SandboxConfig, + tools: Vec, review_payload: ReviewPayload, ) -> anyhow::Result<()> { tracing::info!( @@ -45,10 +56,24 @@ pub async fn exec_review( .replace("{comment}", &review_payload.comment.body) .replace("{diff}", &diff_for_llm); - let chat_result = open_router_client.chat(&bot_request).await?; - let mut review_result = serde_json::from_str::(&chat_result.message)?; + let (message, cost) = if sandbox_config.enabled { + run_sandboxed_review( + gitea_api, + open_router_client, + sandbox_config, + tools, + &review_payload, + &bot_request, + ) + .await? + } else { + let chat_result = open_router_client.chat(&bot_request).await?; + (chat_result.message, chat_result.cost) + }; - review_result.cost = chat_result.cost; + let mut review_result = serde_json::from_str::(&message)?; + + review_result.cost = cost; if let Some(cost) = review_result.cost { metrics::openrouter_cost_usd(cost); } @@ -86,6 +111,50 @@ pub async fn exec_review( } } +/// Runs the review inside a sandbox container, letting the model explore the +/// repository with tools before answering. +async fn run_sandboxed_review( + gitea_api: &GiteaAPI, + open_router_client: &OpenRouterClient, + sandbox_config: &SandboxConfig, + tools: Vec, + review_payload: &ReviewPayload, + bot_request: &str, +) -> anyhow::Result<(String, Option)> { + let repo_url = gitea_api.repo_clone_url(&review_payload.repository.full_name); + + let sandbox = Sandbox::create( + &sandbox_config.runtime, + &repo_url, + gitea_api.token(), + review_payload.pull_request.number, + ) + .await?; + + let result = agent::run( + open_router_client, + &sandbox, + tools, + SANDBOX_SYSTEM_PROMPT, + bot_request, + sandbox_config.max_iterations, + ) + .await; + + if let Err(err) = sandbox.cleanup().await { + warn!(%err, "Failed to clean up sandbox container"); + } + + let result = result?; + info!( + iterations = result.iterations, + cost = ?result.cost, + "Sandboxed review finished" + ); + + Ok((result.message, result.cost)) +} + fn review_result_to_markdown(review_result: &ReviewResult) -> String { if review_result.reviews.is_empty() { return String::from("No issues found. ✅"); diff --git a/crates/herald-server/src/consts.rs b/crates/herald-server/src/consts.rs index 9a38974..7dc6547 100644 --- a/crates/herald-server/src/consts.rs +++ b/crates/herald-server/src/consts.rs @@ -8,6 +8,16 @@ pub const BOT_PROCESS_MSG: &str = " Review in progress with the model \"{model}\"... "; +pub const SANDBOX_SYSTEM_PROMPT: &str = " + You are a senior software engineer reviewing a pull request. + + The repository is checked out in your working directory. Use the provided + 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. +"; + pub const REVIEW_PROMPT: &str = " You are a senior software engineer reviewing code changes. diff --git a/crates/herald-server/src/env.rs b/crates/herald-server/src/env.rs index 8aed750..6fa8ced 100644 --- a/crates/herald-server/src/env.rs +++ b/crates/herald-server/src/env.rs @@ -12,6 +12,9 @@ pub struct EnvConfig { pub gitea_token: String, pub gitea_timeout: u64, pub metrics_bind_addr: Option, + pub container_runtime: String, + pub sandbox_enabled: bool, + pub sandbox_max_iterations: usize, } pub fn load_config() -> anyhow::Result { @@ -25,6 +28,15 @@ pub fn load_config() -> anyhow::Result { 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_enabled = std::env::var("SANDBOX_ENABLED") + .map(|value| matches!(value.as_str(), "1" | "true" | "yes")) + .unwrap_or(false); + let sandbox_max_iterations = std::env::var("SANDBOX_MAX_ITERATIONS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(8); Ok(EnvConfig { http_port, @@ -37,6 +49,9 @@ pub fn load_config() -> anyhow::Result { gitea_token, gitea_timeout, metrics_bind_addr, + container_runtime, + sandbox_enabled, + sandbox_max_iterations, }) } diff --git a/crates/herald-server/src/gitea.rs b/crates/herald-server/src/gitea.rs index b6b48fb..a86123c 100644 --- a/crates/herald-server/src/gitea.rs +++ b/crates/herald-server/src/gitea.rs @@ -9,6 +9,7 @@ use crate::{bot::ReviewResult, errors::AppError}; #[derive(Clone)] pub struct GiteaAPI { base_url: String, + token: String, client: reqwest::Client, } @@ -22,6 +23,7 @@ impl GiteaAPI { Ok(Self { base_url: String::from(base_url), + token: String::from(token), client: reqwest::Client::builder() .timeout(Duration::from_secs(timeout)) .default_headers(default_headers) @@ -29,6 +31,20 @@ impl GiteaAPI { }) } + /// API token used to authenticate against Gitea. + pub fn token(&self) -> &str { + &self.token + } + + /// HTTPS clone URL for a repository, suitable for `git clone`. + pub fn repo_clone_url(&self, full_name: &str) -> String { + format!( + "{}/{}.git", + self.base_url.trim_end_matches('/'), + full_name.trim_start_matches('/') + ) + } + #[instrument(skip(self))] pub async fn get_authorized_user(&self) -> anyhow::Result { let url = format!("{}/api/v1/user", self.base_url); diff --git a/crates/herald-server/src/main.rs b/crates/herald-server/src/main.rs index 366eab1..2c313e4 100644 --- a/crates/herald-server/src/main.rs +++ b/crates/herald-server/src/main.rs @@ -2,6 +2,7 @@ use crate::{ bot::Bot, gitea::{GiteaAPI, WebhookType}, open_router::OpenRouterClient, + sandbox::SandboxConfig, state::AppState, }; @@ -20,6 +21,7 @@ mod errors; mod gitea; mod metrics; mod open_router; +mod sandbox; mod state; fn main() -> anyhow::Result<()> { @@ -76,6 +78,19 @@ async fn run() -> anyhow::Result<()> { let shutdown = CancellationToken::new(); + let sandbox = SandboxConfig { + enabled: config.sandbox_enabled, + runtime: devcontainer_rs::ContainerRuntime::new(config.container_runtime.clone()), + max_iterations: config.sandbox_max_iterations, + }; + + if sandbox.enabled && !sandbox.runtime.available().await { + warn!( + runtime = sandbox.runtime.program(), + "Sandbox is enabled but the container runtime is not available" + ); + } + let bot = Bot::new( gitea_user.login, gitea_api, @@ -83,6 +98,7 @@ async fn run() -> anyhow::Result<()> { reqwest::Client::new(), config.bot_max_concurrent, config.open_router_model.clone(), + sandbox, ); let (tx, rx) = tokio::sync::mpsc::channel::(config.bot_max_concurrent * 2); diff --git a/crates/herald-server/src/open_router.rs b/crates/herald-server/src/open_router.rs index 4320511..dd093ac 100644 --- a/crates/herald-server/src/open_router.rs +++ b/crates/herald-server/src/open_router.rs @@ -1,6 +1,10 @@ use std::time::Duration; -use openrouter_rs::{Message, api::chat::ChatCompletionRequest}; +use openrouter_rs::{ + Message, + api::chat::ChatCompletionRequest, + types::{Tool, ToolCall}, +}; use tracing::instrument; pub struct ChatResult { @@ -8,6 +12,12 @@ pub struct ChatResult { pub cost: Option, } +pub struct ToolChatResult { + pub message: Option, + pub tool_calls: Vec, + pub cost: Option, +} + #[derive(Clone)] pub struct OpenRouterClient { client: openrouter_rs::OpenRouterClient, @@ -47,4 +57,36 @@ impl OpenRouterClient { cost: response.usage.and_then(|u| u.cost), }) } + + /// Sends a conversation with tool definitions and returns either a final + /// message or the tool calls requested by the model. + #[instrument(skip(self, messages, tools), err)] + pub async fn chat_with_tools( + &self, + messages: Vec, + tools: Vec, + ) -> anyhow::Result { + let request = ChatCompletionRequest::builder() + .model(&self.model) + .enable_reasoning() + .messages(messages) + .tools(tools) + .tool_choice_auto() + .build()?; + + let response = self.client.chat().create(&request).await?; + let choice = response + .choices + .first() + .ok_or_else(|| anyhow::anyhow!("No choices in response"))?; + + Ok(ToolChatResult { + message: choice.content().map(String::from), + tool_calls: choice + .tool_calls() + .map(<[ToolCall]>::to_vec) + .unwrap_or_default(), + cost: response.usage.and_then(|u| u.cost), + }) + } } diff --git a/crates/herald-server/src/sandbox/agent.rs b/crates/herald-server/src/sandbox/agent.rs new file mode 100644 index 0000000..35d53c4 --- /dev/null +++ b/crates/herald-server/src/sandbox/agent.rs @@ -0,0 +1,129 @@ +//! Tool-calling loop driving the model against a [`Sandbox`]. +//! +//! The loop sends the conversation and the available tools to OpenRouter, +//! executes any requested tool call inside the sandbox and feeds the results +//! back, until the model produces a final message or the iteration budget is +//! exhausted. + +use anyhow::Context; +use openrouter_rs::{ + Message, + types::{Role, Tool, ToolCall}, +}; +use serde_json::Value; +use tracing::{debug, warn}; + +use crate::{ + open_router::OpenRouterClient, + sandbox::{Sandbox, tools}, +}; + +/// Final output of an agent run. +pub struct AgentResult { + pub message: String, + pub cost: Option, + pub iterations: usize, +} + +/// Runs the tool-calling loop until the model answers or `max_iterations` is +/// reached. +/// +/// `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( + open_router: &OpenRouterClient, + sandbox: &Sandbox, + tool_definitions: Vec, + system_prompt: &str, + user_prompt: &str, + max_iterations: usize, +) -> anyhow::Result { + let mut messages = vec![ + Message::new(Role::System, system_prompt), + Message::new(Role::User, user_prompt), + ]; + + let mut total_cost = 0.0_f64; + let mut has_cost = false; + + for iteration in 1..=max_iterations { + let response = open_router + .chat_with_tools(messages.clone(), tool_definitions.clone()) + .await?; + + if let Some(cost) = response.cost { + total_cost += cost; + 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, + }); + } + + messages.push(Message::assistant_with_tool_calls( + response.message.unwrap_or_default(), + response.tool_calls.clone(), + )); + + for call in &response.tool_calls { + debug!(tool = call.name(), "Executing tool call"); + let content = execute(sandbox, call).await; + 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})") +} + +async fn execute(sandbox: &Sandbox, call: &ToolCall) -> String { + let args = match parse_args(call) { + Ok(args) => args, + Err(err) => return format!("error: {err}"), + }; + + match tools::dispatch(sandbox, call.name(), &args).await { + Ok(output) => output, + Err(err) => format!("error: {err}"), + } +} + +fn parse_args(call: &ToolCall) -> anyhow::Result { + let raw = call.arguments_json().trim(); + if raw.is_empty() { + return Ok(Value::Object(serde_json::Map::new())); + } + + serde_json::from_str(raw) + .with_context(|| format!("invalid arguments for tool `{}`", call.name())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_args_accepts_empty_arguments() { + let call = ToolCall::new("id", "ls", ""); + assert_eq!( + parse_args(&call).unwrap(), + Value::Object(serde_json::Map::new()) + ); + } + + #[test] + fn parse_args_parses_json_object() { + let call = ToolCall::new("id", "ls", r#"{"path":"src"}"#); + assert_eq!(parse_args(&call).unwrap()["path"], "src"); + } + + #[test] + fn parse_args_rejects_invalid_json() { + let call = ToolCall::new("id", "ls", "not json"); + assert!(parse_args(&call).is_err()); + } +} diff --git a/crates/herald-server/src/sandbox/mod.rs b/crates/herald-server/src/sandbox/mod.rs new file mode 100644 index 0000000..f31a507 --- /dev/null +++ b/crates/herald-server/src/sandbox/mod.rs @@ -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//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 { + 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 { + 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 { + 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); + } +} diff --git a/crates/herald-server/src/sandbox/tools.rs b/crates/herald-server/src/sandbox/tools.rs new file mode 100644 index 0000000..a595984 --- /dev/null +++ b/crates/herald-server/src/sandbox/tools.rs @@ -0,0 +1,248 @@ +//! Tool definitions exposed to the model and their execution inside a +//! [`Sandbox`]. +//! +//! Every tool is read-only and confined to the repository workspace: paths are +//! resolved relative to the container workspace folder and rejected if they +//! escape it. Commands are executed as argv vectors (never through a shell), +//! so tool arguments cannot be used for shell injection. + +use std::path::Path; + +use anyhow::{Context, bail}; +use devcontainer_rs::{ExecOutput, normalize}; +use openrouter_rs::types::Tool; +use serde_json::{Value, json}; + +use super::Sandbox; +use crate::gitea::WebhookType; + +/// Tools available to the model for a given webhook action. +/// +/// The match is exhaustive on [`WebhookType`], so adding a new action forces a +/// decision here about which tools that action may use. +pub fn for_webhook(webhook: &WebhookType) -> Vec { + match webhook { + WebhookType::Review(_) => review_tools(), + } +} + +/// Read-only tools used to explore a repository during a review. +fn review_tools() -> Vec { + vec![ + Tool::new( + "ls", + "List the entries of a directory inside the repository.", + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory path relative to the repository root. Defaults to the repository root." + } + } + }), + ), + Tool::new( + "read_file", + "Read the content of a text file inside the repository.", + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "File path relative to the repository root." + }, + "start_line": { + "type": "integer", + "description": "First line to read (1-based, inclusive). Defaults to the first line." + }, + "end_line": { + "type": "integer", + "description": "Last line to read (1-based, inclusive). Defaults to the last line." + } + }, + "required": ["path"] + }), + ), + Tool::new( + "grep", + "Search for a regular expression across the repository files.", + json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Extended regular expression to search for." + }, + "path": { + "type": "string", + "description": "File or directory to search in, relative to the repository root. Defaults to the repository root." + } + }, + "required": ["pattern"] + }), + ), + Tool::new( + "find", + "Find files by name pattern inside the repository.", + json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern matched against file names, e.g. `*.rs`." + }, + "path": { + "type": "string", + "description": "Directory to search in, relative to the repository root. Defaults to the repository root." + } + }, + "required": ["pattern"] + }), + ), + ] +} + +/// Executes a tool call and returns its textual result. +/// +/// Errors are returned as `Err` so the caller can decide whether to surface +/// them to the model or abort. +pub async fn dispatch(sandbox: &Sandbox, name: &str, args: &Value) -> anyhow::Result { + match name { + "ls" => ls(sandbox, args).await, + "read_file" => read_file(sandbox, args).await, + "grep" => grep(sandbox, args).await, + "find" => find(sandbox, args).await, + other => bail!("unknown tool `{other}`"), + } +} + +async fn ls(sandbox: &Sandbox, args: &Value) -> anyhow::Result { + let path = resolve(sandbox, optional_str(args, "path").unwrap_or("."))?; + let output = sandbox.exec(&["ls", "-la", "--", &path]).await?; + into_stdout(output) +} + +async fn read_file(sandbox: &Sandbox, args: &Value) -> anyhow::Result { + let path = resolve(sandbox, required_str(args, "path")?)?; + + let start = args.get("start_line").and_then(Value::as_u64); + let end = args.get("end_line").and_then(Value::as_u64); + + let output = if start.is_none() && end.is_none() { + sandbox.exec(&["cat", "--", &path]).await? + } else { + let start = start.unwrap_or(1); + let end = end + .map(|line| line.to_string()) + .unwrap_or_else(|| "$".to_string()); + let range = format!("{start},{end}p"); + sandbox.exec(&["sed", "-n", &range, "--", &path]).await? + }; + + into_stdout(output) +} + +async fn grep(sandbox: &Sandbox, args: &Value) -> anyhow::Result { + let pattern = required_str(args, "pattern")?; + let path = resolve(sandbox, optional_str(args, "path").unwrap_or("."))?; + + let output = sandbox + .exec(&["grep", "-rn", "-E", "--", pattern, &path]) + .await?; + + // grep exits with 1 when there is no match, which is not an error. + match output.status { + 0 | 1 => Ok(output.stdout), + _ => bail!("grep failed: {}", output.stderr.trim()), + } +} + +async fn find(sandbox: &Sandbox, args: &Value) -> anyhow::Result { + let pattern = required_str(args, "pattern")?; + let path = resolve(sandbox, optional_str(args, "path").unwrap_or("."))?; + + let output = sandbox + .exec(&["find", &path, "-type", "f", "-name", pattern]) + .await?; + + into_stdout(output) +} + +/// Resolves a tool path against the container workspace folder, rejecting any +/// path that escapes it. +fn resolve(sandbox: &Sandbox, path: &str) -> anyhow::Result { + let workspace = sandbox.workspace_folder(); + let candidate = Path::new(workspace).join(path); + + let normalized = + normalize(&candidate).with_context(|| format!("path `{path}` escapes the workspace"))?; + + if !normalized.starts_with(workspace) { + bail!("path `{path}` escapes the workspace"); + } + + Ok(normalized.display().to_string()) +} + +fn required_str<'a>(args: &'a Value, key: &str) -> anyhow::Result<&'a str> { + args.get(key) + .and_then(Value::as_str) + .with_context(|| format!("`{key}` is required")) +} + +fn optional_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> { + args.get(key).and_then(Value::as_str) +} + +fn into_stdout(output: ExecOutput) -> anyhow::Result { + if output.success() { + Ok(output.stdout) + } else { + bail!( + "command failed (status {}): {}", + output.status, + output.stderr.trim() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gitea::{Comment, PullRequest, Repository, ReviewPayload}; + + fn review_webhook() -> WebhookType { + WebhookType::Review(ReviewPayload { + action: "created".to_string(), + pull_request: PullRequest { + diff_url: "https://example.com/diff".to_string(), + number: 1, + title: "My PR".to_string(), + }, + repository: Repository { + full_name: "owner/repo".to_string(), + }, + comment: Comment { + id: 1, + body: "@bot review".to_string(), + }, + }) + } + + #[test] + fn review_webhook_exposes_read_only_tools() { + let names: Vec = for_webhook(&review_webhook()) + .into_iter() + .map(|tool| tool.function.name) + .collect(); + + assert_eq!(names, vec!["ls", "read_file", "grep", "find"]); + } + + #[test] + fn required_str_reports_missing_key() { + let err = required_str(&json!({}), "path").unwrap_err(); + assert!(err.to_string().contains("path")); + } +} From ee221b09544f15b01b6de5cdb8a67d57f3bf4eac Mon Sep 17 00:00:00 2001 From: qpismont Date: Thu, 17 Sep 2026 13:22:24 +0000 Subject: [PATCH 10/17] Update rust version in ci job --- .woodpecker/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.woodpecker/tests.yml b/.woodpecker/tests.yml index 7d128af..1fd091b 100644 --- a/.woodpecker/tests.yml +++ b/.woodpecker/tests.yml @@ -4,19 +4,19 @@ when: steps: - name: fmt - image: rust:1.97 + image: rust:1.98 commands: - rustup component add rustfmt - cargo fmt --all -- --check - name: clippy - image: rust:1.97 + image: rust:1.98 commands: - rustup component add clippy - cargo clippy --workspace --all-targets --all-features -- -D warnings - name: test - image: rust:1.97 + image: rust:1.98 commands: - cargo test --workspace --all-targets From 04cc172848d3c8bb1c542d1fda6d770c6be86f11 Mon Sep 17 00:00:00 2001 From: qpismont Date: Thu, 17 Sep 2026 14:48:42 +0000 Subject: [PATCH 11/17] Replace openrouter-rs with in-tree client and require sandbox Remove the openrouter-rs dependency in favor of a minimal in-tree OpenRouter chat-completions client, and drop the BOT_NAME and SANDBOX_ENABLED config options. Reviews now always run inside the sandbox, and the review prompt asks the model to read files with the available tools instead of embedding the diff. --- .env.example | 6 +- Cargo.lock | 557 +------------ Cargo.toml | 5 +- README.md | 15 +- crates/herald-server/Cargo.toml | 3 - crates/herald-server/src/bot.rs | 44 +- .../herald-server/src/bot_actions/review.rs | 758 +++++++++++++++--- crates/herald-server/src/consts.rs | 18 +- crates/herald-server/src/env.rs | 5 - crates/herald-server/src/gitea.rs | 139 +++- crates/herald-server/src/main.rs | 6 +- crates/herald-server/src/open_router.rs | 455 +++++++++-- crates/herald-server/src/sandbox/agent.rs | 24 +- crates/herald-server/src/sandbox/mod.rs | 2 - crates/herald-server/src/sandbox/tools.rs | 4 +- 15 files changed, 1301 insertions(+), 740 deletions(-) diff --git a/.env.example b/.env.example index cabc5a0..d35705b 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,4 @@ HTTP_PORT=3000 -BOT_NAME=Herald WEBHOOK_SIG_HEADER_SECRET= @@ -20,7 +19,6 @@ RUST_BACKTRACE=1 METRICS_BIND_ADDR= -# Sandboxed tool execution (optional) -SANDBOX_ENABLED=false +# Sandboxed tool execution CONTAINER_RUNTIME=docker -SANDBOX_MAX_ITERATIONS=8 \ No newline at end of file +SANDBOX_MAX_ITERATIONS=8 diff --git a/Cargo.lock b/Cargo.lock index 02d3b25..4444691 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,17 +338,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures", - "rand_core 0.10.1", -] - [[package]] name = "convert_case" version = "0.10.0" @@ -374,15 +363,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] - [[package]] name = "crossbeam-epoch" version = "0.9.20" @@ -398,41 +378,6 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.119", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.119", -] - [[package]] name = "debugid" version = "0.8.0" @@ -459,37 +404,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn 2.0.119", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -521,7 +435,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.20", + "thiserror", "tokio", ] @@ -552,24 +466,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "dotenvy_macro" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb0235d912a8c749f4e0c9f18ca253b4c28cfefc1d2518096016d6e3230b6424" -dependencies = [ - "dotenvy", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - [[package]] name = "encoding_rs" version = "0.8.35" @@ -592,7 +488,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -749,10 +645,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -763,24 +657,10 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi 5.3.0", + "r-efi", "wasip2", ] -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasm-bindgen", -] - [[package]] name = "gimli" version = "0.32.3" @@ -833,24 +713,21 @@ version = "1.2.0" dependencies = [ "anyhow", "axum", - "bytes", "devcontainer-rs", "dotenvy", "futures-util", "hex", "metrics", "metrics-exporter-prometheus", - "openrouter-rs", - "reqwest 0.12.28", + "reqwest", "ring", "sentry", "sentry-anyhow", "serde", "serde_json", "tempfile", - "thiserror 2.0.20", + "thiserror", "tokio", - "tokio-stream", "tokio-util", "tower", "tower-http 0.7.1", @@ -953,22 +830,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http 1.5.0", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - [[package]] name = "hyper-tls" version = "0.6.0" @@ -1091,12 +952,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -1232,12 +1087,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "matchers" version = "0.2.0" @@ -1285,7 +1134,7 @@ dependencies = [ "metrics", "metrics-util", "quanta", - "thiserror 2.0.20", + "thiserror", "tokio", "tracing", ] @@ -1301,7 +1150,7 @@ dependencies = [ "hashbrown 0.16.1", "metrics", "quanta", - "rand 0.9.5", + "rand", "rand_xoshiro", "rapidhash", "sketches-ddsketch", @@ -1313,16 +1162,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1562,26 +1401,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "openrouter-rs" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "042920aeb3955ae7c6c26118c646f9723c4ba39e65ceb663513c6973ebfc21dc" -dependencies = [ - "derive_builder", - "dotenvy_macro", - "futures-util", - "http 1.5.0", - "reqwest 0.12.28", - "schemars", - "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tokio-util", - "urlencoding", -] - [[package]] name = "openssl" version = "0.10.81" @@ -1765,62 +1584,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.20", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" -dependencies = [ - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.2", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.20", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - [[package]] name = "quote" version = "1.0.47" @@ -1836,12 +1599,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - [[package]] name = "rand" version = "0.9.5" @@ -1849,18 +1606,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -1870,7 +1616,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -1882,28 +1628,13 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - [[package]] name = "rand_xoshiro" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" dependencies = [ - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -1933,26 +1664,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "ref-cast" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", -] - [[package]] name = "regex" version = "1.13.1" @@ -1990,53 +1701,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "http 1.5.0", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http 0.6.11", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "webpki-roots", -] - -[[package]] -name = "reqwest" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" -dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-core", @@ -2058,12 +1727,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-util", "tower", "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", ] @@ -2087,12 +1758,6 @@ version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - [[package]] name = "rustc_version" version = "0.4.1" @@ -2112,21 +1777,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", + "windows-sys 0.52.0", ] [[package]] @@ -2135,21 +1786,9 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ - "web-time", "zeroize", ] -[[package]] -name = "rustls-webpki" -version = "0.103.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.23" @@ -2171,31 +1810,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "schemars" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" -dependencies = [ - "dyn-clone", - "ref-cast", - "schemars_derive", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 3.0.4", -] - [[package]] name = "scoped-tls" version = "1.0.1" @@ -2246,7 +1860,7 @@ dependencies = [ "cfg_aliases", "httpdate", "native-tls", - "reqwest 0.13.4", + "reqwest", "sentry-actix", "sentry-backtrace", "sentry-contexts", @@ -2315,7 +1929,7 @@ version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d889520a375e5b93efb0a66def1630d21d168b0251eb55b286b03fa940ccfc84" dependencies = [ - "rand 0.9.5", + "rand", "sentry-types", "serde", "serde_json", @@ -2389,10 +2003,10 @@ checksum = "fc71f5ca55942d9b2901af95d5df5c5d65c164134c22a83682cac3d0b6c7ef2d" dependencies = [ "debugid", "hex", - "rand 0.9.5", + "rand", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror", "time", "url", "uuid", @@ -2428,17 +2042,6 @@ dependencies = [ "syn 3.0.4", ] -[[package]] -name = "serde_derive_internals" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", -] - [[package]] name = "serde_json" version = "1.0.151" @@ -2534,29 +2137,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.119" @@ -2606,19 +2186,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", + "windows-sys 0.52.0", ] [[package]] @@ -2627,18 +2198,7 @@ version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.20", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "thiserror-impl", ] [[package]] @@ -2701,21 +2261,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.53.1" @@ -2754,27 +2299,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "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" @@ -2929,12 +2453,6 @@ dependencies = [ "libc", ] -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -3001,12 +2519,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf8-zero" version = "0.8.1" @@ -3123,9 +2635,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -3144,16 +2656,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "webpki-root-certs" version = "1.0.9" @@ -3163,15 +2665,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 672a89c..1fef14f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,16 +6,14 @@ members = [ resolver = "3" [workspace.dependencies] -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +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" serde = { version = "1.0", features = ["derive"] } sentry = { version = "0.49", features = ["tower-axum-matched-path"] } sentry-anyhow = { version = "0.49", features = ["backtrace"] } -openrouter-rs = "0.14" dotenvy = "0.15" tower = "0.5" tower-http = { version = "0.7", features = ["trace"] } @@ -26,7 +24,6 @@ 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"] } diff --git a/README.md b/README.md index a749dfd..235c963 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,6 @@ Herald reads its configuration from environment variables (a `.env` file is supp | Variable | Description | |---|---| | `HTTP_PORT` | Port to listen on | -| `BOT_NAME` | The bot's Gitea username (used to detect mentions) | | `WEBHOOK_SIG_HEADER_SECRET` | Gitea webhook secret for signature verification | | `OPEN_ROUTER_API_KEY` | OpenRouter API key | | `OPEN_ROUTER_MODEL` | Model to use (e.g. `deepseek/deepseek-v4-flash`) | @@ -38,20 +37,24 @@ 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` | -| `SANDBOX_ENABLED` | *(optional)* Run reviews inside a devcontainer sandbox so the model can explore the repository with tools. Defaults to `false` | | `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` | ## Sandboxed reviews -When `SANDBOX_ENABLED=true`, Herald reviews pull requests inside an ephemeral +Herald reviews pull requests inside an ephemeral [Dev Container](https://containers.dev/). For each review it: 1. clones the pull request head into a temporary directory, 2. builds and starts the repository's devcontainer (`devcontainer-rs`), -3. lets the model explore the repository with read-only tools (`ls`, `read_file`, - `grep`, `find`) executed inside the container, -4. posts the review and removes the container and the temporary clone. +3. reads the pull request diff and file list from the Gitea API with + `GITEA_TOKEN` (so private repositories work), tells the model which files and + lines changed — additions and deletions, with the line numbers of the new and + old versions of the file respectively — then lets it explore the repository + with read-only tools (`ls`, `read_file`, `grep`, `find`) run inside the + container: the code itself is not sent, so the model reads it at those lines, +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`. diff --git a/crates/herald-server/Cargo.toml b/crates/herald-server/Cargo.toml index 9635007..6a6ed9a 100644 --- a/crates/herald-server/Cargo.toml +++ b/crates/herald-server/Cargo.toml @@ -6,14 +6,12 @@ edition = "2024" [dependencies] reqwest = { workspace = true } tokio = { workspace = true } -tokio-stream = { workspace = true } tokio-util = { workspace = true } futures-util = { workspace = true } serde_json = { workspace = true } serde = { workspace = true } sentry = { workspace = true } sentry-anyhow = { workspace = true } -openrouter-rs = { workspace = true } dotenvy = { workspace = true } tower = { workspace = true } tower-http = { workspace = true } @@ -24,7 +22,6 @@ anyhow = { workspace = true } thiserror = { workspace = true } ring = { workspace = true } hex = { workspace = true } -bytes = { workspace = true } metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } devcontainer-rs = { path = "../devcontainer-rs" } diff --git a/crates/herald-server/src/bot.rs b/crates/herald-server/src/bot.rs index ae1029e..aca98ed 100644 --- a/crates/herald-server/src/bot.rs +++ b/crates/herald-server/src/bot.rs @@ -4,7 +4,7 @@ use crate::{ open_router::OpenRouterClient, sandbox::SandboxConfig, }; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use std::{collections::HashSet, sync::Arc}; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -21,15 +21,52 @@ pub struct ReviewResult { pub struct ReviewItem { pub filename: String, pub line: Option, + #[serde(default, deserialize_with = "deserialize_side")] + pub side: Option, pub message: String, } +/// Which version of the file a review comment is anchored on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReviewSide { + /// The line was added by the pull request: `line` is a line number of the + /// new version of the file. + Added, + /// The line was removed by the pull request: `line` is a line number of the + /// old version of the file. + Removed, +} + +impl ReviewSide { + /// Reads the side the model asked for, tolerating casing and synonyms. + fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "added" | "add" | "new" | "right" => Some(Self::Added), + "removed" | "remove" | "deleted" | "delete" | "old" | "left" => Some(Self::Removed), + _ => None, + } + } +} + +/// Reads the side the model asked for. An unreadable value is ignored instead of +/// failing the whole review: the side is then resolved from the changed lines. +fn deserialize_side<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let raw = Option::::deserialize(deserializer)?; + + Ok(raw + .as_ref() + .and_then(serde_json::Value::as_str) + .and_then(ReviewSide::parse)) +} + #[derive(Clone)] pub struct Bot { bot_name: String, gitea_api: GiteaAPI, open_router_client: OpenRouterClient, - http_client: reqwest::Client, max_concurrent: usize, open_router_model: String, sandbox: SandboxConfig, @@ -41,7 +78,6 @@ impl Bot { bot_name: String, gitea_api: GiteaAPI, open_router_client: OpenRouterClient, - http_client: reqwest::Client, max_concurrent: usize, open_router_model: String, sandbox: SandboxConfig, @@ -50,7 +86,6 @@ impl Bot { bot_name, gitea_api, open_router_client, - http_client, max_concurrent, open_router_model, sandbox, @@ -123,7 +158,6 @@ impl Bot { WebhookType::Review(review_payload) => crate::bot_actions::review::exec_review( &self.gitea_api, &self.open_router_client, - &self.http_client, &self.open_router_model, &self.sandbox, tools, diff --git a/crates/herald-server/src/bot_actions/review.rs b/crates/herald-server/src/bot_actions/review.rs index fdb39bb..83d9424 100644 --- a/crates/herald-server/src/bot_actions/review.rs +++ b/crates/herald-server/src/bot_actions/review.rs @@ -1,30 +1,18 @@ -use futures_util::stream::TryStreamExt; -use openrouter_rs::types::Tool; -use tokio::io::AsyncReadExt; -use tokio_util::io::StreamReader; use tracing::{info, instrument, warn}; use crate::{ - bot::ReviewResult, - consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT}, - gitea::{GiteaAPI, ReviewPayload}, + bot::{ReviewResult, ReviewSide}, + consts::{BOT_PROCESS_MSG, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT}, + gitea::{GiteaAPI, PullRequestFile, ReviewPayload}, metrics, - open_router::OpenRouterClient, + open_router::{OpenRouterClient, Tool}, sandbox::{Sandbox, SandboxConfig, agent}, }; -#[instrument(skip( - gitea_api, - open_router_client, - http_client, - sandbox_config, - tools, - review_payload -))] +#[instrument(skip(gitea_api, open_router_client, sandbox_config, tools, review_payload))] pub async fn exec_review( gitea_api: &GiteaAPI, open_router_client: &OpenRouterClient, - http_client: &reqwest::Client, model: &str, sandbox_config: &SandboxConfig, tools: Vec, @@ -46,32 +34,43 @@ pub async fn exec_review( .await?; let bot_result: Result = async { - let git_diff = - download_git_diff(http_client, &review_payload.pull_request.diff_url).await?; + let full_name = &review_payload.repository.full_name; + let index = review_payload.pull_request.number; - let diff_for_llm = format_diff_for_review(&git_diff); + let git_diff = gitea_api.pull_request_diff(full_name, index).await?; + + // The file list only refines the paths and describes the changes: a + // failure is not fatal, the diff is the source of truth for the lines. + let files = match gitea_api.pull_request_files(full_name, index).await { + Ok(files) => files, + Err(err) => { + warn!(%err, "Failed to list the pull request files"); + Vec::new() + } + }; + + let mut changed_lines = parse_changed_lines(&git_diff); + resolve_filenames(&mut changed_lines, &files); + + let changes = format_changes(&files, &changed_lines); let bot_request = REVIEW_PROMPT .replace("{subject}", &review_payload.pull_request.title) .replace("{comment}", &review_payload.comment.body) - .replace("{diff}", &diff_for_llm); + .replace("{changes}", &changes); - let (message, cost) = if sandbox_config.enabled { - run_sandboxed_review( - gitea_api, - open_router_client, - sandbox_config, - tools, - &review_payload, - &bot_request, - ) - .await? - } else { - let chat_result = open_router_client.chat(&bot_request).await?; - (chat_result.message, chat_result.cost) - }; + let (message, cost) = run_sandboxed_review( + gitea_api, + open_router_client, + sandbox_config, + tools, + &review_payload, + &bot_request, + ) + .await?; let mut review_result = serde_json::from_str::(&message)?; + resolve_review_sides(&mut review_result, &changed_lines); review_result.cost = cost; if let Some(cost) = review_result.cost { @@ -183,83 +182,350 @@ fn review_result_to_markdown(review_result: &ReviewResult) -> String { md } -async fn download_git_diff(http_client: &reqwest::Client, url: &str) -> anyhow::Result { - let response = http_client.get(url).send().await?; - let stream = response.bytes_stream().map_err(std::io::Error::other); +/// The lines a pull request changed, per file. +/// +/// Line numbers are the ones Gitea expects to place a review comment: +/// [`ReviewSide::Added`] numbers refer to the new version of the file (sent as +/// `new_position`), [`ReviewSide::Removed`] numbers to the old version (sent as +/// `old_position`). +type ChangedLines = Vec; - let mut buf = Vec::with_capacity(MAX_DIFF_SIZE); - StreamReader::new(stream) - .take((MAX_DIFF_SIZE + 1) as u64) - .read_to_end(&mut buf) - .await?; - - if buf.len() > MAX_DIFF_SIZE { - anyhow::bail!("Git diff exceeds the maximum allowed size of 1 Mo"); - } - - Ok(String::from_utf8_lossy(&buf).into_owned()) +struct ChangedFile { + filename: String, + added: Vec, + removed: Vec, } -fn format_diff_for_review(git_diff: &str) -> String { - let mut output = String::new(); - let mut current_file: Option<&str> = None; +impl ChangedFile { + fn new(filename: &str) -> Self { + Self { + filename: String::from(filename), + added: Vec::new(), + removed: Vec::new(), + } + } + + /// Lines that can be commented on for the given side. + fn lines(&self, side: ReviewSide) -> &[u64] { + match side { + ReviewSide::Added => &self.added, + ReviewSide::Removed => &self.removed, + } + } + + /// Side a line belongs to, used when the model did not state one. + fn side_of(&self, line: u64) -> Option { + if self.added.contains(&line) { + Some(ReviewSide::Added) + } else if self.removed.contains(&line) { + Some(ReviewSide::Removed) + } else { + None + } + } +} + +/// Lists the lines changed by the diff, per file, on both sides. +/// +/// Only the line numbers are kept: the model reads the code itself through the +/// sandbox tools. +fn parse_changed_lines(git_diff: &str) -> ChangedLines { + let mut files = Vec::new(); + let mut current_file: Option = None; + let mut in_hunk = false; + let mut old_line: u64 = 0; let mut new_line: u64 = 0; for line in git_diff.lines() { - if let Some(rest) = line.strip_prefix("diff --git a/") { - if let Some(end) = rest.find(' ') { - current_file = Some(&rest[..end]); - } - new_line = 0; + if line.starts_with("diff --git ") { + current_file = None; + in_hunk = false; continue; } - if line.starts_with("---") || line.starts_with("+++") { - continue; - } - - if line.starts_with("@@") && line.contains('+') { - if let Some(start) = parse_hunk_new_start(line) { - new_line = start; + // `--- a/x` and `+++ b/x` only appear before the first hunk of a file. + // Inside a hunk, a line may legitimately start with them: removing + // `--x` gives `---x`, adding `++i;` gives `+++i;`. + if !in_hunk && (line.starts_with("--- ") || line.starts_with("+++ ")) { + // A header is never a content line: `+++ /dev/null` on a deleted + // file keeps the path of the other side in place. + if let Some(path) = header_file_path(line) { + current_file = Some(path); } continue; } - let Some(filename) = current_file else { + if line.starts_with("@@") { + if let Some((old_start, new_start)) = parse_hunk_starts(line) { + old_line = old_start; + new_line = new_start; + in_hunk = true; + } + continue; + } + + let Some(filename) = current_file.as_deref() else { continue; }; - if line.starts_with(' ') { - new_line += 1; - continue; - } - - if let Some(code) = line.strip_prefix('+') { - use std::fmt::Write; - let _ = writeln!(output, "{filename}:{new_line}:{code}"); - new_line += 1; + match line.as_bytes().first() { + Some(b' ') => { + old_line += 1; + new_line += 1; + } + Some(b'-') => { + changed_file(&mut files, filename).removed.push(old_line); + old_line += 1; + } + Some(b'+') => { + changed_file(&mut files, filename).added.push(new_line); + new_line += 1; + } + // `\ No newline at end of file`, and anything unexpected: a line + // that advances neither side. + _ => {} } } - output + files } -fn parse_hunk_new_start(hunk_header: &str) -> Option { - let plus_part = hunk_header.split('+').nth(1)?; - let num_str = plus_part.split(|c: char| !c.is_ascii_digit()).next()?; - num_str.parse::().ok() +/// Path of the file on one side of the diff, from a `--- a/` or +/// `+++ b/` header line. +/// +/// These lines are the only unambiguous source for the path: the `diff --git` +/// line is cut at the first space, and it names the old path of a renamed file. +/// `None` for `/dev/null`, which leaves the path of the other side in place. +fn header_file_path(line: &str) -> Option { + let (prefix, raw) = match line.strip_prefix("--- ") { + Some(raw) => ("a/", raw), + None => ("b/", line.strip_prefix("+++ ")?), + }; + + let path = decode_git_path(raw); + + Some(String::from(path.strip_prefix(prefix)?)) +} + +/// Decodes a path as git writes it in a diff header: git wraps it in quotes and +/// escapes the bytes that need it (`\303\251` for `é`) when the path contains +/// non-printable or non-ASCII characters. +fn decode_git_path(raw: &str) -> String { + let Some(quoted) = raw.strip_prefix('"').and_then(|raw| raw.strip_suffix('"')) else { + return String::from(raw); + }; + + let bytes = quoted.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + + while let Some(byte) = bytes.get(index) { + index += 1; + + if *byte != b'\\' { + decoded.push(*byte); + continue; + } + + match bytes.get(index) { + // Octal escapes are the ones that matter for a path: git uses them + // for every non-ASCII byte. + Some(digit @ b'0'..=b'7') => { + let mut value = u32::from(digit - b'0'); + let mut digits = 1; + + while digits < 3 { + match bytes.get(index + digits) { + Some(next @ b'0'..=b'7') => { + value = value * 8 + u32::from(next - b'0'); + digits += 1; + } + _ => break, + } + } + + decoded.push(u8::try_from(value).unwrap_or(b'?')); + index += digits; + } + Some(escaped) => { + decoded.push(match escaped { + b't' => b'\t', + b'n' => b'\n', + b'r' => b'\r', + other => *other, + }); + index += 1; + } + None => decoded.push(b'\\'), + } + } + + String::from_utf8_lossy(&decoded).into_owned() +} + +/// Replaces the paths parsed from the diff with the exact paths reported by the +/// API, which are the ones the model sees in the sandbox. +fn resolve_filenames(changed_lines: &mut ChangedLines, files: &[PullRequestFile]) { + for changed in changed_lines.iter_mut() { + let parsed = changed.filename.as_str(); + + let Some(file) = files.iter().find(|file| file.filename == parsed) else { + if !files.is_empty() { + warn!(path = %parsed, "Changed file is not in the pull request file list"); + } + continue; + }; + + changed.filename = file.filename.clone(); + } +} + +/// Renders the changes for the model: the files the pull request touches, then +/// the lines to review per file. +fn format_changes(files: &[PullRequestFile], changed_lines: &ChangedLines) -> String { + let mut sections = Vec::new(); + + if !files.is_empty() { + let described = files + .iter() + .map(describe_file) + .collect::>() + .join(", "); + + sections.push(format!("Files changed by the pull request: {described}")); + } + + sections.push(format!( + "Lines to review, per file:\n{}", + format_changed_lines(changed_lines) + )); + + sections.join("\n\n") +} + +/// Describes a changed file for the model, including how it changed. +fn describe_file(file: &PullRequestFile) -> String { + match &file.previous_filename { + Some(previous) => format!("{} ({} from {})", file.filename, file.status, previous), + None => format!("{} ({})", file.filename, file.status), + } +} + +/// Borrows the entry of `files` for a file, creating it on its first change. +fn changed_file<'a>(files: &'a mut ChangedLines, filename: &str) -> &'a mut ChangedFile { + let index = match files.iter().position(|file| file.filename == filename) { + Some(index) => index, + None => { + files.push(ChangedFile::new(filename)); + files.len() - 1 + } + }; + + &mut files[index] +} + +/// Renders the changed lines as `filename: added 1, 2 / removed 3`, one file per +/// line, keeping only the sides the file actually has. +fn format_changed_lines(changed_lines: &ChangedLines) -> String { + changed_lines + .iter() + .map(|file| { + let mut sides = Vec::new(); + + for (label, lines) in [("added", &file.added), ("removed", &file.removed)] { + if !lines.is_empty() { + sides.push(format!("{label} {}", format_line_numbers(lines))); + } + } + + format!("{}: {}", file.filename, sides.join(" / ")) + }) + .collect::>() + .join("\n") +} + +fn format_line_numbers(lines: &[u64]) -> String { + lines + .iter() + .map(u64::to_string) + .collect::>() + .join(", ") +} + +/// Resolves the side each review is anchored on and drops the reviews that do +/// not match a line the pull request changes. +/// +/// The model is asked to pick a line and a side from the provided lists, but +/// nothing forces it to, and Gitea accepts any position: a wrong one places the +/// comment on an unrelated line of the file instead of failing. A review that +/// omits its side is resolved from the lists, and one that matches no changed +/// line is dropped. +fn resolve_review_sides(review_result: &mut ReviewResult, changed_lines: &ChangedLines) { + let mut dropped = Vec::new(); + + review_result.reviews.retain_mut(|review| { + let side = review.line.and_then(|line| { + let file = changed_lines + .iter() + .find(|file| file.filename == review.filename)?; + + let side = review.side.or_else(|| file.side_of(line))?; + + file.lines(side).contains(&line).then_some(side) + }); + + match side { + Some(side) => { + review.side = Some(side); + true + } + None => { + dropped.push(match review.line { + Some(line) => format!("{}:{line}", review.filename), + None => format!("{}:no line", review.filename), + }); + false + } + } + }); + + if !dropped.is_empty() { + warn!( + dropped = dropped.len(), + reviews = %dropped.join(", "), + "Dropped reviews that are not anchored on a changed line" + ); + } +} + +/// Extracts the old and new starting line numbers of a hunk header such as +/// `@@ -12,3 +12,5 @@`. The counts are optional and git may append a section +/// heading after the closing `@@`. +fn parse_hunk_starts(hunk_header: &str) -> Option<(u64, u64)> { + let body = hunk_header.strip_prefix("@@ ")?; + let body = body.split(" @@").next()?; + + let (old, new) = body.split_once(" +")?; + let old = old.strip_prefix('-')?.split(',').next()?; + let new = new.split(',').next()?; + + Some((old.parse().ok()?, new.parse().ok()?)) } #[cfg(test)] -#[test] -fn test_format_diff_for_review() { - let diff = concat!( +mod tests { + use super::*; + use crate::bot::ReviewItem; + + /// Additions, a removal and a line changed on both sides. + const DIFF: &str = concat!( "diff --git a/src/foo.rs b/src/foo.rs\n", "--- a/src/foo.rs\n", "+++ b/src/foo.rs\n", - "@@ -1,3 +1,6 @@\n", + "@@ -1,4 +1,6 @@\n", " fn main() {\n", "+ let x = 1;\n", + "- let removed = 0;\n", " println!(\"hello\");\n", "+ let y = 2;\n", "+ let z = 3;\n", @@ -274,14 +540,324 @@ fn test_format_diff_for_review() { "+ let b = 20;\n", ); - let result = format_diff_for_review(diff); - let expected = concat!( - "src/foo.rs:2: let x = 1;\n", - "src/foo.rs:4: let y = 2;\n", - "src/foo.rs:5: let z = 3;\n", - "src/bar.rs:11: let a = 10;\n", - "src/bar.rs:13: let b = 20;\n", + /// A pull request that only deletes a file. + const DELETION_ONLY: &str = concat!( + "diff --git a/src/old.rs b/src/old.rs\n", + "deleted file mode 100644\n", + "--- a/src/old.rs\n", + "+++ /dev/null\n", + "@@ -1,3 +0,0 @@\n", + "-fn a() {}\n", + "-fn b() {}\n", + "-fn c() {}\n", ); - assert_eq!(result, expected); + /// A hunk whose content starts with `+++` / `---`, which must not be taken + /// for the file headers. + const TRICKY_CONTENT: &str = concat!( + "diff --git a/src/tricky.js b/src/tricky.js\n", + "--- a/src/tricky.js\n", + "+++ b/src/tricky.js\n", + "@@ -1,4 +1,4 @@\n", + " let i = 0;\n", + "+++i;\n", + "---x;\n", + " console.log(i);\n", + ); + + /// A pull request that renames a file. + const RENAMED: &str = concat!( + "diff --git a/src/old.rs b/src/new.rs\n", + "similarity index 50%\n", + "rename from src/old.rs\n", + "rename to src/new.rs\n", + "--- a/src/old.rs\n", + "+++ b/src/new.rs\n", + "@@ -1,1 +1,1 @@\n", + "-fn old() {}\n", + "+fn new() {}\n", + ); + + /// A file whose path contains a space, which the `diff --git` line cannot + /// express without ambiguity. + const PATH_WITH_SPACE: &str = concat!( + "diff --git a/src/my file.rs b/src/my file.rs\n", + "--- a/src/my file.rs\n", + "+++ b/src/my file.rs\n", + "@@ -1,1 +1,2 @@\n", + " fn a() {}\n", + "+fn b() {}\n", + ); + + /// A file whose path git quotes and escapes (`caf\\303\\251.md` is + /// `caf\u{e9}.md`). + const QUOTED_PATH: &str = concat!( + "diff --git \"a/docs/caf\\303\\251.md\" \"b/docs/caf\\303\\251.md\"\n", + "--- \"a/docs/caf\\303\\251.md\"\n", + "+++ \"b/docs/caf\\303\\251.md\"\n", + "@@ -1,1 +1,2 @@\n", + " intro\n", + "+ajout\n", + ); + + fn review(filename: &str, line: Option, side: Option) -> ReviewItem { + ReviewItem { + filename: String::from(filename), + line, + side, + message: String::from("message"), + } + } + + fn review_result(reviews: Vec) -> ReviewResult { + ReviewResult { + reviews, + comment: String::new(), + cost: None, + } + } + + fn pull_request_file( + filename: &str, + previous_filename: Option<&str>, + status: &str, + ) -> PullRequestFile { + PullRequestFile { + filename: String::from(filename), + previous_filename: previous_filename.map(String::from), + status: String::from(status), + } + } + + #[test] + fn changed_lines_are_listed_per_file_and_side() { + let expected = concat!( + "src/foo.rs: added 2, 4, 5 / removed 2\n", + "src/bar.rs: added 11, 13" + ); + + assert_eq!(format_changed_lines(&parse_changed_lines(DIFF)), expected); + } + + #[test] + fn a_deletion_only_pull_request_lists_removed_lines() { + let expected = "src/old.rs: removed 1, 2, 3"; + + assert_eq!( + format_changed_lines(&parse_changed_lines(DELETION_ONLY)), + expected + ); + } + + #[test] + fn hunk_content_starting_with_plus_or_minus_is_counted() { + let expected = "src/tricky.js: added 2 / removed 2"; + + assert_eq!( + format_changed_lines(&parse_changed_lines(TRICKY_CONTENT)), + expected + ); + } + + #[test] + fn a_renamed_file_uses_its_new_path() { + let expected = "src/new.rs: added 1 / removed 1"; + + assert_eq!( + format_changed_lines(&parse_changed_lines(RENAMED)), + expected + ); + } + + #[test] + fn a_path_with_a_space_is_read_from_the_headers() { + let expected = "src/my file.rs: added 2"; + + assert_eq!( + format_changed_lines(&parse_changed_lines(PATH_WITH_SPACE)), + expected + ); + } + + #[test] + fn a_quoted_path_is_decoded() { + let expected = "docs/café.md: added 2"; + + assert_eq!( + format_changed_lines(&parse_changed_lines(QUOTED_PATH)), + expected + ); + } + + #[test] + fn filenames_are_resolved_against_the_api_list() { + let mut changed_lines = parse_changed_lines(DIFF); + let files = vec![ + pull_request_file("src/bar.rs", None, "modified"), + pull_request_file("src/foo.rs", Some("src/renamed.rs"), "renamed"), + ]; + + resolve_filenames(&mut changed_lines, &files); + + assert_eq!(changed_lines[0].filename, "src/foo.rs"); + assert_eq!(changed_lines[1].filename, "src/bar.rs"); + } + + #[test] + fn a_file_absent_from_the_api_list_keeps_the_diff_path() { + let mut changed_lines = parse_changed_lines(DIFF); + let files = vec![pull_request_file("src/bar.rs", None, "modified")]; + + resolve_filenames(&mut changed_lines, &files); + + assert_eq!(changed_lines[0].filename, "src/foo.rs"); + assert_eq!(changed_lines[1].filename, "src/bar.rs"); + } + + #[test] + fn changes_describe_the_files_then_the_lines() { + let changed_lines = parse_changed_lines(DELETION_ONLY); + let files = vec![pull_request_file("src/old.rs", None, "deleted")]; + + let expected = concat!( + "Files changed by the pull request: src/old.rs (deleted)\n", + "\n", + "Lines to review, per file:\n", + "src/old.rs: removed 1, 2, 3" + ); + + assert_eq!(format_changes(&files, &changed_lines), expected); + } + + #[test] + fn changes_without_the_api_list_only_hold_the_lines() { + let changed_lines = parse_changed_lines(DELETION_ONLY); + + let expected = "Lines to review, per file:\nsrc/old.rs: removed 1, 2, 3"; + + assert_eq!(format_changes(&[], &changed_lines), expected); + } + + #[test] + fn a_renamed_file_is_described_with_its_previous_path() { + let file = pull_request_file("src/new.rs", Some("src/old.rs"), "renamed"); + + assert_eq!(describe_file(&file), "src/new.rs (renamed from src/old.rs)"); + } + + #[test] + fn reviews_keep_their_changed_line_and_resolve_their_side() { + let changed_lines = parse_changed_lines(DIFF); + let mut review_result = review_result(vec![ + review("src/foo.rs", Some(4), Some(ReviewSide::Added)), + review("src/foo.rs", Some(2), Some(ReviewSide::Removed)), + review("src/bar.rs", Some(13), None), + ]); + + resolve_review_sides(&mut review_result, &changed_lines); + + let sides = review_result + .reviews + .iter() + .map(|review| review.side) + .collect::>(); + + assert_eq!( + sides, + vec![ + Some(ReviewSide::Added), + Some(ReviewSide::Removed), + Some(ReviewSide::Added) + ] + ); + } + + #[test] + fn a_line_changed_on_both_sides_defaults_to_added() { + let changed_lines = parse_changed_lines(DIFF); + let mut review_result = review_result(vec![review("src/foo.rs", Some(2), None)]); + + resolve_review_sides(&mut review_result, &changed_lines); + + assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Added)); + } + + #[test] + fn reviews_outside_changed_lines_are_dropped() { + let changed_lines = parse_changed_lines(DIFF); + let mut review_result = review_result(vec![ + // valid + review("src/foo.rs", Some(2), Some(ReviewSide::Added)), + // a line that exists but is not part of the change + review("src/foo.rs", Some(3), None), + // a line beyond the change + review("src/foo.rs", Some(999), None), + // a changed line, but on the wrong side + review("src/foo.rs", Some(4), Some(ReviewSide::Removed)), + // a line changed in another file + review("src/foo.rs", Some(11), None), + // an unknown file + review("src/baz.rs", Some(1), None), + // no line at all + review("src/bar.rs", None, None), + ]); + + resolve_review_sides(&mut review_result, &changed_lines); + + assert_eq!(review_result.reviews.len(), 1); + assert_eq!(review_result.reviews[0].filename, "src/foo.rs"); + assert_eq!(review_result.reviews[0].line, Some(2)); + assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Added)); + } + + #[test] + fn a_deleted_line_is_kept_as_a_removed_anchor() { + let changed_lines = parse_changed_lines(DELETION_ONLY); + let mut review_result = review_result(vec![ + review("src/old.rs", Some(2), Some(ReviewSide::Removed)), + review("src/old.rs", Some(2), Some(ReviewSide::Added)), + ]); + + resolve_review_sides(&mut review_result, &changed_lines); + + assert_eq!(review_result.reviews.len(), 1); + assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Removed)); + } + + #[test] + fn odd_sides_from_the_model_are_tolerated() { + let changed_lines = parse_changed_lines(DIFF); + let mut review_result: ReviewResult = serde_json::from_str( + r#"{ + "reviews": [ + { "filename": "src/foo.rs", "line": 4, "side": "Added", "message": "a" }, + { "filename": "src/foo.rs", "line": 2, "side": "new", "message": "b" }, + { "filename": "src/foo.rs", "line": 2, "side": "REMOVED", "message": "c" }, + { "filename": "src/foo.rs", "line": 2, "side": "banana", "message": "d" }, + { "filename": "src/foo.rs", "line": 5, "message": "e" } + ], + "comment": "" + }"#, + ) + .unwrap(); + + resolve_review_sides(&mut review_result, &changed_lines); + + let sides = review_result + .reviews + .iter() + .map(|review| review.side) + .collect::>(); + + assert_eq!( + sides, + vec![ + Some(ReviewSide::Added), + Some(ReviewSide::Added), + Some(ReviewSide::Removed), + Some(ReviewSide::Added), + Some(ReviewSide::Added) + ] + ); + } } diff --git a/crates/herald-server/src/consts.rs b/crates/herald-server/src/consts.rs index 7dc6547..3d87a6a 100644 --- a/crates/herald-server/src/consts.rs +++ b/crates/herald-server/src/consts.rs @@ -27,25 +27,29 @@ pub const REVIEW_PROMPT: &str = " This is the user comment: \"{comment}\" - The code changes (only added lines, with line numbers): + The pull request changes these files and lines: - {diff} + {changes} - Please review the code changes and provide feedback. + `added` line numbers refer to the new version of the file, `removed` line + numbers to the old version, as they appear in the diff. - IMPORTANT: the `line` field must be the line number shown before each line. - The provided code has the format: `filename:line:code` + The code is not provided: read the files you need with the available tools + before answering. Review only the listed lines. Return your feedback, in french, with only this json format, reviews must contain each review All fields are mandatory. - (filename field must contain the full path with extension) and comment must contain a final summary: + (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) + and comment must contain a final summary: { \"reviews\": [ { \"filename\": \"\", \"line\": , - \"code\": \"\", + \"side\": \"\", \"message\": \"\" } ], diff --git a/crates/herald-server/src/env.rs b/crates/herald-server/src/env.rs index 6fa8ced..7cdf68d 100644 --- a/crates/herald-server/src/env.rs +++ b/crates/herald-server/src/env.rs @@ -13,7 +13,6 @@ pub struct EnvConfig { pub gitea_timeout: u64, pub metrics_bind_addr: Option, pub container_runtime: String, - pub sandbox_enabled: bool, pub sandbox_max_iterations: usize, } @@ -30,9 +29,6 @@ pub fn load_config() -> anyhow::Result { 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_enabled = std::env::var("SANDBOX_ENABLED") - .map(|value| matches!(value.as_str(), "1" | "true" | "yes")) - .unwrap_or(false); let sandbox_max_iterations = std::env::var("SANDBOX_MAX_ITERATIONS") .ok() .and_then(|value| value.parse().ok()) @@ -50,7 +46,6 @@ pub fn load_config() -> anyhow::Result { gitea_timeout, metrics_bind_addr, container_runtime, - sandbox_enabled, sandbox_max_iterations, }) } diff --git a/crates/herald-server/src/gitea.rs b/crates/herald-server/src/gitea.rs index a86123c..13fac7e 100644 --- a/crates/herald-server/src/gitea.rs +++ b/crates/herald-server/src/gitea.rs @@ -1,10 +1,23 @@ use std::time::Duration; +use futures_util::stream::TryStreamExt; use serde::Deserialize; use serde_json::{Value, json}; -use tracing::instrument; +use tokio::io::AsyncReadExt; +use tokio_util::io::StreamReader; +use tracing::{instrument, warn}; -use crate::{bot::ReviewResult, errors::AppError}; +use crate::{ + bot::{ReviewResult, ReviewSide}, + consts::MAX_DIFF_SIZE, + errors::AppError, +}; + +/// Page size requested when listing the files of a pull request. +const FILE_PAGE_SIZE: u64 = 50; + +/// Maximum number of pages fetched for a pull request file list. +const MAX_FILE_PAGES: u64 = 10; #[derive(Clone)] pub struct GiteaAPI { @@ -135,6 +148,70 @@ impl GiteaAPI { Ok(()) } + /// Raw unified diff of a pull request. + /// + /// The API endpoint is used rather than the `diff_url` carried by the + /// webhook: that one points at a web route, which is session authenticated + /// and therefore does not serve private repositories to an API token. + #[instrument(skip(self))] + pub async fn pull_request_diff(&self, full_name: &str, index: u64) -> anyhow::Result { + let url = format!( + "{}/api/v1/repos/{}/pulls/{}.diff", + self.base_url, full_name, index + ); + + let res = self.client.get(url).send().await?; + + if !res.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to download pull request diff: {}", + res.status() + )); + } + + read_capped(res).await + } + + /// Files changed by a pull request, with their exact path and status. + #[instrument(skip(self))] + pub async fn pull_request_files( + &self, + full_name: &str, + index: u64, + ) -> anyhow::Result> { + let mut files = Vec::new(); + + for page in 1..=MAX_FILE_PAGES { + let url = format!( + "{}/api/v1/repos/{}/pulls/{}/files?limit={FILE_PAGE_SIZE}&page={page}", + self.base_url, full_name, index + ); + + let res = self.client.get(url).send().await?; + + if !res.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to list pull request files: {}", + res.status() + )); + } + + let page_files = res.json::>().await?; + + // The instance may cap the page size below the requested one, so a + // short page is not the end of the list: an empty one is. + if page_files.is_empty() { + return Ok(files); + } + + files.extend(page_files); + } + + warn!(files = files.len(), "Pull request file list was truncated"); + + Ok(files) + } + #[instrument(skip(self, review_result))] pub async fn post_pull_request_review( &self, @@ -148,19 +225,27 @@ impl GiteaAPI { self.base_url, full_name, index ); - let comments = &review_result + let comments = review_result .reviews .iter() - .filter(|r| r.line.is_some()) - .map(|r| { - let path = r.filename.clone(); - let line = r.line.unwrap_or(0); - let body = r.message.clone(); + .filter_map(|review| { + let line = review.line?; + let path = review.filename.clone(); + let body = review.message.clone(); - json!({ - "path": path, - "new_position": line, - "body": body + // A line removed by the pull request only exists in the old + // version of the file, so it is anchored with `old_position`. + Some(match review.side { + Some(ReviewSide::Removed) => json!({ + "path": path, + "old_position": line, + "body": body + }), + _ => json!({ + "path": path, + "new_position": line, + "body": body + }), }) }) .collect::>(); @@ -184,6 +269,23 @@ impl GiteaAPI { } } +/// Reads a response body, refusing to buffer more than [`MAX_DIFF_SIZE`]. +async fn read_capped(response: reqwest::Response) -> anyhow::Result { + let stream = response.bytes_stream().map_err(std::io::Error::other); + + let mut buf = Vec::with_capacity(MAX_DIFF_SIZE); + StreamReader::new(stream) + .take((MAX_DIFF_SIZE + 1) as u64) + .read_to_end(&mut buf) + .await?; + + if buf.len() > MAX_DIFF_SIZE { + anyhow::bail!("Pull request diff exceeds the maximum allowed size of 1 MiB"); + } + + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + #[derive(Debug)] pub enum WebhookType { Review(ReviewPayload), @@ -213,7 +315,6 @@ pub struct ReviewPayload { #[derive(Deserialize, Debug)] pub struct PullRequest { - pub diff_url: String, pub number: u64, pub title: String, } @@ -234,6 +335,18 @@ pub struct Repository { pub full_name: String, } +/// A file changed by a pull request, as reported by the API. +#[derive(Deserialize, Debug)] +pub struct PullRequestFile { + /// Path of the file in the new version of the repository. + pub filename: String, + /// Previous path, for a renamed file. + #[serde(default)] + pub previous_filename: Option, + /// `added`, `modified`, `deleted`, `renamed`… + pub status: String, +} + impl WebhookType { pub fn from_event(event: &str, bot_name: &str, json: Value) -> Result { let wb = match event { diff --git a/crates/herald-server/src/main.rs b/crates/herald-server/src/main.rs index 2c313e4..d6d4b0b 100644 --- a/crates/herald-server/src/main.rs +++ b/crates/herald-server/src/main.rs @@ -79,15 +79,14 @@ async fn run() -> anyhow::Result<()> { let shutdown = CancellationToken::new(); let sandbox = SandboxConfig { - enabled: config.sandbox_enabled, runtime: devcontainer_rs::ContainerRuntime::new(config.container_runtime.clone()), max_iterations: config.sandbox_max_iterations, }; - if sandbox.enabled && !sandbox.runtime.available().await { + if !sandbox.runtime.available().await { warn!( runtime = sandbox.runtime.program(), - "Sandbox is enabled but the container runtime is not available" + "Container runtime is not available, every review will fail" ); } @@ -95,7 +94,6 @@ async fn run() -> anyhow::Result<()> { gitea_user.login, gitea_api, open_router_client, - reqwest::Client::new(), config.bot_max_concurrent, config.open_router_model.clone(), sandbox, diff --git a/crates/herald-server/src/open_router.rs b/crates/herald-server/src/open_router.rs index dd093ac..8709091 100644 --- a/crates/herald-server/src/open_router.rs +++ b/crates/herald-server/src/open_router.rs @@ -1,63 +1,253 @@ +//! Minimal OpenRouter chat-completions client. +//! +//! Herald only needs a non-streaming `POST /chat/completions` with optional +//! tool calling, so the wire types are implemented in-tree instead of pulling a +//! third-party SDK (and its own `reqwest` version) into the workspace. +//! +//! Only the response fields Herald consumes are modelled: `content`, +//! `tool_calls` and `usage.cost`. Unknown fields are ignored. + use std::time::Duration; -use openrouter_rs::{ - Message, - api::chat::ChatCompletionRequest, - types::{Tool, ToolCall}, -}; +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use serde_json::Value; use tracing::instrument; -pub struct ChatResult { - pub message: String, - pub cost: Option, -} +/// 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"; + +/// Result of a completion that may contain tool calls. pub struct ToolChatResult { pub message: Option, pub tool_calls: Vec, pub cost: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Role { + System, + User, + Assistant, + Tool, +} + +/// A single turn of the conversation. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Message { + pub role: Role, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, +} + +impl Message { + pub fn new(role: Role, content: impl Into) -> Self { + Self { + role, + content: Some(content.into()), + tool_calls: None, + tool_call_id: None, + } + } + + /// Assistant turn requesting tool calls. Models often answer with tool + /// calls but no text, in which case the content is sent as `null`. + pub fn assistant_with_tool_calls( + content: impl Into, + tool_calls: Vec, + ) -> Self { + let content = content.into(); + + Self { + role: Role::Assistant, + content: (!content.is_empty()).then_some(content), + tool_calls: Some(tool_calls), + tool_call_id: None, + } + } + + /// Result of a tool call, linked to the request by `tool_call_id`. + pub fn tool_response(tool_call_id: impl Into, content: impl Into) -> Self { + Self { + role: Role::Tool, + content: Some(content.into()), + tool_calls: None, + tool_call_id: Some(tool_call_id.into()), + } + } +} + +/// A tool the model may call. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Tool { + #[serde(rename = "type", default = "function_type")] + pub kind: String, + pub function: FunctionDefinition, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct FunctionDefinition { + pub name: String, + pub description: String, + /// JSON schema describing the accepted arguments. + pub parameters: Value, +} + +impl Tool { + pub fn new(name: impl Into, description: impl Into, parameters: Value) -> Self { + Self { + kind: function_type(), + function: FunctionDefinition { + name: name.into(), + description: description.into(), + parameters, + }, + } + } +} + +/// A tool call requested by the model. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ToolCall { + pub id: String, + #[serde(rename = "type", default = "function_type")] + pub kind: String, + pub function: FunctionCall, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct FunctionCall { + pub name: String, + /// Arguments as a JSON-encoded string. Kept verbatim so that echoing the + /// call back into the conversation does not re-encode or corrupt it. + pub arguments: String, +} + +impl ToolCall { + pub fn id(&self) -> &str { + &self.id + } + + pub fn name(&self) -> &str { + &self.function.name + } + + pub fn arguments_json(&self) -> &str { + &self.function.arguments + } +} + +fn function_type() -> String { + String::from("function") +} + +#[derive(Serialize)] +struct ChatRequest<'a> { + model: &'a str, + messages: &'a [Message], + reasoning: Reasoning, + tools: &'a [Tool], + tool_choice: &'a str, +} + +/// OpenRouter reasoning config; Herald always asks for reasoning. +#[derive(Serialize)] +struct Reasoning { + enabled: bool, +} + +#[derive(Deserialize)] +struct ChatResponse { + #[serde(default)] + choices: Vec, + #[serde(default)] + usage: Option, +} + +#[derive(Deserialize)] +struct Choice { + message: ResponseMessage, +} + +#[derive(Deserialize)] +struct ResponseMessage { + #[serde(default)] + content: Option, + #[serde(default)] + tool_calls: Option>, +} + +/// Message content is a plain string for most models and a list of typed parts +/// for others; both are collapsed to text. +#[derive(Deserialize)] +#[serde(untagged)] +enum Content { + Text(String), + Parts(Vec), +} + +#[derive(Deserialize)] +struct ContentPart { + #[serde(default)] + text: Option, +} + +impl Content { + fn into_text(self) -> String { + match self { + Self::Text(text) => text, + Self::Parts(parts) => parts + .into_iter() + .filter_map(|part| part.text) + .collect::>() + .join(""), + } + } +} + +#[derive(Deserialize)] +struct Usage { + #[serde(default)] + cost: Option, +} + +/// Error payload returned by OpenRouter for a failed request. +#[derive(Deserialize)] +struct ErrorResponse { + error: ErrorDetail, +} + +#[derive(Deserialize)] +struct ErrorDetail { + message: String, +} + #[derive(Clone)] pub struct OpenRouterClient { - client: openrouter_rs::OpenRouterClient, + client: reqwest::Client, + api_key: String, model: String, } impl OpenRouterClient { pub fn new(token: &str, model: &str, timeout: u64) -> anyhow::Result { Ok(Self { - client: openrouter_rs::OpenRouterClient::builder() - .api_key(token) - .http_client( - reqwest::Client::builder() - .timeout(Duration::from_secs(timeout)) - .build()?, - ) + client: reqwest::Client::builder() + .timeout(Duration::from_secs(timeout)) .build()?, + api_key: String::from(token), model: String::from(model), }) } - #[instrument(skip(self), err)] - pub async fn chat(&self, msg: &str) -> anyhow::Result { - let request = ChatCompletionRequest::builder() - .model(&self.model) - .enable_reasoning() - .messages(vec![Message::new(openrouter_rs::types::Role::User, msg)]) - .build()?; - - let response = self.client.chat().create(&request).await?; - - Ok(ChatResult { - message: response.choices[0] - .content() - .map(String::from) - .ok_or(anyhow::anyhow!("No content"))?, - cost: response.usage.and_then(|u| u.cost), - }) - } - /// Sends a conversation with tool definitions and returns either a final /// message or the tool calls requested by the model. #[instrument(skip(self, messages, tools), err)] @@ -66,27 +256,186 @@ impl OpenRouterClient { messages: Vec, tools: Vec, ) -> anyhow::Result { - let request = ChatCompletionRequest::builder() - .model(&self.model) - .enable_reasoning() - .messages(messages) - .tools(tools) - .tool_choice_auto() - .build()?; + let response = self.complete(&messages, &tools).await?; - let response = self.client.chat().create(&request).await?; - let choice = response + let cost = response.usage.and_then(|usage| usage.cost); + let message = response .choices - .first() - .ok_or_else(|| anyhow::anyhow!("No choices in response"))?; + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("No choices in response"))? + .message; Ok(ToolChatResult { - message: choice.content().map(String::from), - tool_calls: choice - .tool_calls() - .map(<[ToolCall]>::to_vec) - .unwrap_or_default(), - cost: response.usage.and_then(|u| u.cost), + message: message.content.map(Content::into_text), + tool_calls: message.tool_calls.unwrap_or_default(), + cost, }) } + + async fn complete(&self, messages: &[Message], tools: &[Tool]) -> anyhow::Result { + let request = ChatRequest { + model: &self.model, + messages, + reasoning: Reasoning { enabled: true }, + tools, + tool_choice: TOOL_CHOICE_AUTO, + }; + + let response = self + .client + .post(format!("{BASE_URL}/chat/completions")) + .bearer_auth(&self.api_key) + .json(&request) + .send() + .await + .context("failed to reach OpenRouter")?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("OpenRouter returned {status}: {}", error_message(&body)); + } + + response + .json::() + .await + .context("invalid OpenRouter response") + } +} + +/// Extracts the message from an OpenRouter error body, falling back to the raw +/// body when it is not the expected JSON shape. +fn error_message(body: &str) -> String { + serde_json::from_str::(body) + .map(|response| response.error.message) + .unwrap_or_else(|_| body.trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Builds a tool call the way the API returns one, so the fixture also + /// covers deserialization. + fn tool_call(id: &str, name: &str, arguments: &str) -> ToolCall { + serde_json::from_value(json!({ + "id": id, + "type": "function", + "function": { "name": name, "arguments": arguments } + })) + .unwrap() + } + + #[test] + fn tool_call_arguments_are_kept_verbatim() { + let call = tool_call("call_1", "grep", r#"{"pattern":"fn main"}"#); + let message = Message::assistant_with_tool_calls("", vec![call]); + + let serialized = serde_json::to_value(&message).unwrap(); + + assert_eq!(serialized["content"], Value::Null); + assert_eq!(serialized["tool_calls"][0]["type"], "function"); + assert_eq!(serialized["tool_calls"][0]["function"]["name"], "grep"); + assert_eq!( + serialized["tool_calls"][0]["function"]["arguments"], + r#"{"pattern":"fn main"}"# + ); + } + + #[test] + fn tool_response_carries_role_and_tool_call_id() { + let message = Message::tool_response("call_1", "src/main.rs"); + let serialized = serde_json::to_value(&message).unwrap(); + + assert_eq!(serialized["role"], "tool"); + assert_eq!(serialized["tool_call_id"], "call_1"); + assert_eq!(serialized["content"], "src/main.rs"); + } + + #[test] + fn request_serializes_tool_choice_and_reasoning() { + let messages = [Message::new(Role::User, "hi")]; + let tools = [Tool::new("ls", "List files", json!({"type": "object"}))]; + + let request = serde_json::to_value(ChatRequest { + model: "some/model", + messages: &messages, + reasoning: Reasoning { enabled: true }, + tools: &tools, + tool_choice: TOOL_CHOICE_AUTO, + }) + .unwrap(); + + assert_eq!(request["model"], "some/model"); + assert_eq!(request["reasoning"]["enabled"], true); + assert_eq!(request["tool_choice"], "auto"); + assert_eq!(request["tools"][0]["function"]["name"], "ls"); + assert_eq!(request["messages"][0]["role"], "user"); + } + + #[test] + fn response_parses_tool_calls_and_cost() { + let response: ChatResponse = serde_json::from_value(json!({ + "choices": [{ + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": { "name": "ls", "arguments": "{\"path\":\"src\"}" } + }] + } + }], + "usage": { "cost": 0.0021 } + })) + .unwrap(); + + assert_eq!(response.usage.and_then(|usage| usage.cost), Some(0.0021)); + + let message = response.choices.into_iter().next().unwrap().message; + assert!(message.content.is_none()); + + let calls = message.tool_calls.unwrap(); + assert_eq!(calls[0].name(), "ls"); + assert_eq!(calls[0].id(), "call_1"); + assert_eq!(calls[0].arguments_json(), r#"{"path":"src"}"#); + } + + #[test] + fn response_parses_parts_content() { + let response: ChatResponse = serde_json::from_value(json!({ + "choices": [{ + "message": { + "role": "assistant", + "content": [ + { "type": "text", "text": "hello " }, + { "type": "text", "text": "world" } + ] + } + }] + })) + .unwrap(); + + assert!(response.usage.is_none()); + + let message = response.choices.into_iter().next().unwrap().message; + assert_eq!(message.content.unwrap().into_text(), "hello world"); + } + + #[test] + fn error_message_prefers_api_message() { + let body = r#"{"error":{"message":"No auth credentials found","code":401}}"#; + assert_eq!(error_message(body), "No auth credentials found"); + } + + #[test] + fn error_message_falls_back_to_raw_body() { + assert_eq!( + error_message(" bad gateway "), + "bad gateway" + ); + } } diff --git a/crates/herald-server/src/sandbox/agent.rs b/crates/herald-server/src/sandbox/agent.rs index 35d53c4..bf9e1e2 100644 --- a/crates/herald-server/src/sandbox/agent.rs +++ b/crates/herald-server/src/sandbox/agent.rs @@ -6,15 +6,11 @@ //! exhausted. use anyhow::Context; -use openrouter_rs::{ - Message, - types::{Role, Tool, ToolCall}, -}; use serde_json::Value; use tracing::{debug, warn}; use crate::{ - open_router::OpenRouterClient, + open_router::{Message, OpenRouterClient, Role, Tool, ToolCall}, sandbox::{Sandbox, tools}, }; @@ -105,10 +101,22 @@ fn parse_args(call: &ToolCall) -> anyhow::Result { #[cfg(test)] mod tests { use super::*; + use serde_json::json; + + /// Builds a tool call the way the API returns one, so the fixture also + /// covers deserialization. + fn tool_call(name: &str, arguments: &str) -> ToolCall { + serde_json::from_value(json!({ + "id": "call_1", + "type": "function", + "function": { "name": name, "arguments": arguments } + })) + .unwrap() + } #[test] fn parse_args_accepts_empty_arguments() { - let call = ToolCall::new("id", "ls", ""); + let call = tool_call("ls", ""); assert_eq!( parse_args(&call).unwrap(), Value::Object(serde_json::Map::new()) @@ -117,13 +125,13 @@ mod tests { #[test] fn parse_args_parses_json_object() { - let call = ToolCall::new("id", "ls", r#"{"path":"src"}"#); + let call = tool_call("ls", r#"{"path":"src"}"#); assert_eq!(parse_args(&call).unwrap()["path"], "src"); } #[test] fn parse_args_rejects_invalid_json() { - let call = ToolCall::new("id", "ls", "not json"); + let call = tool_call("ls", "not json"); assert!(parse_args(&call).is_err()); } } diff --git a/crates/herald-server/src/sandbox/mod.rs b/crates/herald-server/src/sandbox/mod.rs index f31a507..362376a 100644 --- a/crates/herald-server/src/sandbox/mod.rs +++ b/crates/herald-server/src/sandbox/mod.rs @@ -25,8 +25,6 @@ const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devc /// 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. diff --git a/crates/herald-server/src/sandbox/tools.rs b/crates/herald-server/src/sandbox/tools.rs index a595984..bf318e6 100644 --- a/crates/herald-server/src/sandbox/tools.rs +++ b/crates/herald-server/src/sandbox/tools.rs @@ -10,11 +10,10 @@ use std::path::Path; use anyhow::{Context, bail}; use devcontainer_rs::{ExecOutput, normalize}; -use openrouter_rs::types::Tool; use serde_json::{Value, json}; use super::Sandbox; -use crate::gitea::WebhookType; +use crate::{gitea::WebhookType, open_router::Tool}; /// Tools available to the model for a given webhook action. /// @@ -216,7 +215,6 @@ mod tests { WebhookType::Review(ReviewPayload { action: "created".to_string(), pull_request: PullRequest { - diff_url: "https://example.com/diff".to_string(), number: 1, title: "My PR".to_string(), }, From 78ad2bf7012afa593b92857c937573780855b361 Mon Sep 17 00:00:00 2001 From: qpismont Date: Thu, 17 Sep 2026 14:49:41 +0000 Subject: [PATCH 12/17] translate comment --- crates/devcontainer-rs/src/container.rs | 112 ++++++++++++------------ crates/devcontainer-rs/src/lib.rs | 6 +- 2 files changed, 59 insertions(+), 59 deletions(-) diff --git a/crates/devcontainer-rs/src/container.rs b/crates/devcontainer-rs/src/container.rs index a2ce142..54b2f3a 100644 --- a/crates/devcontainer-rs/src/container.rs +++ b/crates/devcontainer-rs/src/container.rs @@ -1,20 +1,20 @@ -//! Container lifecycle primitives for a parsed [`DevContainer`]. +//! Primitives de cycle de vie de container pour un [`DevContainer`] analysé. //! -//! This module shells out to a container runtime (`docker` or `podman`) to build -//! the devcontainer image, start a container with the workspace mounted, run the -//! `postCreateCommand` / `postStartCommand` hooks and execute commands inside the -//! running container. +//! Ce module invoque un runtime de containers (`docker` ou `podman`) pour construire +//! l'image devcontainer, démarrer un container avec le workspace monté, exécuter les +//! hooks `postCreateCommand` / `postStartCommand` et lancer des commandes à l'intérieur +//! du container en cours d'exécution. //! -//! It is intentionally runtime-agnostic: any binary exposing the `docker` CLI -//! surface (including `podman`) can be used via [`ContainerRuntime::new`]. +//! Il est volontairement agnostique du runtime : tout binaire exposant l'interface +//! CLI `docker` (y compris `podman`) peut être utilisé via [`ContainerRuntime::new`]. //! //! # Isolation //! -//! Each sandbox gets its own image tag, its own container and its own network. -//! The container starts attached to that network so the `postCreateCommand` / -//! `postStartCommand` hooks can fetch dependencies (e.g. `npm install`); once the -//! hooks have run, the container is disconnected from the network for the rest of -//! its lifetime. Every command is bounded by a timeout. +//! Chaque sandbox dispose de son propre tag d'image, de son propre container et de +//! son propre réseau. Le container démarre attaché à ce réseau afin que les hooks +//! `postCreateCommand` / `postStartCommand` puissent récupérer des dépendances +//! (par ex. `npm install`) ; une fois les hooks exécutés, le container est déconnecté +//! du réseau pour le reste de sa durée de vie. Chaque commande est bornée par un timeout. use std::{ path::{Path, PathBuf}, @@ -26,16 +26,16 @@ use tokio::process::Command; use crate::DevContainer; -/// Timeout applied to build/run/stop/remove operations. +/// Timeout appliqué aux opérations de build/run/stop/remove. const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(600); -/// Timeout applied to commands executed inside a running container. +/// Timeout appliqué aux commandes exécutées dans un container en cours d'exécution. const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(60); -/// Result of a command executed inside a container. +/// Résultat d'une commande exécutée dans un container. #[derive(Debug, Clone)] pub struct ExecOutput { - /// Exit code, or `-1` if the process was terminated by a signal. + /// Code de sortie, ou `-1` si le processus a été terminé par un signal. pub status: i32, pub stdout: String, pub stderr: String, @@ -46,7 +46,7 @@ impl ExecOutput { self.status == 0 } - /// Turns a non-zero exit code into a [`ContainerError::Command`]. + /// Transforme un code de sortie non nul en [`ContainerError::Command`]. pub fn ensure_success(self, program: &str, args: &[String]) -> Result { if self.success() { return Ok(self); @@ -85,7 +85,7 @@ pub enum ContainerError { }, } -/// A container runtime binary exposing the `docker` CLI surface. +/// Un binaire de runtime de containers exposant l'interface CLI `docker`. #[derive(Debug, Clone)] pub struct ContainerRuntime { program: String, @@ -108,7 +108,7 @@ impl ContainerRuntime { Self::new("podman") } - /// Overrides the timeout applied to build/run/stop/remove operations. + /// Remplace le timeout appliqué aux opérations de build/run/stop/remove. pub fn with_timeout(mut self, timeout: Duration) -> Self { self.timeout = timeout; self @@ -122,7 +122,7 @@ impl ContainerRuntime { self.timeout } - /// Checks that the runtime binary is present and responsive. + /// Vérifie que le binaire du runtime est présent et répond. pub async fn available(&self) -> bool { Command::new(&self.program) .arg("version") @@ -135,15 +135,15 @@ impl ContainerRuntime { .unwrap_or(false) } - /// Runs the runtime with the given arguments, capturing stdout/stderr. + /// Exécute le runtime avec les arguments donnés, en capturant stdout/stderr. /// - /// Only spawn failures and timeouts are reported as errors; a non-zero exit - /// code is returned in the [`ExecOutput`] so callers can decide how to react. + /// Seuls les échecs de lancement (spawn) et les timeouts sont des erreurs ; un + /// code non nul est renvoyé dans [`ExecOutput`] afin que les appelants réagissent. pub async fn run(&self, args: &[String]) -> Result { self.run_with_timeout(args, self.timeout).await } - /// Like [`run`](Self::run) with an explicit timeout. + /// Comme [`run`](Self::run), mais avec un timeout explicite. pub async fn run_with_timeout( &self, args: &[String], @@ -174,7 +174,7 @@ impl ContainerRuntime { } } -/// A running devcontainer. +/// Un devcontainer en cours d'exécution. #[derive(Debug, Clone)] pub struct Container { runtime: ContainerRuntime, @@ -194,10 +194,10 @@ impl Container { &self.workspace_folder } - /// Executes a command inside the container, returning its output. + /// Exécute une commande dans le container et renvoie sa sortie. /// - /// The command is passed as an argv vector (no shell), so no quoting or - /// interpolation is performed. + /// La commande est transmise sous forme de vecteur d'arguments (argv, sans shell), + /// donc aucun échappement ni interpolation n'est effectué. pub async fn exec(&self, cmd: &[&str]) -> Result { self.exec_with_timeout(cmd, DEFAULT_EXEC_TIMEOUT).await } @@ -220,12 +220,12 @@ impl Container { self.runtime.run_with_timeout(&args, timeout).await } - /// Executes a shell script inside the container via `sh -c`. + /// Exécute un script shell dans le container via `sh -c`. pub async fn exec_shell(&self, script: &str) -> Result { self.exec(&["sh", "-c", script]).await } - /// Stops the container. + /// Arrête le container. pub async fn stop(&self) -> Result<(), ContainerError> { let args = vec!["stop".to_string(), self.name.clone()]; self.runtime @@ -235,9 +235,9 @@ impl Container { Ok(()) } - /// Removes the container, its network and its image. + /// Supprime le container, son réseau et son image. /// - /// Network and image removal are best-effort: they may already be gone. + /// La suppression du réseau et de l'image est best-effort : ils peuvent déjà être absents. pub async fn remove(&self) -> Result<(), ContainerError> { let args = vec![ "rm".to_string(), @@ -265,21 +265,21 @@ impl Container { } impl DevContainer { - /// Base image name derived from the devcontainer name. + /// Nom de l'image de base dérivé du nom du devcontainer. pub fn image_name(&self) -> String { let base = self.name.as_deref().unwrap_or("devcontainer"); format!("devcontainer-rs/{}", sanitize(base)) } - /// Unique image tag for a single sandbox run. + /// Tag d'image unique pour une exécution de sandbox donnée. /// - /// Uniqueness matters: two concurrent sandboxes (possibly for different - /// repositories sharing a devcontainer name) must not race on a shared tag. + /// L'unicité est importante : deux sandboxes concurrentes (éventuellement pour des + /// dépôts différents partageant un nom de devcontainer) ne doivent pas se disputer le même tag. pub fn image_tag(&self) -> String { format!("{}:{}", self.image_name(), unique_suffix()) } - /// Arguments passed to `docker build` (everything after the `build` verb). + /// Arguments passés à `docker build` (tout ce qui suit le verbe `build`). pub fn build_args(&self, image_tag: &str) -> Vec { let context = self .container_file_path @@ -302,7 +302,7 @@ impl DevContainer { args } - /// Arguments passed to `docker run` (everything after the `run` verb). + /// Arguments passés à `docker run` (tout ce qui suit le verbe `run`). pub fn run_args( &self, workspace_dir: &Path, @@ -340,27 +340,27 @@ impl DevContainer { args.extend(self.run_args.iter().cloned()); args.push(image_tag.to_string()); - // Keep the container alive so we can `exec` into it. + // Maintient le container en vie pour pouvoir y exécuter `exec`. args.push("sleep".to_string()); args.push("infinity".to_string()); args } - /// Workspace folder inside the container, defaulting to `/workspaces/workspace`. + /// Dossier de workspace dans le container, par défaut `/workspaces/workspace`. pub fn workspace_folder(&self) -> String { self.workspace_folder .clone() .unwrap_or_else(|| "/workspaces/workspace".to_string()) } - /// Unique container name for this run. + /// Nom de container unique pour cette exécution. pub fn container_name(&self) -> String { let base = self.name.as_deref().unwrap_or("devcontainer"); format!("devcontainer-rs-{}-{}", sanitize(base), unique_suffix()) } - /// Builds the devcontainer image under `image_tag`. + /// Construit l'image devcontainer sous `image_tag`. pub async fn build( &self, runtime: &ContainerRuntime, @@ -377,12 +377,12 @@ impl DevContainer { Ok(()) } - /// Builds the image, starts the container with the workspace mounted, runs - /// the `postCreateCommand` / `postStartCommand` hooks with network access, - /// then disconnects the container from the network. + /// Construit l'image, démarre le container avec le workspace monté, exécute les + /// hooks `postCreateCommand` / `postStartCommand` avec accès au réseau, puis + /// déconnecte le container du réseau. /// - /// On any failure the container, network and image are cleaned up before - /// returning, so no resource is leaked. + /// En cas d'échec, le container, le réseau et l'image sont nettoyés avant de + /// renvoyer l'erreur, afin qu'aucune ressource ne soit laissée en place. pub async fn up( &self, runtime: &ContainerRuntime, @@ -394,7 +394,7 @@ impl DevContainer { let name = self.container_name(); let network = format!("{name}-net"); - // Dedicated network so connectivity can be cut after the hooks. + // Réseau dédié afin de pouvoir couper la connectivité après les hooks. let args = vec!["network".to_string(), "create".to_string(), network.clone()]; if let Err(err) = runtime .run(&args) @@ -429,13 +429,13 @@ impl DevContainer { image: Some(image_tag), }; - // Hooks run with network access (dependency installation, etc.). + // Les hooks s'exécutent avec accès au réseau (installation de dépendances, etc.). if let Err(err) = self.run_hooks(&container).await { let _ = container.remove().await; return Err(err); } - // Cut network access for the rest of the sandbox lifetime. + // Coupe l'accès réseau pour le reste de la durée de vie de la sandbox. let args = vec![ "network".to_string(), "disconnect".to_string(), @@ -455,8 +455,8 @@ impl DevContainer { } async fn run_hooks(&self, container: &Container) -> Result<(), ContainerError> { - // Hooks may install dependencies, so they get the long command timeout - // rather than the short one used for tool execution. + // Les hooks peuvent installer des dépendances, ils utilisent donc le timeout + // long des commandes plutôt que le court réservé à l'exécution des outils. let timeout = container.runtime.timeout(); for command in [&self.post_create_command, &self.post_start_command] @@ -476,7 +476,7 @@ impl DevContainer { } } -/// Sanitizes a string so it can be used as a docker image/container name. +/// Nettoie une chaîne pour qu'elle puisse servir de nom d'image/container docker. fn sanitize(input: &str) -> String { let sanitized: String = input .chars() @@ -497,7 +497,7 @@ fn sanitize(input: &str) -> String { } } -/// Suffix unique to a sandbox run, combining the process id and a timestamp. +/// Suffixe unique à une exécution de sandbox, combinant l'identifiant de processus et un horodatage. fn unique_suffix() -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -507,8 +507,8 @@ fn unique_suffix() -> String { format!("{}-{}", std::process::id(), nanos) } -/// Lexically normalizes a path, resolving `.` and `..` without touching the -/// filesystem. Returns `None` if the path escapes its root. +/// Normalise lexicalement un chemin, en résolvant `.` et `..` sans toucher au +/// système de fichiers. Renvoie `None` si le chemin sort de sa racine. pub fn normalize(path: &Path) -> Option { use std::path::Component; diff --git a/crates/devcontainer-rs/src/lib.rs b/crates/devcontainer-rs/src/lib.rs index 8743f0a..67914b9 100644 --- a/crates/devcontainer-rs/src/lib.rs +++ b/crates/devcontainer-rs/src/lib.rs @@ -119,9 +119,9 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer { } } -/// Resolves `${localEnv:VAR}` and `${localEnv:VAR:default}` references using the -/// current process environment, as described by the devcontainer specification. -/// Unresolved variables without a default expand to an empty string. +/// Résout les références `${localEnv:VAR}` et `${localEnv:VAR:default}` à l'aide de +/// l'environnement du processus courant, comme décrit par la spécification devcontainer. +/// Les variables non résolues sans valeur par défaut sont remplacées par une chaîne vide. fn substitute_local_env(input: &str) -> String { const PREFIX: &str = "${localEnv:"; From 624bc1e02878b0ed387f306bc0d5fdfd19083895 Mon Sep 17 00:00:00 2001 From: qpismont Date: Thu, 17 Sep 2026 19:51:59 +0000 Subject: [PATCH 13/17] replace ContainerRuntime CLI to Bollard crate (default: docker socket) limit tool result to open router tool response (with truncated info for ai) Hard kill if graceful shutdown is too long --- .env.example | 2 +- Cargo.lock | 136 ++- Cargo.toml | 2 + Containerfile | 33 +- README.md | 16 +- crates/devcontainer-rs/Cargo.toml | 5 + crates/devcontainer-rs/src/container.rs | 862 ++++++++++++------ crates/devcontainer-rs/src/lib.rs | 12 +- .../herald-server/src/bot_actions/review.rs | 108 ++- crates/herald-server/src/consts.rs | 5 +- crates/herald-server/src/env.rs | 4 - crates/herald-server/src/main.rs | 42 +- crates/herald-server/src/open_router.rs | 48 +- crates/herald-server/src/sandbox/agent.rs | 221 ++++- crates/herald-server/src/sandbox/mod.rs | 104 ++- crates/herald-server/src/text.rs | 81 ++ 16 files changed, 1327 insertions(+), 354 deletions(-) create mode 100644 crates/herald-server/src/text.rs diff --git a/.env.example b/.env.example index d35705b..9f25ae2 100644 --- a/.env.example +++ b/.env.example @@ -20,5 +20,5 @@ RUST_BACKTRACE=1 METRICS_BIND_ADDR= # Sandboxed tool execution -CONTAINER_RUNTIME=docker +# DOCKER_HOST= SANDBOX_MAX_ITERATIONS=8 diff --git a/Cargo.lock b/Cargo.lock index 4444691..2ba0448 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 1fef14f..124b5c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/Containerfile b/Containerfile index 7150f27..c39abf8 100644 --- a/Containerfile +++ b/Containerfile @@ -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: [""]`. +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"] diff --git a/README.md b/README.md index 235c963..3c69327 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/crates/devcontainer-rs/Cargo.toml b/crates/devcontainer-rs/Cargo.toml index 1dd07fb..0294fe6 100644 --- a/crates/devcontainer-rs/Cargo.toml +++ b/crates/devcontainer-rs/Cargo.toml @@ -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 } diff --git a/crates/devcontainer-rs/src/container.rs b/crates/devcontainer-rs/src/container.rs index 54b2f3a..224cf4d 100644 --- a/crates/devcontainer-rs/src/container.rs +++ b/crates/devcontainer-rs/src/container.rs @@ -1,12 +1,15 @@ //! Primitives de cycle de vie de container pour un [`DevContainer`] analysé. //! -//! Ce module invoque un runtime de containers (`docker` ou `podman`) pour construire -//! l'image devcontainer, démarrer un container avec le workspace monté, exécuter les -//! hooks `postCreateCommand` / `postStartCommand` et lancer des commandes à l'intérieur -//! du container en cours d'exécution. +//! Ce module pilote l'API du daemon de containers pour construire l'image +//! devcontainer, démarrer un container avec le workspace monté, exécuter les +//! hooks `postCreateCommand` / `postStartCommand` et lancer des commandes à +//! l'intérieur du container en cours d'exécution. //! -//! Il est volontairement agnostique du runtime : tout binaire exposant l'interface -//! CLI `docker` (y compris `podman`) peut être utilisé via [`ContainerRuntime::new`]. +//! Le daemon est joint par son **socket** (celui de l'hôte, monté dans le +//! container de Herald) : [`ContainerRuntime::connect`] suit `DOCKER_HOST` comme +//! le fait le CLI `docker`, et retombe sur le socket local. Aucun binaire de +//! runtime n'est donc requis dans l'image, et `podman` fonctionne de la même +//! façon dès lors qu'il expose son socket compatible Docker. //! //! # Isolation //! @@ -15,23 +18,59 @@ //! `postCreateCommand` / `postStartCommand` puissent récupérer des dépendances //! (par ex. `npm install`) ; une fois les hooks exécutés, le container est déconnecté //! du réseau pour le reste de sa durée de vie. Chaque commande est bornée par un timeout. +//! +//! Les `runArgs` du `devcontainer.json` sont lus mais pas transmis au daemon : ils +//! viennent du dépôt, donc d'une pull request non fiable, et pourraient rattacher le +//! container à un autre réseau ou lui donner des privilèges qui annuleraient cette +//! isolation. use std::{ + collections::HashMap, + io::{BufWriter, Write}, path::{Path, PathBuf}, - process::Stdio, time::{Duration, SystemTime, UNIX_EPOCH}, }; -use tokio::process::Command; +use bollard::{ + Docker, body_try_stream, + container::LogOutput, + errors::Error as BollardError, + exec::{CreateExecOptions, StartExecOptions, StartExecResults}, + models::{ + BuildInfo, ContainerCreateBody, HostConfig, NetworkCreateRequest, NetworkDisconnectRequest, + }, + query_parameters::{ + BuildImageOptions, CreateContainerOptions, RemoveContainerOptions, RemoveImageOptions, + StartContainerOptions, StopContainerOptions, + }, +}; +use bytes::Bytes; +use futures_util::{Stream, StreamExt}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; use crate::DevContainer; +/// Endpoint affiché dans les logs quand `DOCKER_HOST` n'est pas défini. +const DEFAULT_ENDPOINT: &str = "unix:///var/run/docker.sock"; + /// Timeout appliqué aux opérations de build/run/stop/remove. const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(600); /// Timeout appliqué aux commandes exécutées dans un container en cours d'exécution. const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(60); +/// Taille des morceaux du contexte de build envoyés au daemon. +const CONTEXT_CHUNK_SIZE: usize = 64 * 1024; + +/// Nombre de morceaux pouvant attendre dans le canal : c'est ce qui borne la +/// mémoire occupée par le contexte, quelle que soit sa taille. +const CONTEXT_CHUNKS: usize = 4; + +/// Lectures successives du code de sortie d'un exec, et attente entre elles. +const EXIT_CODE_ATTEMPTS: usize = 10; +const EXIT_CODE_DELAY: Duration = Duration::from_millis(20); + /// Résultat d'une commande exécutée dans un container. #[derive(Debug, Clone)] pub struct ExecOutput { @@ -45,67 +84,52 @@ impl ExecOutput { pub fn success(&self) -> bool { self.status == 0 } - - /// Transforme un code de sortie non nul en [`ContainerError::Command`]. - pub fn ensure_success(self, program: &str, args: &[String]) -> Result { - if self.success() { - return Ok(self); - } - - Err(ContainerError::Command { - program: program.to_string(), - args: args.join(" "), - status: self.status, - stderr: self.stderr.trim().to_string(), - }) - } } #[derive(Debug, thiserror::Error)] pub enum ContainerError { - #[error("failed to run `{program}`: {source}")] - Spawn { - program: String, - source: std::io::Error, + /// Le daemon est injoignable, ou a refusé une requête. + #[error("container daemon request failed: {source}")] + Request { + #[from] + source: BollardError, }, - #[error("`{program} {args}` failed with status {status}: {stderr}")] - Command { - program: String, - args: String, - status: i32, - stderr: String, - }, - - #[error("`{program} {args}` timed out after {timeout:?}")] + /// Une opération a dépassé son timeout. + #[error("`{operation}` timed out after {timeout:?}")] Timeout { - program: String, - args: String, + operation: String, timeout: Duration, }, + + /// La construction de l'image a échoué. + #[error("image build failed: {message}")] + Build { message: String }, + + /// Le daemon a répondu autre chose que ce qui était attendu. + #[error("{0}")] + Unexpected(String), } -/// Un binaire de runtime de containers exposant l'interface CLI `docker`. +/// Un client de l'API du daemon de containers. #[derive(Debug, Clone)] pub struct ContainerRuntime { - program: String, + docker: Docker, + endpoint: String, timeout: Duration, } impl ContainerRuntime { - pub fn new(program: impl Into) -> Self { - Self { - program: program.into(), + /// Se connecte au daemon désigné par `DOCKER_HOST`, ou au socket local par défaut. + pub fn connect() -> Result { + let endpoint = + std::env::var("DOCKER_HOST").unwrap_or_else(|_| String::from(DEFAULT_ENDPOINT)); + + Ok(Self { + docker: Docker::connect_with_defaults()?, + endpoint, timeout: DEFAULT_COMMAND_TIMEOUT, - } - } - - pub fn docker() -> Self { - Self::new("docker") - } - - pub fn podman() -> Self { - Self::new("podman") + }) } /// Remplace le timeout appliqué aux opérations de build/run/stop/remove. @@ -114,64 +138,301 @@ impl ContainerRuntime { self } - pub fn program(&self) -> &str { - &self.program + /// Endpoint du daemon, pour les logs. + pub fn endpoint(&self) -> &str { + &self.endpoint } pub fn timeout(&self) -> Duration { self.timeout } - /// Vérifie que le binaire du runtime est présent et répond. + /// Vérifie que le daemon est joignable et répond. pub async fn available(&self) -> bool { - Command::new(&self.program) - .arg("version") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await - .map(|status| status.success()) - .unwrap_or(false) + self.docker.ping().await.is_ok() } - /// Exécute le runtime avec les arguments donnés, en capturant stdout/stderr. - /// - /// Seuls les échecs de lancement (spawn) et les timeouts sont des erreurs ; un - /// code non nul est renvoyé dans [`ExecOutput`] afin que les appelants réagissent. - pub async fn run(&self, args: &[String]) -> Result { - self.run_with_timeout(args, self.timeout).await - } - - /// Comme [`run`](Self::run), mais avec un timeout explicite. - pub async fn run_with_timeout( + /// Exécute une requête du daemon en appliquant le timeout des commandes. + async fn request( &self, - args: &[String], - timeout: Duration, - ) -> Result { - let output = Command::new(&self.program) - .args(args) - .stdin(Stdio::null()) - .kill_on_drop(true) - .output(); - - match tokio::time::timeout(timeout, output).await { - Ok(Ok(output)) => Ok(ExecOutput { - status: output.status.code().unwrap_or(-1), - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - }), - Ok(Err(source)) => Err(ContainerError::Spawn { - program: self.program.clone(), - source, - }), + operation: &str, + request: impl Future>, + ) -> Result { + match tokio::time::timeout(self.timeout, request).await { + Ok(Ok(value)) => Ok(value), + Ok(Err(source)) => Err(ContainerError::Request { source }), Err(_) => Err(ContainerError::Timeout { - program: self.program.clone(), - args: args.join(" "), - timeout, + operation: String::from(operation), + timeout: self.timeout, }), } } + + /// Construit l'image `tag` depuis le contexte `context_dir`. + async fn build_image( + &self, + context_dir: &Path, + dockerfile: &str, + tag: &str, + build_args: &HashMap, + ) -> Result<(), ContainerError> { + let options = BuildImageOptions { + dockerfile: String::from(dockerfile), + t: Some(String::from(tag)), + buildargs: Some(build_args.clone()), + rm: true, + ..Default::default() + }; + + let context = tar_directory_stream(context_dir); + let mut stream = self + .docker + .build_image(options, None, Some(body_try_stream(context))); + + // Le flux porte la progression du build : la dernière erreur signalée par le + // daemon fait échouer l'opération. + let consume = async { + let mut failure = None; + + while let Some(info) = stream.next().await { + let info: BuildInfo = info?; + + if let Some(message) = info.error_detail.and_then(|detail| detail.message) { + failure = Some(message); + } + } + + Ok::<_, BollardError>(failure) + }; + + let failure = match tokio::time::timeout(self.timeout, consume).await { + Ok(Ok(failure)) => failure, + Ok(Err(source)) => return Err(ContainerError::Request { source }), + Err(_) => { + return Err(ContainerError::Timeout { + operation: format!("build image `{tag}`"), + timeout: self.timeout, + }); + } + }; + + match failure { + Some(message) => Err(ContainerError::Build { message }), + None => Ok(()), + } + } + + /// Crée un container, sans le démarrer. + async fn create_container( + &self, + name: &str, + body: ContainerCreateBody, + ) -> Result<(), ContainerError> { + let options = CreateContainerOptions { + name: Some(String::from(name)), + ..Default::default() + }; + + self.request( + "create container", + self.docker.create_container(Some(options), body), + ) + .await?; + + Ok(()) + } + + async fn start_container(&self, name: &str) -> Result<(), ContainerError> { + self.request( + "start container", + self.docker + .start_container(name, None::), + ) + .await?; + + Ok(()) + } + + /// Exécute une commande dans un container et renvoie sa sortie. + async fn exec( + &self, + container: &str, + cmd: &[&str], + user: Option<&str>, + timeout: Duration, + ) -> Result { + let config = CreateExecOptions { + cmd: Some(cmd.iter().map(|arg| String::from(*arg)).collect()), + user: user.map(String::from), + attach_stdout: Some(true), + attach_stderr: Some(true), + ..Default::default() + }; + + let created = self + .request("create exec", self.docker.create_exec(container, config)) + .await?; + + let started = self + .request( + "start exec", + self.docker + .start_exec(&created.id, None::), + ) + .await?; + + let StartExecResults::Attached { mut output, .. } = started else { + return Err(ContainerError::Unexpected(String::from( + "the daemon detached an exec that was not requested as detached", + ))); + }; + + let mut stdout = String::new(); + let mut stderr = String::new(); + + // Le timeout couvre l'exécution de la commande elle-même, pas seulement sa + // mise en place : le processus continue de tourner dans le container, il + // disparaît avec lui. + let collect = async { + while let Some(message) = output.next().await { + match message? { + LogOutput::StdOut { message } | LogOutput::Console { message } => { + stdout.push_str(&String::from_utf8_lossy(&message)); + } + LogOutput::StdErr { message } => { + stderr.push_str(&String::from_utf8_lossy(&message)); + } + LogOutput::StdIn { .. } => {} + } + } + + Ok::<_, BollardError>(()) + }; + + match tokio::time::timeout(timeout, collect).await { + Ok(Ok(())) => {} + Ok(Err(source)) => return Err(ContainerError::Request { source }), + Err(_) => { + return Err(ContainerError::Timeout { + operation: format!("exec {}", cmd.join(" ")), + timeout, + }); + } + } + + // Le code de sortie n'est pas forcément publié au moment où le flux se ferme : + // on laisse au daemon le temps de le renseigner, sans bloquer indéfiniment. + // Sans cela, une commande réussie peut être rapportée en échec (code -1) et + // l'outil renvoie une erreur au modèle à la place du contenu. + let mut inspected = self + .request("inspect exec", self.docker.inspect_exec(&created.id)) + .await?; + + for _ in 0..EXIT_CODE_ATTEMPTS { + if !inspected.running.unwrap_or(false) { + break; + } + + tokio::time::sleep(EXIT_CODE_DELAY).await; + + inspected = self + .request("inspect exec", self.docker.inspect_exec(&created.id)) + .await?; + } + + Ok(ExecOutput { + status: inspected.exit_code.unwrap_or(-1) as i32, + stdout, + stderr, + }) + } + + async fn stop_container(&self, name: &str) -> Result<(), ContainerError> { + let options = StopContainerOptions { + t: Some(1), + ..Default::default() + }; + + self.request( + "stop container", + self.docker.stop_container(name, Some(options)), + ) + .await?; + + Ok(()) + } + + /// Supprime un container, ses volumes anonymes et le processus qui y tourne. + async fn remove_container(&self, name: &str) -> Result<(), ContainerError> { + let options = RemoveContainerOptions { + force: true, + v: true, + ..Default::default() + }; + + self.request( + "remove container", + self.docker.remove_container(name, Some(options)), + ) + .await?; + + Ok(()) + } + + async fn remove_image(&self, tag: &str) -> Result<(), ContainerError> { + let options = RemoveImageOptions { + force: true, + ..Default::default() + }; + + self.request( + "remove image", + self.docker.remove_image(tag, Some(options), None), + ) + .await?; + + Ok(()) + } + + /// Crée le réseau dédié d'une sandbox. + async fn create_network(&self, name: &str) -> Result<(), ContainerError> { + let request = NetworkCreateRequest { + name: String::from(name), + ..Default::default() + }; + + self.request("create network", self.docker.create_network(request)) + .await?; + + Ok(()) + } + + /// Détache un container d'un réseau, ce qui coupe sa connectivité. + async fn disconnect_network( + &self, + network: &str, + container: &str, + ) -> Result<(), ContainerError> { + let request = NetworkDisconnectRequest { + container: String::from(container), + ..Default::default() + }; + + self.request( + "disconnect network", + self.docker.disconnect_network(network, request), + ) + .await?; + + Ok(()) + } + + async fn remove_network(&self, name: &str) -> Result<(), ContainerError> { + self.request("remove network", self.docker.remove_network(name)) + .await?; + + Ok(()) + } } /// Un devcontainer en cours d'exécution. @@ -207,17 +468,9 @@ impl Container { cmd: &[&str], timeout: Duration, ) -> Result { - let mut args = vec!["exec".to_string()]; - - if let Some(user) = &self.remote_user { - args.push("--user".to_string()); - args.push(user.clone()); - } - - args.push(self.name.clone()); - args.extend(cmd.iter().map(|arg| arg.to_string())); - - self.runtime.run_with_timeout(&args, timeout).await + self.runtime + .exec(&self.name, cmd, self.remote_user.as_deref(), timeout) + .await } /// Exécute un script shell dans le container via `sh -c`. @@ -227,37 +480,21 @@ impl Container { /// Arrête le container. pub async fn stop(&self) -> Result<(), ContainerError> { - let args = vec!["stop".to_string(), self.name.clone()]; - self.runtime - .run(&args) - .await? - .ensure_success(self.runtime.program(), &args)?; - Ok(()) + self.runtime.stop_container(&self.name).await } /// Supprime le container, son réseau et son image. /// /// La suppression du réseau et de l'image est best-effort : ils peuvent déjà être absents. pub async fn remove(&self) -> Result<(), ContainerError> { - let args = vec![ - "rm".to_string(), - "-f".to_string(), - "-v".to_string(), - self.name.clone(), - ]; - self.runtime - .run(&args) - .await? - .ensure_success(self.runtime.program(), &args)?; + self.runtime.remove_container(&self.name).await?; if let Some(network) = &self.network { - let args = vec!["network".to_string(), "rm".to_string(), network.clone()]; - let _ = self.runtime.run(&args).await; + let _ = self.runtime.remove_network(network).await; } if let Some(image) = &self.image { - let args = vec!["rmi".to_string(), image.clone()]; - let _ = self.runtime.run(&args).await; + let _ = self.runtime.remove_image(image).await; } Ok(()) @@ -279,74 +516,6 @@ impl DevContainer { format!("{}:{}", self.image_name(), unique_suffix()) } - /// Arguments passés à `docker build` (tout ce qui suit le verbe `build`). - pub fn build_args(&self, image_tag: &str) -> Vec { - let context = self - .container_file_path - .parent() - .unwrap_or_else(|| Path::new(".")); - - let mut args = vec![ - "-f".to_string(), - self.container_file_path.display().to_string(), - "-t".to_string(), - image_tag.to_string(), - ]; - - for (key, value) in &self.build_args { - args.push("--build-arg".to_string()); - args.push(format!("{key}={value}")); - } - - args.push(context.display().to_string()); - args - } - - /// Arguments passés à `docker run` (tout ce qui suit le verbe `run`). - pub fn run_args( - &self, - workspace_dir: &Path, - container_name: &str, - image_tag: &str, - network: Option<&str>, - ) -> Vec { - let workspace_folder = self.workspace_folder(); - - let mut args = vec![ - "-d".to_string(), - "--name".to_string(), - container_name.to_string(), - "-v".to_string(), - format!("{}:{}", workspace_dir.display(), workspace_folder), - "-w".to_string(), - workspace_folder.to_string(), - ]; - - if let Some(network) = network { - args.push("--network".to_string()); - args.push(network.to_string()); - } - - if let Some(user) = &self.remote_user { - args.push("--user".to_string()); - args.push(user.clone()); - } - - for (key, value) in &self.container_env { - args.push("-e".to_string()); - args.push(format!("{key}={value}")); - } - - args.extend(self.run_args.iter().cloned()); - - args.push(image_tag.to_string()); - // Maintient le container en vie pour pouvoir y exécuter `exec`. - args.push("sleep".to_string()); - args.push("infinity".to_string()); - - args - } - /// Dossier de workspace dans le container, par défaut `/workspaces/workspace`. pub fn workspace_folder(&self) -> String { self.workspace_folder @@ -366,20 +535,28 @@ impl DevContainer { runtime: &ContainerRuntime, image_tag: &str, ) -> Result<(), ContainerError> { - let mut args = vec!["build".to_string()]; - args.extend(self.build_args(image_tag)); + let context = self.container_file_path.parent().ok_or_else(|| { + ContainerError::Unexpected(String::from( + "the devcontainer file path has no parent directory", + )) + })?; + + let dockerfile = self + .container_file_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + ContainerError::Unexpected(String::from( + "the devcontainer file name is not valid UTF-8", + )) + })?; runtime - .run(&args) - .await? - .ensure_success(runtime.program(), &args)?; - - Ok(()) + .build_image(context, dockerfile, image_tag, &self.build_args) + .await } - /// Construit l'image, démarre le container avec le workspace monté, exécute les - /// hooks `postCreateCommand` / `postStartCommand` avec accès au réseau, puis - /// déconnecte le container du réseau. + /// Construit l'image, démarre le container, exécute les hooks puis coupe le réseau. /// /// En cas d'échec, le container, le réseau et l'image sont nettoyés avant de /// renvoyer l'erreur, afin qu'aucune ressource ne soit laissée en place. @@ -395,28 +572,50 @@ impl DevContainer { let network = format!("{name}-net"); // Réseau dédié afin de pouvoir couper la connectivité après les hooks. - let args = vec!["network".to_string(), "create".to_string(), network.clone()]; - if let Err(err) = runtime - .run(&args) - .await - .and_then(|out| out.ensure_success(runtime.program(), &args)) - { - let _ = runtime.run(&["rmi".to_string(), image_tag]).await; + if let Err(err) = runtime.create_network(&network).await { + let _ = runtime.remove_image(&image_tag).await; return Err(err); } - let mut args = vec!["run".to_string()]; - args.extend(self.run_args(workspace_dir, &name, &image_tag, Some(&network))); + let workspace_folder = self.workspace_folder(); + let body = ContainerCreateBody { + image: Some(image_tag.clone()), + cmd: Some(vec![String::from("sleep"), String::from("infinity")]), + user: self.remote_user.clone(), + env: Some( + self.container_env + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect(), + ), + working_dir: Some(workspace_folder.clone()), + host_config: Some(HostConfig { + binds: Some(vec![format!( + "{}:{workspace_folder}", + workspace_dir.display() + )]), + network_mode: Some(network.clone()), + // Le clone est monté depuis un chemin de l'hôte. Sur une distribution + // à SELinux enforcing, ce chemin n'a pas le label attendu et l'accès + // est refusé (EACCES), ce qui fait échouer tous les outils de la + // sandbox. C'est le compromis inverse de l'alternative `:Z` sur le + // montage, qui re-labellise le clone et garde le confinement SELinux. + security_opt: Some(vec![String::from("label=disable")]), + ..Default::default() + }), + ..Default::default() + }; - if let Err(err) = runtime - .run(&args) - .await - .and_then(|out| out.ensure_success(runtime.program(), &args)) - { - let _ = runtime - .run(&["network".to_string(), "rm".to_string(), network]) - .await; - let _ = runtime.run(&["rmi".to_string(), image_tag]).await; + if let Err(err) = runtime.create_container(&name, body).await { + let _ = runtime.remove_network(&network).await; + let _ = runtime.remove_image(&image_tag).await; + return Err(err); + } + + if let Err(err) = runtime.start_container(&name).await { + let _ = runtime.remove_container(&name).await; + let _ = runtime.remove_network(&network).await; + let _ = runtime.remove_image(&image_tag).await; return Err(err); } @@ -436,17 +635,7 @@ impl DevContainer { } // Coupe l'accès réseau pour le reste de la durée de vie de la sandbox. - let args = vec![ - "network".to_string(), - "disconnect".to_string(), - network, - container.name.clone(), - ]; - if let Err(err) = runtime - .run(&args) - .await - .and_then(|out| out.ensure_success(runtime.program(), &args)) - { + if let Err(err) = runtime.disconnect_network(&network, container.name()).await { let _ = container.remove().await; return Err(err); } @@ -466,16 +655,81 @@ impl DevContainer { let output = container .exec_with_timeout(&["sh", "-c", command], timeout) .await?; - output.ensure_success( - container.runtime.program(), - &["exec".to_string(), command.clone()], - )?; + + if !output.success() { + return Err(ContainerError::Unexpected(format!( + "hook `{command}` failed with status {}: {}", + output.status, + output.stderr.trim() + ))); + } } Ok(()) } } +/// Empaquette le contexte de build dans un tar, **en flux**. +/// +/// Le CLI `docker build` empaquette le contexte puis l'envoie ; le faire d'un coup +/// chargerait tout le dossier en mémoire, ce qui devient intenable dès que le +/// `.devcontainer` embarque des binaires. Ici l'archive est écrite par une tâche +/// bloquante et poussée morceau par morceau, avec la contre-pression du canal : +/// si le daemon lit lentement, l'empaquetage ralentit. +fn tar_directory_stream( + dir: &Path, +) -> impl Stream> + Send + 'static { + let (sender, receiver) = mpsc::channel(CONTEXT_CHUNKS); + let failures = sender.clone(); + let dir = dir.to_path_buf(); + + tokio::task::spawn_blocking(move || { + let writer = BufWriter::with_capacity(CONTEXT_CHUNK_SIZE, ChannelWriter { sender }); + let mut builder = tar::Builder::new(writer); + + let result = builder + .append_dir_all(".", &dir) + .and_then(|()| builder.finish()) + // `finish` écrit les blocs de fin d'archive ; il reste à vider le tampon + // pour que le daemon reçoive tout, y compris une archive vide. + .and_then(|()| builder.get_mut().flush()); + + if let Err(error) = result { + // L'échec est remonté au daemon par le flux : sinon il ne verrait qu'un + // tar tronqué et signalerait une erreur incompréhensible. + let _ = failures.blocking_send(Err(error)); + } + }); + + ReceiverStream::new(receiver) +} + +/// Écrit les morceaux du tar dans un canal, en attendant qu'il se vide. +/// +/// Utilisé depuis une tâche bloquante, seul contexte où `blocking_send` est autorisé. +struct ChannelWriter { + sender: mpsc::Sender>, +} + +impl Write for ChannelWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.sender + .blocking_send(Ok(Bytes::copy_from_slice(buf))) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "the daemon dropped the build context", + ) + })?; + + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + /// Nettoie une chaîne pour qu'elle puisse servir de nom d'image/container docker. fn sanitize(input: &str) -> String { let sanitized: String = input @@ -550,8 +804,7 @@ mod tests { }, "workspaceFolder": "/workspaces/my-project", "containerEnv": { "RUST_LOG": "debug" }, - "remoteUser": "dev", - "runArgs": ["--userns=keep-id"] + "remoteUser": "dev" }"#, ) .unwrap(); @@ -575,46 +828,13 @@ mod tests { } #[test] - fn build_args_include_dockerfile_tag_build_args_and_context() { + fn container_name_is_sanitized() { let dir = tempfile::tempdir().unwrap(); let dc = devcontainer(dir.path()); - let args = dc.build_args("devcontainer-rs/my-project:test"); - assert_eq!(args[0], "-f"); - assert!(args[1].ends_with("Dockerfile")); - assert_eq!(args[2], "-t"); - assert_eq!(args[3], "devcontainer-rs/my-project:test"); - assert!(args.contains(&"--build-arg".to_string())); - assert!(args.contains(&"VERSION=1".to_string())); - assert_eq!(args.last().unwrap(), &dir.path().display().to_string()); - } - - #[test] - fn run_args_mount_workspace_and_keep_alive() { - let dir = tempfile::tempdir().unwrap(); - let dc = devcontainer(dir.path()); - let workspace = Path::new("/tmp/clone"); - let args = dc.run_args( - workspace, - "devcontainer-rs-my-project-42", - "devcontainer-rs/my-project:test", - Some("sandbox-net"), - ); - - assert!(args.contains(&"-d".to_string())); - assert!(args.contains(&"--name".to_string())); - assert!(args.contains(&"devcontainer-rs-my-project-42".to_string())); - assert!(args.contains(&"/tmp/clone:/workspaces/my-project".to_string())); - assert!(args.contains(&"--network".to_string())); - assert!(args.contains(&"sandbox-net".to_string())); - assert!(args.contains(&"--user".to_string())); - assert!(args.contains(&"dev".to_string())); - assert!(args.contains(&"RUST_LOG=debug".to_string())); - assert!(args.contains(&"--userns=keep-id".to_string())); - assert!(args.contains(&"devcontainer-rs/my-project:test".to_string())); - assert_eq!( - &args[args.len() - 2..], - &["sleep".to_string(), "infinity".to_string()] + assert!( + dc.container_name() + .starts_with("devcontainer-rs-my-project-") ); } @@ -627,23 +847,87 @@ mod tests { assert_eq!(normalize(Path::new("/workspaces/../../etc/passwd")), None); } - #[tokio::test] - async fn run_captures_output() { - let runtime = ContainerRuntime::new("echo"); - let output = runtime.run(&["hello".to_string()]).await.unwrap(); + /// Rassemble le flux du contexte en un tar complet, pour l'inspecter. + async fn context_tar(dir: &Path) -> Vec { + let mut tar = Vec::new(); + let mut chunks = tar_directory_stream(dir); - assert!(output.success()); - assert_eq!(output.stdout.trim(), "hello"); + while let Some(chunk) = chunks.next().await { + tar.extend_from_slice(&chunk.unwrap()); + } + + tar } #[tokio::test] - async fn run_times_out_and_kills_the_process() { - let runtime = ContainerRuntime::new("sleep"); - let err = runtime - .run_with_timeout(&["10".to_string()], Duration::from_millis(50)) - .await - .unwrap_err(); + async fn tar_context_holds_the_devcontainer_files() { + let dir = tempfile::tempdir().unwrap(); + let devcontainer_dir = dir.path().join(".devcontainer"); + fs::create_dir(&devcontainer_dir).unwrap(); + fs::write(devcontainer_dir.join("Dockerfile"), "FROM alpine\n").unwrap(); + fs::write(devcontainer_dir.join("devcontainer.json"), "{}").unwrap(); - assert!(matches!(err, ContainerError::Timeout { .. })); + let tar = context_tar(&devcontainer_dir).await; + + let mut archive = tar::Archive::new(tar.as_slice()); + let names = archive + .entries() + .unwrap() + .map(|entry| entry.unwrap().path().unwrap().display().to_string()) + .collect::>(); + + assert!(names.iter().any(|name| name.ends_with("Dockerfile"))); + assert!(names.iter().any(|name| name.ends_with("devcontainer.json"))); + } + + #[tokio::test] + async fn tar_context_keeps_the_executable_bit() { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("setup.sh"); + fs::write(&script, "#!/bin/sh\n").unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + } + + let tar = context_tar(dir.path()).await; + + let mut archive = tar::Archive::new(tar.as_slice()); + let mode = archive + .entries() + .unwrap() + .map(|entry| entry.unwrap()) + .find(|entry| entry.path().unwrap().ends_with("setup.sh")) + .unwrap() + .header() + .mode() + .unwrap(); + + assert_eq!(mode & 0o111, 0o111); + } + + #[tokio::test] + async fn tar_context_is_sent_in_chunks() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("big.bin"), + vec![0_u8; CONTEXT_CHUNK_SIZE * 3], + ) + .unwrap(); + + let mut chunks = tar_directory_stream(dir.path()); + let mut count = 0; + + while let Some(chunk) = chunks.next().await { + assert!(chunk.unwrap().len() <= CONTEXT_CHUNK_SIZE); + count += 1; + } + + assert!( + count > 1, + "the context should be streamed, not buffered in one piece" + ); } } diff --git a/crates/devcontainer-rs/src/lib.rs b/crates/devcontainer-rs/src/lib.rs index 67914b9..c8f39bc 100644 --- a/crates/devcontainer-rs/src/lib.rs +++ b/crates/devcontainer-rs/src/lib.rs @@ -38,6 +38,12 @@ pub struct DevContainerSchema { #[serde(rename = "remoteUser", default)] pub remote_user: Option, + /// 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, } @@ -52,7 +58,6 @@ pub struct DevContainer { pub post_create_command: Option, pub post_start_command: Option, pub remote_user: Option, - pub run_args: Vec, } #[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] diff --git a/crates/herald-server/src/bot_actions/review.rs b/crates/herald-server/src/bot_actions/review.rs index 83d9424..61ca7c4 100644 --- a/crates/herald-server/src/bot_actions/review.rs +++ b/crates/herald-server/src/bot_actions/review.rs @@ -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::(&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, review_payload: &ReviewPayload, bot_request: &str, -) -> anyhow::Result<(String, Option)> { +) -> anyhow::Result<(ReviewResult, Option)> { 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 { + let error = match serde_json::from_str::(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::(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::().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::().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::().unwrap().comment, "ok"); + } + + #[test] + fn an_answer_without_json_is_rejected() { + assert!("Je n'ai rien relevé.".parse::().is_err()); + } + + #[test] + fn an_answer_that_is_an_object_but_not_a_review_is_rejected() { + assert!(r#"{"message": "LGTM"}"#.parse::().is_err()); + } + #[test] fn odd_sides_from_the_model_are_tolerated() { let changed_lines = parse_changed_lines(DIFF); diff --git a/crates/herald-server/src/consts.rs b/crates/herald-server/src/consts.rs index 3d87a6a..4ec599e 100644 --- a/crates/herald-server/src/consts.rs +++ b/crates/herald-server/src/consts.rs @@ -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) diff --git a/crates/herald-server/src/env.rs b/crates/herald-server/src/env.rs index 7cdf68d..a7752de 100644 --- a/crates/herald-server/src/env.rs +++ b/crates/herald-server/src/env.rs @@ -12,7 +12,6 @@ pub struct EnvConfig { pub gitea_token: String, pub gitea_timeout: u64, pub metrics_bind_addr: Option, - pub container_runtime: String, pub sandbox_max_iterations: usize, } @@ -27,8 +26,6 @@ pub fn load_config() -> anyhow::Result { 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 { gitea_token, gitea_timeout, metrics_bind_addr, - container_runtime, sandbox_max_iterations, }) } diff --git a/crates/herald-server/src/main.rs b/crates/herald-server/src/main.rs index d6d4b0b..c22d24f 100644 --- a/crates/herald-server/src/main.rs +++ b/crates/herald-server/src/main.rs @@ -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"); diff --git a/crates/herald-server/src/open_router.rs b/crates/herald-server/src/open_router.rs index 8709091..3c5de7a 100644 --- a/crates/herald-server/src/open_router.rs +++ b/crates/herald-server/src/open_router.rs @@ -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, - tools: Vec, + messages: &[Message], + tools: &[Tool], + tool_choice: ToolChoice, ) -> anyhow::Result { - 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 { + async fn complete( + &self, + messages: &[Message], + tools: &[Tool], + tool_choice: ToolChoice, + ) -> anyhow::Result { 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!({ diff --git a/crates/herald-server/src/sandbox/agent.rs b/crates/herald-server/src/sandbox/agent.rs index bf9e1e2..7de2702 100644 --- a/crates/herald-server/src/sandbox/agent.rs +++ b/crates/herald-server/src/sandbox/agent.rs @@ -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 { + /// Answer of the model, parsed into the type the caller asked for. + pub answer: A, pub cost: Option, 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( open_router: &OpenRouterClient, sandbox: &Sandbox, tool_definitions: Vec, system_prompt: &str, user_prompt: &str, max_iterations: usize, -) -> anyhow::Result { +) -> anyhow::Result> +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::>() + .join(", "), + max_iterations, + "Starting tool-calling loop" + ); + let mut total_cost = 0.0_f64; let mut has_cost = false; + let mut last_rejection: Option = 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::() { + 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 { 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('é')); + } } diff --git a/crates/herald-server/src/sandbox/mod.rs b/crates/herald-server/src/sandbox/mod.rs index 362376a..d732d27 100644 --- a/crates/herald-server/src/sandbox/mod.rs +++ b/crates/herald-server/src/sandbox/mod.rs @@ -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<()> { diff --git a/crates/herald-server/src/text.rs b/crates/herald-server/src/text.rs new file mode 100644 index 0000000..e0471d8 --- /dev/null +++ b/crates/herald-server/src/text.rs @@ -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::(); + + 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, "é"); + } +} From 620ec6727ebcf5547420f2dc0ea31d6c3dc68206 Mon Sep 17 00:00:00 2001 From: qpismont Date: Thu, 17 Sep 2026 20:37:54 +0000 Subject: [PATCH 14/17] clean devcontainer-rs architecture --- crates/devcontainer-rs/src/consts.rs | 24 + crates/devcontainer-rs/src/container.rs | 892 +-------------------- crates/devcontainer-rs/src/context.rs | 166 ++++ crates/devcontainer-rs/src/devcontainer.rs | 278 +++++++ crates/devcontainer-rs/src/errors.rs | 52 ++ crates/devcontainer-rs/src/exec.rs | 16 + crates/devcontainer-rs/src/lib.rs | 287 +------ crates/devcontainer-rs/src/path.rs | 40 + crates/devcontainer-rs/src/runtime.rs | 352 ++++++++ crates/devcontainer-rs/src/schema.rs | 230 ++++++ crates/herald-server/src/sandbox/tools.rs | 46 +- 11 files changed, 1265 insertions(+), 1118 deletions(-) create mode 100644 crates/devcontainer-rs/src/consts.rs create mode 100644 crates/devcontainer-rs/src/context.rs create mode 100644 crates/devcontainer-rs/src/devcontainer.rs create mode 100644 crates/devcontainer-rs/src/errors.rs create mode 100644 crates/devcontainer-rs/src/exec.rs create mode 100644 crates/devcontainer-rs/src/path.rs create mode 100644 crates/devcontainer-rs/src/runtime.rs create mode 100644 crates/devcontainer-rs/src/schema.rs diff --git a/crates/devcontainer-rs/src/consts.rs b/crates/devcontainer-rs/src/consts.rs new file mode 100644 index 0000000..b62ce35 --- /dev/null +++ b/crates/devcontainer-rs/src/consts.rs @@ -0,0 +1,24 @@ +//! Constantes de la crate : endpoint du daemon, timeouts et découpage du +//! contexte de build. + +use std::time::Duration; + +/// Endpoint affiché dans les logs quand `DOCKER_HOST` n'est pas défini. +pub(crate) const DEFAULT_ENDPOINT: &str = "unix:///var/run/docker.sock"; + +/// Timeout appliqué aux opérations de build/run/stop/remove. +pub(crate) const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(600); + +/// Timeout appliqué aux commandes exécutées dans un container en cours d'exécution. +pub(crate) const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(60); + +/// Taille des morceaux du contexte de build envoyés au daemon. +pub(crate) const CONTEXT_CHUNK_SIZE: usize = 64 * 1024; + +/// Nombre de morceaux pouvant attendre dans le canal : c'est ce qui borne la +/// mémoire occupée par le contexte, quelle que soit sa taille. +pub(crate) const CONTEXT_CHUNKS: usize = 4; + +/// Lectures successives du code de sortie d'un exec, et attente entre elles. +pub(crate) const EXIT_CODE_ATTEMPTS: usize = 10; +pub(crate) const EXIT_CODE_DELAY: Duration = Duration::from_millis(20); diff --git a/crates/devcontainer-rs/src/container.rs b/crates/devcontainer-rs/src/container.rs index 224cf4d..e93d9e5 100644 --- a/crates/devcontainer-rs/src/container.rs +++ b/crates/devcontainer-rs/src/container.rs @@ -1,440 +1,12 @@ -//! Primitives de cycle de vie de container pour un [`DevContainer`] analysé. -//! -//! Ce module pilote l'API du daemon de containers pour construire l'image -//! devcontainer, démarrer un container avec le workspace monté, exécuter les -//! hooks `postCreateCommand` / `postStartCommand` et lancer des commandes à -//! l'intérieur du container en cours d'exécution. -//! -//! Le daemon est joint par son **socket** (celui de l'hôte, monté dans le -//! container de Herald) : [`ContainerRuntime::connect`] suit `DOCKER_HOST` comme -//! le fait le CLI `docker`, et retombe sur le socket local. Aucun binaire de -//! runtime n'est donc requis dans l'image, et `podman` fonctionne de la même -//! façon dès lors qu'il expose son socket compatible Docker. -//! -//! # Isolation -//! -//! Chaque sandbox dispose de son propre tag d'image, de son propre container et de -//! son propre réseau. Le container démarre attaché à ce réseau afin que les hooks -//! `postCreateCommand` / `postStartCommand` puissent récupérer des dépendances -//! (par ex. `npm install`) ; une fois les hooks exécutés, le container est déconnecté -//! du réseau pour le reste de sa durée de vie. Chaque commande est bornée par un timeout. -//! -//! Les `runArgs` du `devcontainer.json` sont lus mais pas transmis au daemon : ils -//! viennent du dépôt, donc d'une pull request non fiable, et pourraient rattacher le -//! container à un autre réseau ou lui donner des privilèges qui annuleraient cette -//! isolation. +//! Un devcontainer en cours d'exécution : inspection et exécution de commandes. -use std::{ - collections::HashMap, - io::{BufWriter, Write}, - path::{Path, PathBuf}, - time::{Duration, SystemTime, UNIX_EPOCH}, +use std::time::Duration; + +use crate::{ + consts::DEFAULT_EXEC_TIMEOUT, errors::ContainerError, exec::ExecOutput, + runtime::ContainerRuntime, }; -use bollard::{ - Docker, body_try_stream, - container::LogOutput, - errors::Error as BollardError, - exec::{CreateExecOptions, StartExecOptions, StartExecResults}, - models::{ - BuildInfo, ContainerCreateBody, HostConfig, NetworkCreateRequest, NetworkDisconnectRequest, - }, - query_parameters::{ - BuildImageOptions, CreateContainerOptions, RemoveContainerOptions, RemoveImageOptions, - StartContainerOptions, StopContainerOptions, - }, -}; -use bytes::Bytes; -use futures_util::{Stream, StreamExt}; -use tokio::sync::mpsc; -use tokio_stream::wrappers::ReceiverStream; - -use crate::DevContainer; - -/// Endpoint affiché dans les logs quand `DOCKER_HOST` n'est pas défini. -const DEFAULT_ENDPOINT: &str = "unix:///var/run/docker.sock"; - -/// Timeout appliqué aux opérations de build/run/stop/remove. -const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(600); - -/// Timeout appliqué aux commandes exécutées dans un container en cours d'exécution. -const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(60); - -/// Taille des morceaux du contexte de build envoyés au daemon. -const CONTEXT_CHUNK_SIZE: usize = 64 * 1024; - -/// Nombre de morceaux pouvant attendre dans le canal : c'est ce qui borne la -/// mémoire occupée par le contexte, quelle que soit sa taille. -const CONTEXT_CHUNKS: usize = 4; - -/// Lectures successives du code de sortie d'un exec, et attente entre elles. -const EXIT_CODE_ATTEMPTS: usize = 10; -const EXIT_CODE_DELAY: Duration = Duration::from_millis(20); - -/// Résultat d'une commande exécutée dans un container. -#[derive(Debug, Clone)] -pub struct ExecOutput { - /// Code de sortie, ou `-1` si le processus a été terminé par un signal. - pub status: i32, - pub stdout: String, - pub stderr: String, -} - -impl ExecOutput { - pub fn success(&self) -> bool { - self.status == 0 - } -} - -#[derive(Debug, thiserror::Error)] -pub enum ContainerError { - /// Le daemon est injoignable, ou a refusé une requête. - #[error("container daemon request failed: {source}")] - Request { - #[from] - source: BollardError, - }, - - /// Une opération a dépassé son timeout. - #[error("`{operation}` timed out after {timeout:?}")] - Timeout { - operation: String, - timeout: Duration, - }, - - /// La construction de l'image a échoué. - #[error("image build failed: {message}")] - Build { message: String }, - - /// Le daemon a répondu autre chose que ce qui était attendu. - #[error("{0}")] - Unexpected(String), -} - -/// Un client de l'API du daemon de containers. -#[derive(Debug, Clone)] -pub struct ContainerRuntime { - docker: Docker, - endpoint: String, - timeout: Duration, -} - -impl ContainerRuntime { - /// Se connecte au daemon désigné par `DOCKER_HOST`, ou au socket local par défaut. - pub fn connect() -> Result { - let endpoint = - std::env::var("DOCKER_HOST").unwrap_or_else(|_| String::from(DEFAULT_ENDPOINT)); - - Ok(Self { - docker: Docker::connect_with_defaults()?, - endpoint, - timeout: DEFAULT_COMMAND_TIMEOUT, - }) - } - - /// Remplace le timeout appliqué aux opérations de build/run/stop/remove. - pub fn with_timeout(mut self, timeout: Duration) -> Self { - self.timeout = timeout; - self - } - - /// Endpoint du daemon, pour les logs. - pub fn endpoint(&self) -> &str { - &self.endpoint - } - - pub fn timeout(&self) -> Duration { - self.timeout - } - - /// Vérifie que le daemon est joignable et répond. - pub async fn available(&self) -> bool { - self.docker.ping().await.is_ok() - } - - /// Exécute une requête du daemon en appliquant le timeout des commandes. - async fn request( - &self, - operation: &str, - request: impl Future>, - ) -> Result { - match tokio::time::timeout(self.timeout, request).await { - Ok(Ok(value)) => Ok(value), - Ok(Err(source)) => Err(ContainerError::Request { source }), - Err(_) => Err(ContainerError::Timeout { - operation: String::from(operation), - timeout: self.timeout, - }), - } - } - - /// Construit l'image `tag` depuis le contexte `context_dir`. - async fn build_image( - &self, - context_dir: &Path, - dockerfile: &str, - tag: &str, - build_args: &HashMap, - ) -> Result<(), ContainerError> { - let options = BuildImageOptions { - dockerfile: String::from(dockerfile), - t: Some(String::from(tag)), - buildargs: Some(build_args.clone()), - rm: true, - ..Default::default() - }; - - let context = tar_directory_stream(context_dir); - let mut stream = self - .docker - .build_image(options, None, Some(body_try_stream(context))); - - // Le flux porte la progression du build : la dernière erreur signalée par le - // daemon fait échouer l'opération. - let consume = async { - let mut failure = None; - - while let Some(info) = stream.next().await { - let info: BuildInfo = info?; - - if let Some(message) = info.error_detail.and_then(|detail| detail.message) { - failure = Some(message); - } - } - - Ok::<_, BollardError>(failure) - }; - - let failure = match tokio::time::timeout(self.timeout, consume).await { - Ok(Ok(failure)) => failure, - Ok(Err(source)) => return Err(ContainerError::Request { source }), - Err(_) => { - return Err(ContainerError::Timeout { - operation: format!("build image `{tag}`"), - timeout: self.timeout, - }); - } - }; - - match failure { - Some(message) => Err(ContainerError::Build { message }), - None => Ok(()), - } - } - - /// Crée un container, sans le démarrer. - async fn create_container( - &self, - name: &str, - body: ContainerCreateBody, - ) -> Result<(), ContainerError> { - let options = CreateContainerOptions { - name: Some(String::from(name)), - ..Default::default() - }; - - self.request( - "create container", - self.docker.create_container(Some(options), body), - ) - .await?; - - Ok(()) - } - - async fn start_container(&self, name: &str) -> Result<(), ContainerError> { - self.request( - "start container", - self.docker - .start_container(name, None::), - ) - .await?; - - Ok(()) - } - - /// Exécute une commande dans un container et renvoie sa sortie. - async fn exec( - &self, - container: &str, - cmd: &[&str], - user: Option<&str>, - timeout: Duration, - ) -> Result { - let config = CreateExecOptions { - cmd: Some(cmd.iter().map(|arg| String::from(*arg)).collect()), - user: user.map(String::from), - attach_stdout: Some(true), - attach_stderr: Some(true), - ..Default::default() - }; - - let created = self - .request("create exec", self.docker.create_exec(container, config)) - .await?; - - let started = self - .request( - "start exec", - self.docker - .start_exec(&created.id, None::), - ) - .await?; - - let StartExecResults::Attached { mut output, .. } = started else { - return Err(ContainerError::Unexpected(String::from( - "the daemon detached an exec that was not requested as detached", - ))); - }; - - let mut stdout = String::new(); - let mut stderr = String::new(); - - // Le timeout couvre l'exécution de la commande elle-même, pas seulement sa - // mise en place : le processus continue de tourner dans le container, il - // disparaît avec lui. - let collect = async { - while let Some(message) = output.next().await { - match message? { - LogOutput::StdOut { message } | LogOutput::Console { message } => { - stdout.push_str(&String::from_utf8_lossy(&message)); - } - LogOutput::StdErr { message } => { - stderr.push_str(&String::from_utf8_lossy(&message)); - } - LogOutput::StdIn { .. } => {} - } - } - - Ok::<_, BollardError>(()) - }; - - match tokio::time::timeout(timeout, collect).await { - Ok(Ok(())) => {} - Ok(Err(source)) => return Err(ContainerError::Request { source }), - Err(_) => { - return Err(ContainerError::Timeout { - operation: format!("exec {}", cmd.join(" ")), - timeout, - }); - } - } - - // Le code de sortie n'est pas forcément publié au moment où le flux se ferme : - // on laisse au daemon le temps de le renseigner, sans bloquer indéfiniment. - // Sans cela, une commande réussie peut être rapportée en échec (code -1) et - // l'outil renvoie une erreur au modèle à la place du contenu. - let mut inspected = self - .request("inspect exec", self.docker.inspect_exec(&created.id)) - .await?; - - for _ in 0..EXIT_CODE_ATTEMPTS { - if !inspected.running.unwrap_or(false) { - break; - } - - tokio::time::sleep(EXIT_CODE_DELAY).await; - - inspected = self - .request("inspect exec", self.docker.inspect_exec(&created.id)) - .await?; - } - - Ok(ExecOutput { - status: inspected.exit_code.unwrap_or(-1) as i32, - stdout, - stderr, - }) - } - - async fn stop_container(&self, name: &str) -> Result<(), ContainerError> { - let options = StopContainerOptions { - t: Some(1), - ..Default::default() - }; - - self.request( - "stop container", - self.docker.stop_container(name, Some(options)), - ) - .await?; - - Ok(()) - } - - /// Supprime un container, ses volumes anonymes et le processus qui y tourne. - async fn remove_container(&self, name: &str) -> Result<(), ContainerError> { - let options = RemoveContainerOptions { - force: true, - v: true, - ..Default::default() - }; - - self.request( - "remove container", - self.docker.remove_container(name, Some(options)), - ) - .await?; - - Ok(()) - } - - async fn remove_image(&self, tag: &str) -> Result<(), ContainerError> { - let options = RemoveImageOptions { - force: true, - ..Default::default() - }; - - self.request( - "remove image", - self.docker.remove_image(tag, Some(options), None), - ) - .await?; - - Ok(()) - } - - /// Crée le réseau dédié d'une sandbox. - async fn create_network(&self, name: &str) -> Result<(), ContainerError> { - let request = NetworkCreateRequest { - name: String::from(name), - ..Default::default() - }; - - self.request("create network", self.docker.create_network(request)) - .await?; - - Ok(()) - } - - /// Détache un container d'un réseau, ce qui coupe sa connectivité. - async fn disconnect_network( - &self, - network: &str, - container: &str, - ) -> Result<(), ContainerError> { - let request = NetworkDisconnectRequest { - container: String::from(container), - ..Default::default() - }; - - self.request( - "disconnect network", - self.docker.disconnect_network(network, request), - ) - .await?; - - Ok(()) - } - - async fn remove_network(&self, name: &str) -> Result<(), ContainerError> { - self.request("remove network", self.docker.remove_network(name)) - .await?; - - Ok(()) - } -} - /// Un devcontainer en cours d'exécution. #[derive(Debug, Clone)] pub struct Container { @@ -447,6 +19,25 @@ pub struct Container { } impl Container { + /// Assemble un container démarré, avec son réseau et son image à nettoyer. + pub(crate) fn new( + runtime: ContainerRuntime, + name: String, + workspace_folder: String, + remote_user: Option, + network: Option, + image: Option, + ) -> Self { + Self { + runtime, + name, + workspace_folder, + remote_user, + network, + image, + } + } + pub fn name(&self) -> &str { &self.name } @@ -463,7 +54,7 @@ impl Container { self.exec_with_timeout(cmd, DEFAULT_EXEC_TIMEOUT).await } - async fn exec_with_timeout( + pub(crate) async fn exec_with_timeout( &self, cmd: &[&str], timeout: Duration, @@ -500,434 +91,3 @@ impl Container { Ok(()) } } - -impl DevContainer { - /// Nom de l'image de base dérivé du nom du devcontainer. - pub fn image_name(&self) -> String { - let base = self.name.as_deref().unwrap_or("devcontainer"); - format!("devcontainer-rs/{}", sanitize(base)) - } - - /// Tag d'image unique pour une exécution de sandbox donnée. - /// - /// L'unicité est importante : deux sandboxes concurrentes (éventuellement pour des - /// dépôts différents partageant un nom de devcontainer) ne doivent pas se disputer le même tag. - pub fn image_tag(&self) -> String { - format!("{}:{}", self.image_name(), unique_suffix()) - } - - /// Dossier de workspace dans le container, par défaut `/workspaces/workspace`. - pub fn workspace_folder(&self) -> String { - self.workspace_folder - .clone() - .unwrap_or_else(|| "/workspaces/workspace".to_string()) - } - - /// Nom de container unique pour cette exécution. - pub fn container_name(&self) -> String { - let base = self.name.as_deref().unwrap_or("devcontainer"); - format!("devcontainer-rs-{}-{}", sanitize(base), unique_suffix()) - } - - /// Construit l'image devcontainer sous `image_tag`. - pub async fn build( - &self, - runtime: &ContainerRuntime, - image_tag: &str, - ) -> Result<(), ContainerError> { - let context = self.container_file_path.parent().ok_or_else(|| { - ContainerError::Unexpected(String::from( - "the devcontainer file path has no parent directory", - )) - })?; - - let dockerfile = self - .container_file_path - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| { - ContainerError::Unexpected(String::from( - "the devcontainer file name is not valid UTF-8", - )) - })?; - - runtime - .build_image(context, dockerfile, image_tag, &self.build_args) - .await - } - - /// Construit l'image, démarre le container, exécute les hooks puis coupe le réseau. - /// - /// En cas d'échec, le container, le réseau et l'image sont nettoyés avant de - /// renvoyer l'erreur, afin qu'aucune ressource ne soit laissée en place. - pub async fn up( - &self, - runtime: &ContainerRuntime, - workspace_dir: &Path, - ) -> Result { - let image_tag = self.image_tag(); - self.build(runtime, &image_tag).await?; - - let name = self.container_name(); - let network = format!("{name}-net"); - - // Réseau dédié afin de pouvoir couper la connectivité après les hooks. - if let Err(err) = runtime.create_network(&network).await { - let _ = runtime.remove_image(&image_tag).await; - return Err(err); - } - - let workspace_folder = self.workspace_folder(); - let body = ContainerCreateBody { - image: Some(image_tag.clone()), - cmd: Some(vec![String::from("sleep"), String::from("infinity")]), - user: self.remote_user.clone(), - env: Some( - self.container_env - .iter() - .map(|(key, value)| format!("{key}={value}")) - .collect(), - ), - working_dir: Some(workspace_folder.clone()), - host_config: Some(HostConfig { - binds: Some(vec![format!( - "{}:{workspace_folder}", - workspace_dir.display() - )]), - network_mode: Some(network.clone()), - // Le clone est monté depuis un chemin de l'hôte. Sur une distribution - // à SELinux enforcing, ce chemin n'a pas le label attendu et l'accès - // est refusé (EACCES), ce qui fait échouer tous les outils de la - // sandbox. C'est le compromis inverse de l'alternative `:Z` sur le - // montage, qui re-labellise le clone et garde le confinement SELinux. - security_opt: Some(vec![String::from("label=disable")]), - ..Default::default() - }), - ..Default::default() - }; - - if let Err(err) = runtime.create_container(&name, body).await { - let _ = runtime.remove_network(&network).await; - let _ = runtime.remove_image(&image_tag).await; - return Err(err); - } - - if let Err(err) = runtime.start_container(&name).await { - let _ = runtime.remove_container(&name).await; - let _ = runtime.remove_network(&network).await; - let _ = runtime.remove_image(&image_tag).await; - return Err(err); - } - - let container = Container { - runtime: runtime.clone(), - name, - workspace_folder: self.workspace_folder(), - remote_user: self.remote_user.clone(), - network: Some(network.clone()), - image: Some(image_tag), - }; - - // Les hooks s'exécutent avec accès au réseau (installation de dépendances, etc.). - if let Err(err) = self.run_hooks(&container).await { - let _ = container.remove().await; - return Err(err); - } - - // Coupe l'accès réseau pour le reste de la durée de vie de la sandbox. - if let Err(err) = runtime.disconnect_network(&network, container.name()).await { - let _ = container.remove().await; - return Err(err); - } - - Ok(container) - } - - async fn run_hooks(&self, container: &Container) -> Result<(), ContainerError> { - // Les hooks peuvent installer des dépendances, ils utilisent donc le timeout - // long des commandes plutôt que le court réservé à l'exécution des outils. - let timeout = container.runtime.timeout(); - - for command in [&self.post_create_command, &self.post_start_command] - .into_iter() - .flatten() - { - let output = container - .exec_with_timeout(&["sh", "-c", command], timeout) - .await?; - - if !output.success() { - return Err(ContainerError::Unexpected(format!( - "hook `{command}` failed with status {}: {}", - output.status, - output.stderr.trim() - ))); - } - } - - Ok(()) - } -} - -/// Empaquette le contexte de build dans un tar, **en flux**. -/// -/// Le CLI `docker build` empaquette le contexte puis l'envoie ; le faire d'un coup -/// chargerait tout le dossier en mémoire, ce qui devient intenable dès que le -/// `.devcontainer` embarque des binaires. Ici l'archive est écrite par une tâche -/// bloquante et poussée morceau par morceau, avec la contre-pression du canal : -/// si le daemon lit lentement, l'empaquetage ralentit. -fn tar_directory_stream( - dir: &Path, -) -> impl Stream> + Send + 'static { - let (sender, receiver) = mpsc::channel(CONTEXT_CHUNKS); - let failures = sender.clone(); - let dir = dir.to_path_buf(); - - tokio::task::spawn_blocking(move || { - let writer = BufWriter::with_capacity(CONTEXT_CHUNK_SIZE, ChannelWriter { sender }); - let mut builder = tar::Builder::new(writer); - - let result = builder - .append_dir_all(".", &dir) - .and_then(|()| builder.finish()) - // `finish` écrit les blocs de fin d'archive ; il reste à vider le tampon - // pour que le daemon reçoive tout, y compris une archive vide. - .and_then(|()| builder.get_mut().flush()); - - if let Err(error) = result { - // L'échec est remonté au daemon par le flux : sinon il ne verrait qu'un - // tar tronqué et signalerait une erreur incompréhensible. - let _ = failures.blocking_send(Err(error)); - } - }); - - ReceiverStream::new(receiver) -} - -/// Écrit les morceaux du tar dans un canal, en attendant qu'il se vide. -/// -/// Utilisé depuis une tâche bloquante, seul contexte où `blocking_send` est autorisé. -struct ChannelWriter { - sender: mpsc::Sender>, -} - -impl Write for ChannelWriter { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.sender - .blocking_send(Ok(Bytes::copy_from_slice(buf))) - .map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "the daemon dropped the build context", - ) - })?; - - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } -} - -/// Nettoie une chaîne pour qu'elle puisse servir de nom d'image/container docker. -fn sanitize(input: &str) -> String { - let sanitized: String = input - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { - c.to_ascii_lowercase() - } else { - '-' - } - }) - .collect(); - - let trimmed = sanitized.trim_matches(|c| c == '-' || c == '.' || c == '_'); - if trimmed.is_empty() { - "devcontainer".to_string() - } else { - trimmed.to_string() - } -} - -/// Suffixe unique à une exécution de sandbox, combinant l'identifiant de processus et un horodatage. -fn unique_suffix() -> String { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or(0); - - format!("{}-{}", std::process::id(), nanos) -} - -/// Normalise lexicalement un chemin, en résolvant `.` et `..` sans toucher au -/// système de fichiers. Renvoie `None` si le chemin sort de sa racine. -pub fn normalize(path: &Path) -> Option { - use std::path::Component; - - let mut out = PathBuf::new(); - for component in path.components() { - match component { - Component::RootDir => out.push("/"), - Component::CurDir => {} - Component::ParentDir => { - if !out.pop() { - return None; - } - } - Component::Normal(part) => out.push(part), - Component::Prefix(_) => return None, - } - } - - Some(out) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - fn devcontainer(dir: &Path) -> DevContainer { - let devcontainer_path = dir.join("devcontainer.json"); - let dockerfile_path = dir.join("Dockerfile"); - - fs::write(&dockerfile_path, "FROM alpine\n").unwrap(); - fs::write( - &devcontainer_path, - r#"{ - "name": "My Project", - "build": { - "dockerfile": "Dockerfile", - "args": { "VERSION": "1" } - }, - "workspaceFolder": "/workspaces/my-project", - "containerEnv": { "RUST_LOG": "debug" }, - "remoteUser": "dev" - }"#, - ) - .unwrap(); - - let runtime = tokio::runtime::Runtime::new().unwrap(); - runtime.block_on(crate::parse(&devcontainer_path)).unwrap() - } - - #[test] - fn image_name_is_sanitized() { - let dir = tempfile::tempdir().unwrap(); - let dc = devcontainer(dir.path()); - assert_eq!(dc.image_name(), "devcontainer-rs/my-project"); - } - - #[test] - fn image_tags_are_unique() { - let dir = tempfile::tempdir().unwrap(); - let dc = devcontainer(dir.path()); - assert_ne!(dc.image_tag(), dc.image_tag()); - } - - #[test] - fn container_name_is_sanitized() { - let dir = tempfile::tempdir().unwrap(); - let dc = devcontainer(dir.path()); - - assert!( - dc.container_name() - .starts_with("devcontainer-rs-my-project-") - ); - } - - #[test] - fn normalize_rejects_escaping_paths() { - assert_eq!( - normalize(Path::new("/workspaces/project/src/../main.rs")), - Some(PathBuf::from("/workspaces/project/main.rs")) - ); - assert_eq!(normalize(Path::new("/workspaces/../../etc/passwd")), None); - } - - /// Rassemble le flux du contexte en un tar complet, pour l'inspecter. - async fn context_tar(dir: &Path) -> Vec { - let mut tar = Vec::new(); - let mut chunks = tar_directory_stream(dir); - - while let Some(chunk) = chunks.next().await { - tar.extend_from_slice(&chunk.unwrap()); - } - - tar - } - - #[tokio::test] - async fn tar_context_holds_the_devcontainer_files() { - let dir = tempfile::tempdir().unwrap(); - let devcontainer_dir = dir.path().join(".devcontainer"); - fs::create_dir(&devcontainer_dir).unwrap(); - fs::write(devcontainer_dir.join("Dockerfile"), "FROM alpine\n").unwrap(); - fs::write(devcontainer_dir.join("devcontainer.json"), "{}").unwrap(); - - let tar = context_tar(&devcontainer_dir).await; - - let mut archive = tar::Archive::new(tar.as_slice()); - let names = archive - .entries() - .unwrap() - .map(|entry| entry.unwrap().path().unwrap().display().to_string()) - .collect::>(); - - assert!(names.iter().any(|name| name.ends_with("Dockerfile"))); - assert!(names.iter().any(|name| name.ends_with("devcontainer.json"))); - } - - #[tokio::test] - async fn tar_context_keeps_the_executable_bit() { - let dir = tempfile::tempdir().unwrap(); - let script = dir.path().join("setup.sh"); - fs::write(&script, "#!/bin/sh\n").unwrap(); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); - } - - let tar = context_tar(dir.path()).await; - - let mut archive = tar::Archive::new(tar.as_slice()); - let mode = archive - .entries() - .unwrap() - .map(|entry| entry.unwrap()) - .find(|entry| entry.path().unwrap().ends_with("setup.sh")) - .unwrap() - .header() - .mode() - .unwrap(); - - assert_eq!(mode & 0o111, 0o111); - } - - #[tokio::test] - async fn tar_context_is_sent_in_chunks() { - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("big.bin"), - vec![0_u8; CONTEXT_CHUNK_SIZE * 3], - ) - .unwrap(); - - let mut chunks = tar_directory_stream(dir.path()); - let mut count = 0; - - while let Some(chunk) = chunks.next().await { - assert!(chunk.unwrap().len() <= CONTEXT_CHUNK_SIZE); - count += 1; - } - - assert!( - count > 1, - "the context should be streamed, not buffered in one piece" - ); - } -} diff --git a/crates/devcontainer-rs/src/context.rs b/crates/devcontainer-rs/src/context.rs new file mode 100644 index 0000000..8c80d46 --- /dev/null +++ b/crates/devcontainer-rs/src/context.rs @@ -0,0 +1,166 @@ +//! Empaquetage en flux du contexte de build envoyé au daemon. + +use std::{ + io::{BufWriter, Write}, + path::Path, +}; + +use bytes::Bytes; +use futures_util::Stream; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; + +use crate::consts::{CONTEXT_CHUNK_SIZE, CONTEXT_CHUNKS}; + +/// Empaquette le contexte de build dans un tar, **en flux**. +/// +/// Le CLI `docker build` empaquette le contexte puis l'envoie ; le faire d'un coup +/// chargerait tout le dossier en mémoire, ce qui devient intenable dès que le +/// `.devcontainer` embarque des binaires. Ici l'archive est écrite par une tâche +/// bloquante et poussée morceau par morceau, avec la contre-pression du canal : +/// si le daemon lit lentement, l'empaquetage ralentit. +pub(crate) fn tar_directory_stream( + dir: &Path, +) -> impl Stream> + Send + 'static { + let (sender, receiver) = mpsc::channel(CONTEXT_CHUNKS); + let failures = sender.clone(); + let dir = dir.to_path_buf(); + + tokio::task::spawn_blocking(move || { + let writer = BufWriter::with_capacity(CONTEXT_CHUNK_SIZE, ChannelWriter { sender }); + let mut builder = tar::Builder::new(writer); + + let result = builder + .append_dir_all(".", &dir) + .and_then(|()| builder.finish()) + // `finish` écrit les blocs de fin d'archive ; il reste à vider le tampon + // pour que le daemon reçoive tout, y compris une archive vide. + .and_then(|()| builder.get_mut().flush()); + + if let Err(error) = result { + // L'échec est remonté au daemon par le flux : sinon il ne verrait qu'un + // tar tronqué et signalerait une erreur incompréhensible. + let _ = failures.blocking_send(Err(error)); + } + }); + + ReceiverStream::new(receiver) +} + +/// Écrit les morceaux du tar dans un canal, en attendant qu'il se vide. +/// +/// Utilisé depuis une tâche bloquante, seul contexte où `blocking_send` est autorisé. +struct ChannelWriter { + sender: mpsc::Sender>, +} + +impl Write for ChannelWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.sender + .blocking_send(Ok(Bytes::copy_from_slice(buf))) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "the daemon dropped the build context", + ) + })?; + + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + use futures_util::StreamExt; + + /// Rassemble le flux du contexte en un tar complet, pour l'inspecter. + async fn context_tar(dir: &Path) -> Vec { + let mut tar = Vec::new(); + let mut chunks = tar_directory_stream(dir); + + while let Some(chunk) = chunks.next().await { + tar.extend_from_slice(&chunk.unwrap()); + } + + tar + } + + #[tokio::test] + async fn tar_context_holds_the_devcontainer_files() { + let dir = tempfile::tempdir().unwrap(); + let devcontainer_dir = dir.path().join(".devcontainer"); + fs::create_dir(&devcontainer_dir).unwrap(); + fs::write(devcontainer_dir.join("Dockerfile"), "FROM alpine\n").unwrap(); + fs::write(devcontainer_dir.join("devcontainer.json"), "{}").unwrap(); + + let tar = context_tar(&devcontainer_dir).await; + + let mut archive = tar::Archive::new(tar.as_slice()); + let names = archive + .entries() + .unwrap() + .map(|entry| entry.unwrap().path().unwrap().display().to_string()) + .collect::>(); + + assert!(names.iter().any(|name| name.ends_with("Dockerfile"))); + assert!(names.iter().any(|name| name.ends_with("devcontainer.json"))); + } + + #[tokio::test] + async fn tar_context_keeps_the_executable_bit() { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("setup.sh"); + fs::write(&script, "#!/bin/sh\n").unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + } + + let tar = context_tar(dir.path()).await; + + let mut archive = tar::Archive::new(tar.as_slice()); + let mode = archive + .entries() + .unwrap() + .map(|entry| entry.unwrap()) + .find(|entry| entry.path().unwrap().ends_with("setup.sh")) + .unwrap() + .header() + .mode() + .unwrap(); + + assert_eq!(mode & 0o111, 0o111); + } + + #[tokio::test] + async fn tar_context_is_sent_in_chunks() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("big.bin"), + vec![0_u8; CONTEXT_CHUNK_SIZE * 3], + ) + .unwrap(); + + let mut chunks = tar_directory_stream(dir.path()); + let mut count = 0; + + while let Some(chunk) = chunks.next().await { + assert!(chunk.unwrap().len() <= CONTEXT_CHUNK_SIZE); + count += 1; + } + + assert!( + count > 1, + "the context should be streamed, not buffered in one piece" + ); + } +} diff --git a/crates/devcontainer-rs/src/devcontainer.rs b/crates/devcontainer-rs/src/devcontainer.rs new file mode 100644 index 0000000..bd73690 --- /dev/null +++ b/crates/devcontainer-rs/src/devcontainer.rs @@ -0,0 +1,278 @@ +//! Représentation analysée d'un `devcontainer.json` et son cycle de vie : +//! nommage, build, démarrage et hooks. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use bollard::models::{ContainerCreateBody, HostConfig}; + +use crate::{container::Container, errors::ContainerError, runtime::ContainerRuntime}; + +#[derive(Debug)] +pub struct DevContainer { + pub container_file_path: PathBuf, + pub name: Option, + pub build_args: HashMap, + pub container_env: HashMap, + pub workspace_folder: Option, + pub post_create_command: Option, + pub post_start_command: Option, + pub remote_user: Option, +} + +impl DevContainer { + /// Nom de l'image de base dérivé du nom du devcontainer. + pub fn image_name(&self) -> String { + let base = self.name.as_deref().unwrap_or("devcontainer"); + format!("devcontainer-rs/{}", sanitize(base)) + } + + /// Tag d'image unique pour une exécution de sandbox donnée. + /// + /// L'unicité est importante : deux sandboxes concurrentes (éventuellement pour des + /// dépôts différents partageant un nom de devcontainer) ne doivent pas se disputer le même tag. + pub fn image_tag(&self) -> String { + format!("{}:{}", self.image_name(), unique_suffix()) + } + + /// Dossier de workspace dans le container, par défaut `/workspaces/workspace`. + pub fn workspace_folder(&self) -> String { + self.workspace_folder + .clone() + .unwrap_or_else(|| "/workspaces/workspace".to_string()) + } + + /// Nom de container unique pour cette exécution. + pub fn container_name(&self) -> String { + let base = self.name.as_deref().unwrap_or("devcontainer"); + format!("devcontainer-rs-{}-{}", sanitize(base), unique_suffix()) + } + + /// Construit l'image devcontainer sous `image_tag`. + pub async fn build( + &self, + runtime: &ContainerRuntime, + image_tag: &str, + ) -> Result<(), ContainerError> { + let context = self.container_file_path.parent().ok_or_else(|| { + ContainerError::Unexpected(String::from( + "the devcontainer file path has no parent directory", + )) + })?; + + let dockerfile = self + .container_file_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + ContainerError::Unexpected(String::from( + "the devcontainer file name is not valid UTF-8", + )) + })?; + + runtime + .build_image(context, dockerfile, image_tag, &self.build_args) + .await + } + + /// Construit l'image, démarre le container, exécute les hooks puis coupe le réseau. + /// + /// En cas d'échec, le container, le réseau et l'image sont nettoyés avant de + /// renvoyer l'erreur, afin qu'aucune ressource ne soit laissée en place. + pub async fn up( + &self, + runtime: &ContainerRuntime, + workspace_dir: &Path, + ) -> Result { + let image_tag = self.image_tag(); + self.build(runtime, &image_tag).await?; + + let name = self.container_name(); + let network = format!("{name}-net"); + + // Réseau dédié afin de pouvoir couper la connectivité après les hooks. + if let Err(err) = runtime.create_network(&network).await { + let _ = runtime.remove_image(&image_tag).await; + return Err(err); + } + + let workspace_folder = self.workspace_folder(); + let body = ContainerCreateBody { + image: Some(image_tag.clone()), + cmd: Some(vec![String::from("sleep"), String::from("infinity")]), + user: self.remote_user.clone(), + env: Some( + self.container_env + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect(), + ), + working_dir: Some(workspace_folder.clone()), + host_config: Some(HostConfig { + binds: Some(vec![format!( + "{}:{workspace_folder}", + workspace_dir.display() + )]), + network_mode: Some(network.clone()), + // Le clone est monté depuis un chemin de l'hôte. Sur une distribution + // à SELinux enforcing, ce chemin n'a pas le label attendu et l'accès + // est refusé (EACCES), ce qui fait échouer tous les outils de la + // sandbox. C'est le compromis inverse de l'alternative `:Z` sur le + // montage, qui re-labellise le clone et garde le confinement SELinux. + security_opt: Some(vec![String::from("label=disable")]), + ..Default::default() + }), + ..Default::default() + }; + + if let Err(err) = runtime.create_container(&name, body).await { + let _ = runtime.remove_network(&network).await; + let _ = runtime.remove_image(&image_tag).await; + return Err(err); + } + + if let Err(err) = runtime.start_container(&name).await { + let _ = runtime.remove_container(&name).await; + let _ = runtime.remove_network(&network).await; + let _ = runtime.remove_image(&image_tag).await; + return Err(err); + } + + let container = Container::new( + runtime.clone(), + name, + self.workspace_folder(), + self.remote_user.clone(), + Some(network.clone()), + Some(image_tag), + ); + + // Les hooks s'exécutent avec accès au réseau (installation de dépendances, etc.). + if let Err(err) = self.run_hooks(&container, runtime.timeout()).await { + let _ = container.remove().await; + return Err(err); + } + + // Coupe l'accès réseau pour le reste de la durée de vie de la sandbox. + if let Err(err) = runtime.disconnect_network(&network, container.name()).await { + let _ = container.remove().await; + return Err(err); + } + + Ok(container) + } + + async fn run_hooks( + &self, + container: &Container, + timeout: Duration, + ) -> Result<(), ContainerError> { + for command in [&self.post_create_command, &self.post_start_command] + .into_iter() + .flatten() + { + let output = container + .exec_with_timeout(&["sh", "-c", command], timeout) + .await?; + + if !output.success() { + return Err(ContainerError::Unexpected(format!( + "hook `{command}` failed with status {}: {}", + output.status, + output.stderr.trim() + ))); + } + } + + Ok(()) + } +} + +/// Nettoie une chaîne pour qu'elle puisse servir de nom d'image/container docker. +fn sanitize(input: &str) -> String { + let sanitized: String = input + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect(); + + let trimmed = sanitized.trim_matches(|c| c == '-' || c == '.' || c == '_'); + if trimmed.is_empty() { + "devcontainer".to_string() + } else { + trimmed.to_string() + } +} + +/// Suffixe unique à une exécution de sandbox, combinant l'identifiant de processus et un horodatage. +fn unique_suffix() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + + format!("{}-{}", std::process::id(), nanos) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn devcontainer(dir: &Path) -> DevContainer { + let devcontainer_path = dir.join("devcontainer.json"); + let dockerfile_path = dir.join("Dockerfile"); + + fs::write(&dockerfile_path, "FROM alpine\n").unwrap(); + fs::write( + &devcontainer_path, + r#"{ + "name": "My Project", + "build": { + "dockerfile": "Dockerfile", + "args": { "VERSION": "1" } + }, + "workspaceFolder": "/workspaces/my-project", + "containerEnv": { "RUST_LOG": "debug" }, + "remoteUser": "dev" + }"#, + ) + .unwrap(); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(crate::parse(&devcontainer_path)).unwrap() + } + + #[test] + fn image_name_is_sanitized() { + let dir = tempfile::tempdir().unwrap(); + let dc = devcontainer(dir.path()); + assert_eq!(dc.image_name(), "devcontainer-rs/my-project"); + } + + #[test] + fn image_tags_are_unique() { + let dir = tempfile::tempdir().unwrap(); + let dc = devcontainer(dir.path()); + assert_ne!(dc.image_tag(), dc.image_tag()); + } + + #[test] + fn container_name_is_sanitized() { + let dir = tempfile::tempdir().unwrap(); + let dc = devcontainer(dir.path()); + + assert!( + dc.container_name() + .starts_with("devcontainer-rs-my-project-") + ); + } +} diff --git a/crates/devcontainer-rs/src/errors.rs b/crates/devcontainer-rs/src/errors.rs new file mode 100644 index 0000000..ac0b400 --- /dev/null +++ b/crates/devcontainer-rs/src/errors.rs @@ -0,0 +1,52 @@ +//! Erreurs de la crate : échecs du daemon de containers et échecs d'analyse du +//! `devcontainer.json`. + +use std::{path::PathBuf, time::Duration}; + +use bollard::errors::Error as BollardError; + +#[derive(Debug, thiserror::Error)] +pub enum ContainerError { + /// Le daemon est injoignable, ou a refusé une requête. + #[error("container daemon request failed: {source}")] + Request { + #[from] + source: BollardError, + }, + + /// Une opération a dépassé son timeout. + #[error("`{operation}` timed out after {timeout:?}")] + Timeout { + operation: String, + timeout: Duration, + }, + + /// La construction de l'image a échoué. + #[error("image build failed: {message}")] + Build { message: String }, + + /// Le daemon a répondu autre chose que ce qui était attendu. + #[error("{0}")] + Unexpected(String), +} + +#[derive(Debug, thiserror::Error)] +pub enum ParseError { + #[error("failed to read devcontainer file `{path}`: {source}")] + Read { + path: PathBuf, + source: std::io::Error, + }, + + #[error("invalid devcontainer JSON in `{path}`: {source}")] + Json { + path: PathBuf, + source: serde_json::Error, + }, + + #[error("container file `{0}` does not exist or is not a regular file")] + ContainerFileNotFound(PathBuf), + + #[error("the devcontainer file path has no parent directory: `{0}`")] + InvalidDevContainerPath(PathBuf), +} diff --git a/crates/devcontainer-rs/src/exec.rs b/crates/devcontainer-rs/src/exec.rs new file mode 100644 index 0000000..58d2615 --- /dev/null +++ b/crates/devcontainer-rs/src/exec.rs @@ -0,0 +1,16 @@ +//! Résultat d'une commande exécutée dans un container. + +/// Résultat d'une commande exécutée dans un container. +#[derive(Debug, Clone)] +pub struct ExecOutput { + /// Code de sortie, ou `-1` si le processus a été terminé par un signal. + pub status: i32, + pub stdout: String, + pub stderr: String, +} + +impl ExecOutput { + pub fn success(&self) -> bool { + self.status == 0 + } +} diff --git a/crates/devcontainer-rs/src/lib.rs b/crates/devcontainer-rs/src/lib.rs index c8f39bc..0bdb717 100644 --- a/crates/devcontainer-rs/src/lib.rs +++ b/crates/devcontainer-rs/src/lib.rs @@ -1,250 +1,43 @@ -use std::{ - collections::HashMap, - path::{Path, PathBuf}, -}; - -use serde::Deserialize; +//! Primitives de cycle de vie de container pour un [`DevContainer`] analysé. +//! +//! Cette crate pilote l'API du daemon de containers pour construire l'image +//! devcontainer, démarrer un container avec le workspace monté, exécuter les +//! hooks `postCreateCommand` / `postStartCommand` et lancer des commandes à +//! l'intérieur du container en cours d'exécution. +//! +//! Le daemon est joint par son **socket** (celui de l'hôte, monté dans le +//! container de Herald) : [`ContainerRuntime::connect`] suit `DOCKER_HOST` comme +//! le fait le CLI `docker`, et retombe sur le socket local. Aucun binaire de +//! runtime n'est donc requis dans l'image, et `podman` fonctionne de la même +//! façon dès lors qu'il expose son socket compatible Docker. +//! +//! # Isolation +//! +//! Chaque sandbox dispose de son propre tag d'image, de son propre container et de +//! son propre réseau. Le container démarre attaché à ce réseau afin que les hooks +//! `postCreateCommand` / `postStartCommand` puissent récupérer des dépendances +//! (par ex. `npm install`) ; une fois les hooks exécutés, le container est déconnecté +//! du réseau pour le reste de sa durée de vie. Chaque commande est bornée par un timeout. +//! +//! Les `runArgs` du `devcontainer.json` sont lus mais pas transmis au daemon : ils +//! viennent du dépôt, donc d'une pull request non fiable, et pourraient rattacher le +//! container à un autre réseau ou lui donner des privilèges qui annuleraient cette +//! isolation. +mod consts; mod container; +mod context; +mod devcontainer; +mod errors; +mod exec; +mod path; +mod runtime; +mod schema; -pub use container::{Container, ContainerError, ContainerRuntime, ExecOutput, normalize}; - -#[derive(Debug, Deserialize)] -pub struct DevContainerBuildSchema { - #[serde(default)] - pub dockerfile: String, - #[serde(default)] - pub args: HashMap, -} - -#[derive(Debug, Deserialize)] -pub struct DevContainerSchema { - #[serde(default)] - pub name: Option, - pub build: DevContainerBuildSchema, - - #[serde(rename = "workspaceFolder", default)] - pub workspace_folder: Option, - - #[serde(rename = "containerEnv", default)] - pub container_env: HashMap, - - #[serde(rename = "postCreateCommand", default)] - pub post_create_command: Option, - - #[serde(rename = "postStartCommand", default)] - pub post_start_command: Option, - - #[serde(rename = "remoteUser", default)] - pub remote_user: Option, - - /// 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, -} - -#[derive(Debug)] -pub struct DevContainer { - pub container_file_path: PathBuf, - pub name: Option, - pub build_args: HashMap, - pub container_env: HashMap, - pub workspace_folder: Option, - pub post_create_command: Option, - pub post_start_command: Option, - pub remote_user: Option, -} - -#[derive(Debug, thiserror::Error)] -pub enum ParseError { - #[error("failed to read devcontainer file `{path}`: {source}")] - Read { - path: PathBuf, - source: std::io::Error, - }, - - #[error("invalid devcontainer JSON in `{path}`: {source}")] - Json { - path: PathBuf, - source: serde_json::Error, - }, - - #[error("container file `{0}` does not exist or is not a regular file")] - ContainerFileNotFound(PathBuf), - - #[error("the devcontainer file path has no parent directory: `{0}`")] - InvalidDevContainerPath(PathBuf), -} - -impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer { - type Error = ParseError; - - fn try_from( - (schema, devcontainer_path): (DevContainerSchema, PathBuf), - ) -> Result { - let base_dir = devcontainer_path - .parent() - .ok_or_else(|| ParseError::InvalidDevContainerPath(devcontainer_path.clone()))?; - - let container_file_path = base_dir.join(schema.build.dockerfile); - - if !container_file_path.is_file() { - return Err(ParseError::ContainerFileNotFound(container_file_path)); - } - - let build_args = schema - .build - .args - .into_iter() - .map(|(k, v)| (k, substitute_local_env(&v))) - .collect(); - - let container_env = schema - .container_env - .into_iter() - .map(|(k, v)| (k, substitute_local_env(&v))) - .collect(); - - Ok(Self { - container_file_path, - name: schema.name, - build_args, - container_env, - workspace_folder: schema.workspace_folder, - post_create_command: schema.post_create_command, - post_start_command: schema.post_start_command, - remote_user: schema.remote_user, - }) - } -} - -/// Résout les références `${localEnv:VAR}` et `${localEnv:VAR:default}` à l'aide de -/// l'environnement du processus courant, comme décrit par la spécification devcontainer. -/// Les variables non résolues sans valeur par défaut sont remplacées par une chaîne vide. -fn substitute_local_env(input: &str) -> String { - const PREFIX: &str = "${localEnv:"; - - let mut out = String::with_capacity(input.len()); - let mut rest = input; - - while let Some(start) = rest.find(PREFIX) { - out.push_str(&rest[..start]); - let after = &rest[start + PREFIX.len()..]; - - match after.find('}') { - Some(end) => { - let inner = &after[..end]; - let (key, default) = match inner.split_once(':') { - Some((key, default)) => (key, Some(default)), - None => (inner, None), - }; - - match std::env::var(key) { - Ok(value) => out.push_str(&value), - Err(_) => out.push_str(default.unwrap_or("")), - } - - rest = &after[end + 1..]; - } - None => { - out.push_str(PREFIX); - rest = after; - } - } - } - - out.push_str(rest); - out -} - -pub async fn parse(path: impl AsRef) -> Result { - let path = path.as_ref().to_path_buf(); - let contents = tokio::fs::read_to_string(&path) - .await - .map_err(|source| ParseError::Read { - path: path.clone(), - source, - })?; - - let schema = serde_json::from_str::(&contents).map_err(|source| { - ParseError::Json { - path: path.clone(), - source, - } - })?; - - DevContainer::try_from((schema, path)) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - #[tokio::test] - async fn parses_devcontainer_file() { - let dir = tempfile::tempdir().unwrap(); - let devcontainer_path = dir.path().join("devcontainer.json"); - let dockerfile_path = dir.path().join("Dockerfile"); - - fs::write(&dockerfile_path, "FROM alpine\n").unwrap(); - fs::write( - &devcontainer_path, - r#"{ - "name": "test", - "build": { - "dockerfile": "Dockerfile", - "args": { - "VERSION": "1" - } - }, - "workspaceFolder": "/workspace", - "containerEnv": { - "RUST_LOG": "debug" - }, - "remoteUser": "dev" - }"#, - ) - .unwrap(); - - let config = parse(&devcontainer_path).await.unwrap(); - - assert_eq!(config.name.as_deref(), Some("test")); - assert_eq!(config.container_file_path, dockerfile_path); - assert_eq!(config.build_args.get("VERSION").unwrap(), "1"); - 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")); - } - - #[test] - fn substitutes_local_env_with_default() { - unsafe { std::env::set_var("DEVCONTAINER_TEST_UID", "1000") }; - - assert_eq!( - substitute_local_env("${localEnv:DEVCONTAINER_TEST_UID}"), - "1000" - ); - assert_eq!( - substitute_local_env("uid=${localEnv:DEVCONTAINER_TEST_UID}"), - "uid=1000" - ); - assert_eq!( - substitute_local_env("${localEnv:DEVCONTAINER_TEST_MISSING:fallback}"), - "fallback" - ); - assert_eq!( - substitute_local_env("${localEnv:DEVCONTAINER_TEST_MISSING}"), - "" - ); - assert_eq!( - substitute_local_env("no variables here"), - "no variables here" - ); - } -} +pub use container::Container; +pub use devcontainer::DevContainer; +pub use errors::{ContainerError, ParseError}; +pub use exec::ExecOutput; +pub use path::normalize; +pub use runtime::ContainerRuntime; +pub use schema::{DevContainerBuildSchema, DevContainerSchema, parse}; diff --git a/crates/devcontainer-rs/src/path.rs b/crates/devcontainer-rs/src/path.rs new file mode 100644 index 0000000..4524c0e --- /dev/null +++ b/crates/devcontainer-rs/src/path.rs @@ -0,0 +1,40 @@ +//! Normalisation lexicale de chemins, sans accès au système de fichiers. + +use std::path::{Path, PathBuf}; + +/// Normalise lexicalement un chemin, en résolvant `.` et `..` sans toucher au +/// système de fichiers. Renvoie `None` si le chemin sort de sa racine. +pub fn normalize(path: &Path) -> Option { + use std::path::Component; + + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::RootDir => out.push("/"), + Component::CurDir => {} + Component::ParentDir => { + if !out.pop() { + return None; + } + } + Component::Normal(part) => out.push(part), + Component::Prefix(_) => return None, + } + } + + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_rejects_escaping_paths() { + assert_eq!( + normalize(Path::new("/workspaces/project/src/../main.rs")), + Some(PathBuf::from("/workspaces/project/main.rs")) + ); + assert_eq!(normalize(Path::new("/workspaces/../../etc/passwd")), None); + } +} diff --git a/crates/devcontainer-rs/src/runtime.rs b/crates/devcontainer-rs/src/runtime.rs new file mode 100644 index 0000000..43b238d --- /dev/null +++ b/crates/devcontainer-rs/src/runtime.rs @@ -0,0 +1,352 @@ +//! Client de l'API du daemon de containers et opérations de cycle de vie. + +use std::{collections::HashMap, path::Path, time::Duration}; + +use bollard::{ + Docker, body_try_stream, + container::LogOutput, + errors::Error as BollardError, + exec::{CreateExecOptions, StartExecOptions, StartExecResults}, + models::{BuildInfo, ContainerCreateBody, NetworkCreateRequest, NetworkDisconnectRequest}, + query_parameters::{ + BuildImageOptions, CreateContainerOptions, RemoveContainerOptions, RemoveImageOptions, + StartContainerOptions, StopContainerOptions, + }, +}; +use futures_util::StreamExt; + +use crate::{ + consts::{DEFAULT_COMMAND_TIMEOUT, DEFAULT_ENDPOINT, EXIT_CODE_ATTEMPTS, EXIT_CODE_DELAY}, + context::tar_directory_stream, + errors::ContainerError, + exec::ExecOutput, +}; + +/// Un client de l'API du daemon de containers. +#[derive(Debug, Clone)] +pub struct ContainerRuntime { + docker: Docker, + endpoint: String, + timeout: Duration, +} + +impl ContainerRuntime { + /// Se connecte au daemon désigné par `DOCKER_HOST`, ou au socket local par défaut. + pub fn connect() -> Result { + let endpoint = + std::env::var("DOCKER_HOST").unwrap_or_else(|_| String::from(DEFAULT_ENDPOINT)); + + Ok(Self { + docker: Docker::connect_with_defaults()?, + endpoint, + timeout: DEFAULT_COMMAND_TIMEOUT, + }) + } + + /// Remplace le timeout appliqué aux opérations de build/run/stop/remove. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Endpoint du daemon, pour les logs. + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + pub fn timeout(&self) -> Duration { + self.timeout + } + + /// Vérifie que le daemon est joignable et répond. + pub async fn available(&self) -> bool { + self.docker.ping().await.is_ok() + } + + /// Exécute une requête du daemon en appliquant le timeout des commandes. + async fn request( + &self, + operation: &str, + request: impl Future>, + ) -> Result { + match tokio::time::timeout(self.timeout, request).await { + Ok(Ok(value)) => Ok(value), + Ok(Err(source)) => Err(ContainerError::Request { source }), + Err(_) => Err(ContainerError::Timeout { + operation: String::from(operation), + timeout: self.timeout, + }), + } + } + + /// Construit l'image `tag` depuis le contexte `context_dir`. + pub(crate) async fn build_image( + &self, + context_dir: &Path, + dockerfile: &str, + tag: &str, + build_args: &HashMap, + ) -> Result<(), ContainerError> { + let options = BuildImageOptions { + dockerfile: String::from(dockerfile), + t: Some(String::from(tag)), + buildargs: Some(build_args.clone()), + rm: true, + ..Default::default() + }; + + let context = tar_directory_stream(context_dir); + let mut stream = self + .docker + .build_image(options, None, Some(body_try_stream(context))); + + // Le flux porte la progression du build : la dernière erreur signalée par le + // daemon fait échouer l'opération. + let consume = async { + let mut failure = None; + + while let Some(info) = stream.next().await { + let info: BuildInfo = info?; + + if let Some(message) = info.error_detail.and_then(|detail| detail.message) { + failure = Some(message); + } + } + + Ok::<_, BollardError>(failure) + }; + + let failure = match tokio::time::timeout(self.timeout, consume).await { + Ok(Ok(failure)) => failure, + Ok(Err(source)) => return Err(ContainerError::Request { source }), + Err(_) => { + return Err(ContainerError::Timeout { + operation: format!("build image `{tag}`"), + timeout: self.timeout, + }); + } + }; + + match failure { + Some(message) => Err(ContainerError::Build { message }), + None => Ok(()), + } + } + + /// Crée un container, sans le démarrer. + pub(crate) async fn create_container( + &self, + name: &str, + body: ContainerCreateBody, + ) -> Result<(), ContainerError> { + let options = CreateContainerOptions { + name: Some(String::from(name)), + ..Default::default() + }; + + self.request( + "create container", + self.docker.create_container(Some(options), body), + ) + .await?; + + Ok(()) + } + + pub(crate) async fn start_container(&self, name: &str) -> Result<(), ContainerError> { + self.request( + "start container", + self.docker + .start_container(name, None::), + ) + .await?; + + Ok(()) + } + + /// Exécute une commande dans un container et renvoie sa sortie. + pub(crate) async fn exec( + &self, + container: &str, + cmd: &[&str], + user: Option<&str>, + timeout: Duration, + ) -> Result { + let config = CreateExecOptions { + cmd: Some(cmd.iter().map(|arg| String::from(*arg)).collect()), + user: user.map(String::from), + attach_stdout: Some(true), + attach_stderr: Some(true), + ..Default::default() + }; + + let created = self + .request("create exec", self.docker.create_exec(container, config)) + .await?; + + let started = self + .request( + "start exec", + self.docker + .start_exec(&created.id, None::), + ) + .await?; + + let StartExecResults::Attached { mut output, .. } = started else { + return Err(ContainerError::Unexpected(String::from( + "the daemon detached an exec that was not requested as detached", + ))); + }; + + let mut stdout = String::new(); + let mut stderr = String::new(); + + // Le timeout couvre l'exécution de la commande elle-même, pas seulement sa + // mise en place. + let collect = async { + while let Some(message) = output.next().await { + match message? { + LogOutput::StdOut { message } | LogOutput::Console { message } => { + stdout.push_str(&String::from_utf8_lossy(&message)); + } + LogOutput::StdErr { message } => { + stderr.push_str(&String::from_utf8_lossy(&message)); + } + LogOutput::StdIn { .. } => {} + } + } + + Ok::<_, BollardError>(()) + }; + + match tokio::time::timeout(timeout, collect).await { + Ok(Ok(())) => {} + Ok(Err(source)) => return Err(ContainerError::Request { source }), + Err(_) => { + // Le processus continue de tourner dans le container : sans arrêt, il + // consommerait CPU et mémoire et pourrait encore modifier le workspace + // pendant toute la durée de vie de la sandbox. L'API n'offre pas de + // moyen de tuer un exec, on arrête donc le container qui le porte. + let _ = self.stop_container(container).await; + + return Err(ContainerError::Timeout { + operation: format!("exec {}", cmd.join(" ")), + timeout, + }); + } + } + + // Le code de sortie n'est pas forcément publié au moment où le flux se ferme : + // on laisse au daemon le temps de le renseigner, sans bloquer indéfiniment. + // Sans cela, une commande réussie peut être rapportée en échec (code -1) et + // l'outil renvoie une erreur au modèle à la place du contenu. + let mut inspected = self + .request("inspect exec", self.docker.inspect_exec(&created.id)) + .await?; + + for _ in 0..EXIT_CODE_ATTEMPTS { + if !inspected.running.unwrap_or(false) { + break; + } + + tokio::time::sleep(EXIT_CODE_DELAY).await; + + inspected = self + .request("inspect exec", self.docker.inspect_exec(&created.id)) + .await?; + } + + Ok(ExecOutput { + status: inspected.exit_code.unwrap_or(-1) as i32, + stdout, + stderr, + }) + } + + pub(crate) async fn stop_container(&self, name: &str) -> Result<(), ContainerError> { + let options = StopContainerOptions { + t: Some(1), + ..Default::default() + }; + + self.request( + "stop container", + self.docker.stop_container(name, Some(options)), + ) + .await?; + + Ok(()) + } + + /// Supprime un container, ses volumes anonymes et le processus qui y tourne. + pub(crate) async fn remove_container(&self, name: &str) -> Result<(), ContainerError> { + let options = RemoveContainerOptions { + force: true, + v: true, + ..Default::default() + }; + + self.request( + "remove container", + self.docker.remove_container(name, Some(options)), + ) + .await?; + + Ok(()) + } + + pub(crate) async fn remove_image(&self, tag: &str) -> Result<(), ContainerError> { + let options = RemoveImageOptions { + force: true, + ..Default::default() + }; + + self.request( + "remove image", + self.docker.remove_image(tag, Some(options), None), + ) + .await?; + + Ok(()) + } + + /// Crée le réseau dédié d'une sandbox. + pub(crate) async fn create_network(&self, name: &str) -> Result<(), ContainerError> { + let request = NetworkCreateRequest { + name: String::from(name), + ..Default::default() + }; + + self.request("create network", self.docker.create_network(request)) + .await?; + + Ok(()) + } + + /// Détache un container d'un réseau, ce qui coupe sa connectivité. + pub(crate) async fn disconnect_network( + &self, + network: &str, + container: &str, + ) -> Result<(), ContainerError> { + let request = NetworkDisconnectRequest { + container: String::from(container), + ..Default::default() + }; + + self.request( + "disconnect network", + self.docker.disconnect_network(network, request), + ) + .await?; + + Ok(()) + } + + pub(crate) async fn remove_network(&self, name: &str) -> Result<(), ContainerError> { + self.request("remove network", self.docker.remove_network(name)) + .await?; + + Ok(()) + } +} diff --git a/crates/devcontainer-rs/src/schema.rs b/crates/devcontainer-rs/src/schema.rs new file mode 100644 index 0000000..033337a --- /dev/null +++ b/crates/devcontainer-rs/src/schema.rs @@ -0,0 +1,230 @@ +//! Schéma du `devcontainer.json`, analyse du fichier et résolution des +//! références `${localEnv:…}`. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use serde::Deserialize; + +use crate::{devcontainer::DevContainer, errors::ParseError}; + +#[derive(Debug, Deserialize)] +pub struct DevContainerBuildSchema { + #[serde(default)] + pub dockerfile: String, + #[serde(default)] + pub args: HashMap, +} + +#[derive(Debug, Deserialize)] +pub struct DevContainerSchema { + #[serde(default)] + pub name: Option, + pub build: DevContainerBuildSchema, + + #[serde(rename = "workspaceFolder", default)] + pub workspace_folder: Option, + + #[serde(rename = "containerEnv", default)] + pub container_env: HashMap, + + #[serde(rename = "postCreateCommand", default)] + pub post_create_command: Option, + + #[serde(rename = "postStartCommand", default)] + pub post_start_command: Option, + + #[serde(rename = "remoteUser", default)] + pub remote_user: Option, + + /// 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, décrite + /// dans le doc de la crate. + #[serde(rename = "runArgs", default)] + pub run_args: Vec, +} + +impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer { + type Error = ParseError; + + fn try_from( + (schema, devcontainer_path): (DevContainerSchema, PathBuf), + ) -> Result { + let base_dir = devcontainer_path + .parent() + .ok_or_else(|| ParseError::InvalidDevContainerPath(devcontainer_path.clone()))?; + + let container_file_path = base_dir.join(schema.build.dockerfile); + + if !container_file_path.is_file() { + return Err(ParseError::ContainerFileNotFound(container_file_path)); + } + + let build_args = schema + .build + .args + .into_iter() + .map(|(k, v)| (k, substitute_local_env(&v))) + .collect(); + + let container_env = schema + .container_env + .into_iter() + .map(|(k, v)| (k, substitute_local_env(&v))) + .collect(); + + Ok(Self { + container_file_path, + name: schema.name, + build_args, + container_env, + workspace_folder: schema.workspace_folder, + post_create_command: schema.post_create_command, + post_start_command: schema.post_start_command, + remote_user: schema.remote_user, + }) + } +} + +/// Résout les références `${localEnv:VAR}` et `${localEnv:VAR:default}` d'un +/// `devcontainer.json`. +/// +/// La spécification veut que `${localEnv:VAR}` soit lu dans l'environnement du +/// client ; ici le `devcontainer.json` vient d'une **pull request**, donc de code +/// non fiable : lire l'environnement du processus lui permettrait de récupérer +/// `GITEA_TOKEN`, `OPEN_ROUTER_API_KEY` ou n'importe quel autre secret de Herald et +/// de l'exfiltrer depuis un `build.args`, un `containerEnv`, son Dockerfile ou un +/// hook. L'environnement n'est donc **jamais** consulté : seule la valeur par +/// défaut est utilisée, et une référence sans défaut devient une chaîne vide. +fn substitute_local_env(input: &str) -> String { + const PREFIX: &str = "${localEnv:"; + + let mut out = String::with_capacity(input.len()); + let mut rest = input; + + while let Some(start) = rest.find(PREFIX) { + out.push_str(&rest[..start]); + let after = &rest[start + PREFIX.len()..]; + + match after.find('}') { + Some(end) => { + let inner = &after[..end]; + // La clé est lue pour délimiter la référence, pas pour la résoudre. + let (_key, default) = match inner.split_once(':') { + Some((key, default)) => (key, Some(default)), + None => (inner, None), + }; + + out.push_str(default.unwrap_or("")); + + rest = &after[end + 1..]; + } + None => { + out.push_str(PREFIX); + rest = after; + } + } + } + + out.push_str(rest); + out +} + +pub async fn parse(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + let contents = tokio::fs::read_to_string(&path) + .await + .map_err(|source| ParseError::Read { + path: path.clone(), + source, + })?; + + let schema = serde_json::from_str::(&contents).map_err(|source| { + ParseError::Json { + path: path.clone(), + source, + } + })?; + + DevContainer::try_from((schema, path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[tokio::test] + async fn parses_devcontainer_file() { + let dir = tempfile::tempdir().unwrap(); + let devcontainer_path = dir.path().join("devcontainer.json"); + let dockerfile_path = dir.path().join("Dockerfile"); + + fs::write(&dockerfile_path, "FROM alpine\n").unwrap(); + fs::write( + &devcontainer_path, + r#"{ + "name": "test", + "build": { + "dockerfile": "Dockerfile", + "args": { + "VERSION": "1" + } + }, + "workspaceFolder": "/workspace", + "containerEnv": { + "RUST_LOG": "debug" + }, + "remoteUser": "dev" + }"#, + ) + .unwrap(); + + let config = parse(&devcontainer_path).await.unwrap(); + + assert_eq!(config.name.as_deref(), Some("test")); + assert_eq!(config.container_file_path, dockerfile_path); + assert_eq!(config.build_args.get("VERSION").unwrap(), "1"); + 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")); + } + + #[test] + fn local_env_references_are_never_read_from_the_environment() { + unsafe { std::env::set_var("DEVCONTAINER_TEST_SECRET", "s3cret") }; + + // Le `devcontainer.json` vient d'une pull request : résoudre la référence + // depuis l'environnement de Herald y exposerait ses jetons. + assert_eq!( + substitute_local_env("${localEnv:DEVCONTAINER_TEST_SECRET}"), + "" + ); + assert_eq!( + substitute_local_env("token=${localEnv:DEVCONTAINER_TEST_SECRET}"), + "token=" + ); + } + + #[test] + fn local_env_defaults_are_used() { + assert_eq!( + substitute_local_env("${localEnv:DEVCONTAINER_TEST_MISSING:fallback}"), + "fallback" + ); + assert_eq!( + substitute_local_env("${localEnv:DEVCONTAINER_TEST_MISSING}"), + "" + ); + assert_eq!(substitute_local_env("uid=${localEnv:UID:1000}"), "uid=1000"); + assert_eq!( + substitute_local_env("no variables here"), + "no variables here" + ); + } +} diff --git a/crates/herald-server/src/sandbox/tools.rs b/crates/herald-server/src/sandbox/tools.rs index bf318e6..0e7d916 100644 --- a/crates/herald-server/src/sandbox/tools.rs +++ b/crates/herald-server/src/sandbox/tools.rs @@ -43,7 +43,8 @@ fn review_tools() -> Vec { ), Tool::new( "read_file", - "Read the content of a text file inside the repository.", + "Read the content of a text file inside the repository. Every line is \ + prefixed with its absolute line number, even when only a range is read.", json!({ "type": "object", "properties": { @@ -128,18 +129,36 @@ async fn read_file(sandbox: &Sandbox, args: &Value) -> anyhow::Result { let start = args.get("start_line").and_then(Value::as_u64); let end = args.get("end_line").and_then(Value::as_u64); - let output = if start.is_none() && end.is_none() { - sandbox.exec(&["cat", "--", &path]).await? + let (first_line, output) = if start.is_none() && end.is_none() { + (1, sandbox.exec(&["cat", "--", &path]).await?) } else { let start = start.unwrap_or(1); let end = end .map(|line| line.to_string()) .unwrap_or_else(|| "$".to_string()); let range = format!("{start},{end}p"); - sandbox.exec(&["sed", "-n", &range, "--", &path]).await? + + ( + start, + sandbox.exec(&["sed", "-n", &range, "--", &path]).await?, + ) }; - into_stdout(output) + Ok(number_lines(&into_stdout(output)?, first_line)) +} + +/// Préfixe chaque ligne par son numéro. +/// +/// Le modèle doit citer une ligne précise pour ancrer son commentaire : sans +/// numéros, il les compte lui-même et se décale de quelques lignes, ce qui place le +/// commentaire à côté du code visé. +fn number_lines(content: &str, first_line: u64) -> String { + content + .lines() + .enumerate() + .map(|(offset, line)| format!("{}:{line}", first_line + offset as u64)) + .collect::>() + .join("\n") } async fn grep(sandbox: &Sandbox, args: &Value) -> anyhow::Result { @@ -243,4 +262,21 @@ mod tests { let err = required_str(&json!({}), "path").unwrap_err(); assert!(err.to_string().contains("path")); } + + #[test] + fn lines_are_numbered_from_the_first_one() { + assert_eq!(number_lines("a\nb\n", 1), "1:a\n2:b"); + } + + #[test] + fn a_range_keeps_the_absolute_line_numbers() { + // Un extrait lu à partir de la ligne 12 doit garder la numérotation du + // fichier : sinon le modèle citerait des lignes décalées. + assert_eq!(number_lines("x\ny", 12), "12:x\n13:y"); + } + + #[test] + fn an_empty_read_stays_empty() { + assert_eq!(number_lines("", 1), ""); + } } From 48e09aa373e526002760069fc290c0174f39e7d8 Mon Sep 17 00:00:00 2001 From: qpismont Date: Sun, 20 Sep 2026 13:10:05 +0000 Subject: [PATCH 15/17] Fix missing workspace when herald run in container --- Containerfile | 11 --------- README.md | 12 ++++++++++ crates/devcontainer-rs/src/devcontainer.rs | 24 +++++++++++--------- crates/devcontainer-rs/src/lib.rs | 2 +- crates/devcontainer-rs/src/runtime.rs | 26 +++++++++++++++++++++- crates/herald-server/src/sandbox/mod.rs | 15 ++++++++----- crates/herald-server/src/sandbox/tools.rs | 20 +++++++++++++++++ 7 files changed, 81 insertions(+), 29 deletions(-) diff --git a/Containerfile b/Containerfile index c39abf8..24748c2 100644 --- a/Containerfile +++ b/Containerfile @@ -22,21 +22,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ 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: [""]`. -RUN useradd --create-home --shell /usr/sbin/nologin --uid 10001 herald WORKDIR /app 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"] diff --git a/README.md b/README.md index 3c69327..12bcb22 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,18 @@ Herald drives the container daemon through its socket: `DOCKER_HOST` (default Docker-compatible socket. The repository must contain a `.devcontainer/devcontainer.json`. +Herald can therefore run inside a container with only that socket mounted (no +shared workspace directory is required): the clone is streamed to the daemon over +the socket, like the build context, instead of being bind-mounted from a host +path the daemon would have to see. This is the setup the `Containerfile` +produces, e.g.: + +```sh +podman run --env-file=.env -p 3001:3001 \ + -v /run/user/$(id -u)/podman/podman.sock:/var/run/docker.sock \ + herald:latest +``` + 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 diff --git a/crates/devcontainer-rs/src/devcontainer.rs b/crates/devcontainer-rs/src/devcontainer.rs index bd73690..173a06d 100644 --- a/crates/devcontainer-rs/src/devcontainer.rs +++ b/crates/devcontainer-rs/src/devcontainer.rs @@ -112,17 +112,7 @@ impl DevContainer { ), working_dir: Some(workspace_folder.clone()), host_config: Some(HostConfig { - binds: Some(vec![format!( - "{}:{workspace_folder}", - workspace_dir.display() - )]), network_mode: Some(network.clone()), - // Le clone est monté depuis un chemin de l'hôte. Sur une distribution - // à SELinux enforcing, ce chemin n'a pas le label attendu et l'accès - // est refusé (EACCES), ce qui fait échouer tous les outils de la - // sandbox. C'est le compromis inverse de l'alternative `:Z` sur le - // montage, qui re-labellise le clone et garde le confinement SELinux. - security_opt: Some(vec![String::from("label=disable")]), ..Default::default() }), ..Default::default() @@ -141,6 +131,20 @@ impl DevContainer { return Err(err); } + // Le clone est copié dans le container par le socket, pas monté depuis un + // chemin de l'hôte : le daemon n'a pas besoin de voir le clone pour le rendre + // visible dans la sandbox, ce qui permet à Herald de tourner dans un container + // (avec le socket monté) sans partager de dossier avec l'hôte. + if let Err(err) = runtime + .upload_directory(&name, &workspace_folder, workspace_dir) + .await + { + let _ = runtime.remove_container(&name).await; + let _ = runtime.remove_network(&network).await; + let _ = runtime.remove_image(&image_tag).await; + return Err(err); + } + let container = Container::new( runtime.clone(), name, diff --git a/crates/devcontainer-rs/src/lib.rs b/crates/devcontainer-rs/src/lib.rs index 0bdb717..7771ad6 100644 --- a/crates/devcontainer-rs/src/lib.rs +++ b/crates/devcontainer-rs/src/lib.rs @@ -1,7 +1,7 @@ //! Primitives de cycle de vie de container pour un [`DevContainer`] analysé. //! //! Cette crate pilote l'API du daemon de containers pour construire l'image -//! devcontainer, démarrer un container avec le workspace monté, exécuter les +//! devcontainer, démarrer un container et y copier le workspace, exécuter les //! hooks `postCreateCommand` / `postStartCommand` et lancer des commandes à //! l'intérieur du container en cours d'exécution. //! diff --git a/crates/devcontainer-rs/src/runtime.rs b/crates/devcontainer-rs/src/runtime.rs index 43b238d..e7fd8aa 100644 --- a/crates/devcontainer-rs/src/runtime.rs +++ b/crates/devcontainer-rs/src/runtime.rs @@ -10,7 +10,7 @@ use bollard::{ models::{BuildInfo, ContainerCreateBody, NetworkCreateRequest, NetworkDisconnectRequest}, query_parameters::{ BuildImageOptions, CreateContainerOptions, RemoveContainerOptions, RemoveImageOptions, - StartContainerOptions, StopContainerOptions, + StartContainerOptions, StopContainerOptions, UploadToContainerOptions, }, }; use futures_util::StreamExt; @@ -153,6 +153,30 @@ impl ContainerRuntime { Ok(()) } + pub(crate) async fn upload_directory( + &self, + container: &str, + destination: &str, + directory: &Path, + ) -> Result<(), ContainerError> { + let options = UploadToContainerOptions { + path: String::from(destination), + ..Default::default() + }; + + self.request( + "upload workspace", + self.docker.upload_to_container( + container, + Some(options), + body_try_stream(tar_directory_stream(directory)), + ), + ) + .await?; + + Ok(()) + } + pub(crate) async fn start_container(&self, name: &str) -> Result<(), ContainerError> { self.request( "start container", diff --git a/crates/herald-server/src/sandbox/mod.rs b/crates/herald-server/src/sandbox/mod.rs index d732d27..2d4ad28 100644 --- a/crates/herald-server/src/sandbox/mod.rs +++ b/crates/herald-server/src/sandbox/mod.rs @@ -35,7 +35,7 @@ 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. + /// Path of the clone, as copied into the container. repo_dir: PathBuf, container: Container, } @@ -72,17 +72,20 @@ impl Sandbox { container, }; - sandbox.check_workspace().await?; + if let Err(err) = sandbox.check_workspace().await { + let _ = sandbox.container.remove().await; + return Err(err); + } 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. + /// Sans ce contrôle, un workspace vide — par exemple un daemon qui n'a pas pu + /// recevoir le clone — 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 diff --git a/crates/herald-server/src/sandbox/tools.rs b/crates/herald-server/src/sandbox/tools.rs index 0e7d916..7af9f64 100644 --- a/crates/herald-server/src/sandbox/tools.rs +++ b/crates/herald-server/src/sandbox/tools.rs @@ -41,6 +41,19 @@ fn review_tools() -> Vec { } }), ), + Tool::new( + "file_size", + "Get the size of a file inside the repository in bytes.", + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "File path relative to the repository root." + } + } + }), + ), Tool::new( "read_file", "Read the content of a text file inside the repository. Every line is \ @@ -111,12 +124,19 @@ pub async fn dispatch(sandbox: &Sandbox, name: &str, args: &Value) -> anyhow::Re match name { "ls" => ls(sandbox, args).await, "read_file" => read_file(sandbox, args).await, + "file_size" => file_size(sandbox, args).await, "grep" => grep(sandbox, args).await, "find" => find(sandbox, args).await, other => bail!("unknown tool `{other}`"), } } +async fn file_size(sandbox: &Sandbox, args: &Value) -> anyhow::Result { + let path = resolve(sandbox, required_str(args, "path")?)?; + let size = sandbox.exec(&["du", "-b", "--", &path]).await?; + into_stdout(size) +} + async fn ls(sandbox: &Sandbox, args: &Value) -> anyhow::Result { let path = resolve(sandbox, optional_str(args, "path").unwrap_or("."))?; let output = sandbox.exec(&["ls", "-la", "--", &path]).await?; From be7284c6d8efb7685462a4d8428bdd505e9c83c8 Mon Sep 17 00:00:00 2001 From: qpismont Date: Sun, 20 Sep 2026 13:21:52 +0000 Subject: [PATCH 16/17] Fix file_size test --- crates/herald-server/src/consts.rs | 2 +- crates/herald-server/src/sandbox/tools.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/herald-server/src/consts.rs b/crates/herald-server/src/consts.rs index 4ec599e..2829224 100644 --- a/crates/herald-server/src/consts.rs +++ b/crates/herald-server/src/consts.rs @@ -12,7 +12,7 @@ pub const SANDBOX_SYSTEM_PROMPT: &str = " You are a senior software engineer reviewing a pull request. The repository is checked out in your working directory. Use the provided - tools (ls, read_file, grep, find) to explore the code and gather the context + tools (ls, read_file, grep, find, file_size) 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, as a raw diff --git a/crates/herald-server/src/sandbox/tools.rs b/crates/herald-server/src/sandbox/tools.rs index 7af9f64..919feaf 100644 --- a/crates/herald-server/src/sandbox/tools.rs +++ b/crates/herald-server/src/sandbox/tools.rs @@ -274,7 +274,7 @@ mod tests { .map(|tool| tool.function.name) .collect(); - assert_eq!(names, vec!["ls", "read_file", "grep", "find"]); + assert_eq!(names, vec!["ls", "read_file", "grep", "find", "file_size"]); } #[test] From fa03fd8589f38b03ba243e80284048e887a03c80 Mon Sep 17 00:00:00 2001 From: qpismont Date: Sun, 20 Sep 2026 14:42:14 +0000 Subject: [PATCH 17/17] add review type (perf, bug, security, ...) + fix tests --- README.md | 13 +- crates/herald-server/src/bot.rs | 56 ++++ .../herald-server/src/bot_actions/review.rs | 218 ++++++++++++- crates/herald-server/src/consts.rs | 63 ++-- crates/herald-server/src/gitea.rs | 37 ++- crates/herald-server/src/sandbox/instance.rs | 288 ++++++++++++++++++ crates/herald-server/src/sandbox/mod.rs | 287 +---------------- crates/herald-server/src/sandbox/tools.rs | 3 +- 8 files changed, 630 insertions(+), 335 deletions(-) create mode 100644 crates/herald-server/src/sandbox/instance.rs diff --git a/README.md b/README.md index 12bcb22..dffffe8 100644 --- a/README.md +++ b/README.md @@ -51,11 +51,20 @@ Herald reviews pull requests inside an ephemeral `GITEA_TOKEN` (so private repositories work), tells the model which files and lines changed — additions and deletions, with the line numbers of the new and old versions of the file respectively — then lets it explore the repository - with read-only tools (`ls`, `read_file`, `grep`, `find`) run inside the - container: the code itself is not sent, so the model reads it at those lines, + with read-only tools (`ls`, `file_size`, `read_file`, `grep`, `find`) run inside + the container: the code itself is not sent, so the model reads it at those lines, 4. posts the review, anchoring each comment on the added or removed line it refers to, and removes the container and the temporary clone. +Generated files are left out of the changes handed to the model: lockfiles +(`Cargo.lock`, `package-lock.json`, `yarn.lock`, `go.sum`…) are machine-written +dependency churn whose thousands of lines would drown the code under review, and +they are never a place where a comment belongs. + +Each comment is tagged with a severity — `bug`, `security`, `performance` or +`maintainability` — shown at the start of the comment, and the summary also lists +what the pull request does well. + 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 diff --git a/crates/herald-server/src/bot.rs b/crates/herald-server/src/bot.rs index aca98ed..596e1c8 100644 --- a/crates/herald-server/src/bot.rs +++ b/crates/herald-server/src/bot.rs @@ -23,6 +23,8 @@ pub struct ReviewItem { pub line: Option, #[serde(default, deserialize_with = "deserialize_side")] pub side: Option, + #[serde(default, deserialize_with = "deserialize_severity")] + pub severity: Option, pub message: String, } @@ -48,6 +50,46 @@ impl ReviewSide { } } +/// What kind of problem a review reports. +/// +/// The categories are the ones the prompt asks for; a review whose severity is +/// missing or unreadable falls back to [`ReviewSeverity::Maintainability`], the +/// least alarming bucket, instead of failing the whole review. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReviewSeverity { + /// Wrong behaviour. + Bug, + /// A vulnerability. + Security, + /// A resource problem. + Performance, + /// Everything else: readability, structure, naming, tests. + Maintainability, +} + +impl ReviewSeverity { + /// Reads the severity the model asked for, tolerating casing and synonyms. + fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "bug" | "bugs" | "correctness" | "error" => Some(Self::Bug), + "security" | "vulnerability" => Some(Self::Security), + "performance" | "perf" => Some(Self::Performance), + "maintainability" | "quality" | "style" => Some(Self::Maintainability), + _ => None, + } + } + + /// Lower-case label used in the review markdown. + pub fn label(self) -> &'static str { + match self { + Self::Bug => "bug", + Self::Security => "security", + Self::Performance => "performance", + Self::Maintainability => "maintainability", + } + } +} + /// Reads the side the model asked for. An unreadable value is ignored instead of /// failing the whole review: the side is then resolved from the changed lines. fn deserialize_side<'de, D>(deserializer: D) -> Result, D::Error> @@ -62,6 +104,20 @@ where .and_then(ReviewSide::parse)) } +/// Reads the severity the model asked for, ignoring an unreadable value: the +/// review is then reported as [`ReviewSeverity::Maintainability`]. +fn deserialize_severity<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let raw = Option::::deserialize(deserializer)?; + + Ok(raw + .as_ref() + .and_then(serde_json::Value::as_str) + .and_then(ReviewSeverity::parse)) +} + #[derive(Clone)] pub struct Bot { bot_name: String, diff --git a/crates/herald-server/src/bot_actions/review.rs b/crates/herald-server/src/bot_actions/review.rs index 61ca7c4..371f7a6 100644 --- a/crates/herald-server/src/bot_actions/review.rs +++ b/crates/herald-server/src/bot_actions/review.rs @@ -3,7 +3,7 @@ use std::str::FromStr; use tracing::{info, instrument, warn}; use crate::{ - bot::{ReviewResult, ReviewSide}, + bot::{ReviewItem, ReviewResult, ReviewSeverity, ReviewSide}, consts::{BOT_PROCESS_MSG, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT}, gitea::{GiteaAPI, PullRequestFile, ReviewPayload}, metrics, @@ -53,6 +53,7 @@ pub async fn exec_review( }; let mut changed_lines = parse_changed_lines(&git_diff); + drop_generated_files(&mut changed_lines); resolve_filenames(&mut changed_lines, &files); let changes = format_changes(&files, &changed_lines); @@ -126,11 +127,9 @@ async fn run_sandboxed_review( review_payload: &ReviewPayload, bot_request: &str, ) -> anyhow::Result<(ReviewResult, Option)> { - let repo_url = gitea_api.repo_clone_url(&review_payload.repository.full_name); - let sandbox = Sandbox::create( &sandbox_config.runtime, - &repo_url, + &review_payload.repository.clone_url, gitea_api.token(), review_payload.pull_request.number, ) @@ -172,6 +171,12 @@ fn review_result_to_markdown(review_result: &ReviewResult) -> String { review_result.reviews.len() )); + let breakdown = severity_breakdown(&review_result.reviews); + if !breakdown.is_empty() { + md.push_str(&breakdown); + md.push('\n'); + } + if !review_result.comment.is_empty() { md.push_str("\n---\n\n"); md.push_str("### Summary\n\n"); @@ -188,6 +193,30 @@ fn review_result_to_markdown(review_result: &ReviewResult) -> String { md } +/// Counts the reviews per severity, most severe first. +/// +/// A review whose severity the model omitted or wrote unreadably is counted as +/// [`ReviewSeverity::Maintainability`], the same fallback used when posting it. +fn severity_breakdown(reviews: &[ReviewItem]) -> String { + [ + ReviewSeverity::Bug, + ReviewSeverity::Security, + ReviewSeverity::Performance, + ReviewSeverity::Maintainability, + ] + .into_iter() + .filter_map(|severity| { + let count = reviews + .iter() + .filter(|review| review.severity.unwrap_or(ReviewSeverity::Maintainability) == severity) + .count(); + + (count > 0).then(|| format!("- {} {}", count, severity.label())) + }) + .collect::>() + .join("\n") +} + /// The lines a pull request changed, per file. /// /// Line numbers are the ones Gitea expects to place a review comment: @@ -386,18 +415,61 @@ fn resolve_filenames(changed_lines: &mut ChangedLines, files: &[PullRequestFile] } } +/// Basenames of generated files that are never reviewed. +/// +/// Their diffs are machine-written dependency churn: they run to thousands of +/// lines, flood the prompt with line numbers and drown the code the model should +/// look at. A lockfile is also never a place where a review comment belongs. +const IGNORED_FILE_NAMES: [&str; 16] = [ + "Cargo.lock", + "package-lock.json", + "npm-shrinkwrap.json", + "yarn.lock", + "pnpm-lock.yaml", + "bun.lock", + "bun.lockb", + "composer.lock", + "Gemfile.lock", + "poetry.lock", + "uv.lock", + "Pipfile.lock", + "go.sum", + "packages.lock.json", + "flake.lock", + "pubspec.lock", +]; + +/// Whether a changed path is a generated file that is never reviewed. +/// +/// The comparison is on the basename, so a lockfile nested in a workspace (for +/// example `crates/foo/Cargo.lock`) is matched too. +fn is_ignored(filename: &str) -> bool { + let basename = filename.rsplit('/').next().unwrap_or(filename); + + IGNORED_FILE_NAMES.contains(&basename) +} + +/// Drops the generated files from the changed lines. +/// +/// Removing them here keeps the prompt free of their line numbers, and also makes +/// [`resolve_review_sides`] reject any review the model anchors on one of them. +fn drop_generated_files(changed_lines: &mut ChangedLines) { + changed_lines.retain(|file| !is_ignored(&file.filename)); +} + /// Renders the changes for the model: the files the pull request touches, then /// the lines to review per file. fn format_changes(files: &[PullRequestFile], changed_lines: &ChangedLines) -> String { let mut sections = Vec::new(); - if !files.is_empty() { - let described = files - .iter() - .map(describe_file) - .collect::>() - .join(", "); + let described = files + .iter() + .filter(|file| !is_ignored(&file.filename)) + .map(describe_file) + .collect::>() + .join(", "); + if !described.is_empty() { sections.push(format!("Files changed by the pull request: {described}")); } @@ -666,6 +738,7 @@ mod tests { filename: String::from(filename), line, side, + severity: None, message: String::from("message"), } } @@ -799,6 +872,56 @@ mod tests { assert_eq!(format_changes(&[], &changed_lines), expected); } + #[test] + fn generated_lockfiles_are_ignored() { + assert!(is_ignored("Cargo.lock")); + assert!(is_ignored("crates/herald-server/Cargo.lock")); + assert!(is_ignored("package-lock.json")); + assert!(is_ignored("web/yarn.lock")); + assert!(is_ignored("go.sum")); + + assert!(!is_ignored("src/main.rs")); + assert!(!is_ignored("Cargo.toml")); + assert!(!is_ignored("docs/lockfiles.md")); + } + + #[test] + fn generated_files_are_dropped_from_the_changed_lines() { + const DIFF_WITH_LOCKFILE: &str = concat!( + "diff --git a/Cargo.lock b/Cargo.lock\n", + "--- a/Cargo.lock\n", + "+++ b/Cargo.lock\n", + "@@ -1,1 +1,2 @@\n", + " [[package]]\n", + "+name = \"x\"\n", + "diff --git a/src/foo.rs b/src/foo.rs\n", + "--- a/src/foo.rs\n", + "+++ b/src/foo.rs\n", + "@@ -1,1 +1,2 @@\n", + " fn a() {}\n", + "+fn b() {}\n", + ); + + let mut changed_lines = parse_changed_lines(DIFF_WITH_LOCKFILE); + drop_generated_files(&mut changed_lines); + + assert_eq!(format_changed_lines(&changed_lines), "src/foo.rs: added 2"); + } + + #[test] + fn a_generated_file_is_not_described_to_the_model() { + let files = vec![ + pull_request_file("Cargo.lock", None, "modified"), + pull_request_file("src/foo.rs", None, "modified"), + ]; + let changed_lines = parse_changed_lines(DIFF); + + let changes = format_changes(&files, &changed_lines); + + assert!(!changes.contains("Cargo.lock")); + assert!(changes.contains("src/foo.rs (modified)")); + } + #[test] fn a_renamed_file_is_described_with_its_previous_path() { let file = pull_request_file("src/new.rs", Some("src/old.rs"), "renamed"); @@ -960,4 +1083,79 @@ mod tests { ] ); } + + #[test] + fn severities_are_read_from_the_model() { + let result: ReviewResult = serde_json::from_str( + r#"{ + "reviews": [ + { "filename": "a", "line": 1, "severity": "Bug", "message": "x" }, + { "filename": "b", "line": 2, "severity": "SECURITY", "message": "x" }, + { "filename": "c", "line": 3, "severity": "perf", "message": "x" }, + { "filename": "d", "line": 4, "severity": "banana", "message": "x" } + ], + "comment": "" + }"#, + ) + .unwrap(); + + let severities = result + .reviews + .iter() + .map(|review| review.severity) + .collect::>(); + + assert_eq!( + severities, + vec![ + Some(ReviewSeverity::Bug), + Some(ReviewSeverity::Security), + Some(ReviewSeverity::Performance), + None, + ] + ); + } + + #[test] + fn the_breakdown_counts_severities_most_severe_first() { + let reviews = vec![ + ReviewItem { + severity: Some(ReviewSeverity::Maintainability), + ..review("a", Some(1), None) + }, + ReviewItem { + severity: Some(ReviewSeverity::Bug), + ..review("b", Some(2), None) + }, + ReviewItem { + severity: Some(ReviewSeverity::Bug), + ..review("c", Some(3), None) + }, + // A severity the model left out counts as maintainability. + review("d", Some(4), None), + ]; + + assert_eq!(severity_breakdown(&reviews), "- 2 bug\n- 2 maintainability"); + } + + #[test] + fn the_markdown_breaks_the_issues_down_by_severity() { + let mut result = review_result(vec![ + ReviewItem { + severity: Some(ReviewSeverity::Bug), + ..review("a", Some(1), None) + }, + ReviewItem { + severity: Some(ReviewSeverity::Performance), + ..review("b", Some(2), None) + }, + ]); + result.comment = String::from("Le découpage en crates est propre."); + + let markdown = review_result_to_markdown(&result); + + assert!(markdown.contains("- 1 bug")); + assert!(markdown.contains("- 1 performance")); + assert!(markdown.contains("Le découpage en crates est propre.")); + } } diff --git a/crates/herald-server/src/consts.rs b/crates/herald-server/src/consts.rs index 2829224..3e21f3a 100644 --- a/crates/herald-server/src/consts.rs +++ b/crates/herald-server/src/consts.rs @@ -9,50 +9,71 @@ pub const BOT_PROCESS_MSG: &str = " "; pub const SANDBOX_SYSTEM_PROMPT: &str = " - You are a senior software engineer reviewing a pull request. + You are a senior software engineer reviewing a pull request inside an + isolated sandbox. - The repository is checked out in your working directory. Use the provided - tools (ls, read_file, grep, find, file_size) to explore the code and gather the context - you need before answering. Paths are relative to the repository root. + The repository is checked out in your working directory. Explore it with the + provided read-only tools (ls, file_size, read_file, grep, find); paths are + relative to the repository root. Every line you read is prefixed with its + absolute line number: that is the number you must cite to anchor a comment. - 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. + Read before you assert: a review anchored on a line you did not read is + worthless, so never comment on code you have not seen. Gather enough context + to be confident, but do not re-read what you already have. + + When you answer, send the requested JSON only: a raw JSON object, no markdown + code fence, nothing before or after it. "; pub const REVIEW_PROMPT: &str = " - You are a senior software engineer reviewing code changes. + You are a senior software engineer reviewing a pull request. - Check good practices and code quality. + Judge the changes for correctness, security, resource use and + maintainability. Report real problems; do not invent issues, and do not + report pure formatting a formatter would fix. + + Be exhaustive: one review per distinct issue, and cover every changed file + that has something to report. Do not stop at the first few findings, and do + not merge several issues into a single review. This is the pull request subject: \"{subject}\" This is the user comment: \"{comment}\" + If the user asks about something precise, address that first. The pull request changes these files and lines: {changes} - `added` line numbers refer to the new version of the file, `removed` line - numbers to the old version, as they appear in the diff. + The code is not provided. Read the files you need with the available tools + before answering. - The code is not provided: read the files you need with the available tools - before answering. Review only the listed lines. + `added` line numbers refer to the new version of the file, which is what your + working directory contains now. `removed` line numbers refer to the old + version, which is not in your working directory: you cannot read a removed + line, only the code around where it used to be. - 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) - and comment must contain a final summary: + Every review must anchor on one of the listed line numbers: + - filename: the full path exactly as listed above, + - line: one of the numbers listed for that file, + - side: \"added\" for a number from the `added` list, \"removed\" for one from + the `removed` list, + - severity: exactly one of \"bug\", \"security\", \"performance\" or + \"maintainability\" — a bug is wrong behaviour, security a vulnerability, + performance a resource problem, maintainability everything else. + + Answer in french, with the raw json object only: no markdown code fence, no + text before or after it. All fields are mandatory. The `comment` field must + hold a short summary that lists the issues, and also what the pull request + does well: the author should get the compliments too. { \"reviews\": [ { \"filename\": \"\", - \"line\": , + \"line\": 0, \"side\": \"\", + \"severity\": \"\", \"message\": \"\" } ], diff --git a/crates/herald-server/src/gitea.rs b/crates/herald-server/src/gitea.rs index 13fac7e..7dbbfcc 100644 --- a/crates/herald-server/src/gitea.rs +++ b/crates/herald-server/src/gitea.rs @@ -8,7 +8,7 @@ use tokio_util::io::StreamReader; use tracing::{instrument, warn}; use crate::{ - bot::{ReviewResult, ReviewSide}, + bot::{ReviewResult, ReviewSeverity, ReviewSide}, consts::MAX_DIFF_SIZE, errors::AppError, }; @@ -49,15 +49,6 @@ impl GiteaAPI { &self.token } - /// HTTPS clone URL for a repository, suitable for `git clone`. - pub fn repo_clone_url(&self, full_name: &str) -> String { - format!( - "{}/{}.git", - self.base_url.trim_end_matches('/'), - full_name.trim_start_matches('/') - ) - } - #[instrument(skip(self))] pub async fn get_authorized_user(&self) -> anyhow::Result { let url = format!("{}/api/v1/user", self.base_url); @@ -231,7 +222,11 @@ impl GiteaAPI { .filter_map(|review| { let line = review.line?; let path = review.filename.clone(); - let body = review.message.clone(); + let severity = review + .severity + .unwrap_or(ReviewSeverity::Maintainability) + .label(); + let body = format!("**[{severity}]** {}", review.message); // A line removed by the pull request only exists in the old // version of the file, so it is anchored with `old_position`. @@ -333,6 +328,7 @@ pub struct User { #[derive(Deserialize, Debug)] pub struct Repository { pub full_name: String, + pub clone_url: String, } /// A file changed by a pull request, as reported by the API. @@ -390,7 +386,8 @@ mod tests { "title": "My PR" }, "repository": { - "full_name": "owner/repo" + "full_name": "owner/repo", + "clone_url": "https://github.com/owner/repo.git" }, "comment": { "id": 7, @@ -453,7 +450,8 @@ mod tests { "title": "My PR" }, "repository": { - "full_name": "owner/repo" + "full_name": "owner/repo", + "clone_url": "https://github.com/owner/repo.git" }, "comment": { "id": 1, @@ -485,7 +483,8 @@ mod tests { "title": "My PR" }, "repository": { - "full_name": "owner/repo" + "full_name": "owner/repo", + "clone_url": "https://github.com/owner/repo.git" }, "comment": { "id": 12, @@ -501,6 +500,10 @@ mod tests { assert_eq!(payload.action, "created"); assert_eq!(payload.comment.id, 12); assert_eq!(payload.comment.body, "Needs work"); + assert_eq!( + payload.repository.clone_url, + "https://github.com/owner/repo.git" + ); } #[test] @@ -521,7 +524,8 @@ mod tests { "title": "My PR" }, "repository": { - "full_name": "owner/repo" + "full_name": "owner/repo", + "clone_url": "https://github.com/owner/repo.git" }, "comment": { "id": 1, @@ -548,7 +552,8 @@ mod tests { "title": "My PR" }, "repository": { - "full_name": "owner/repo" + "full_name": "owner/repo", + "clone_url": "https://github.com/owner/repo.git" }, "comment": { "id": 1, diff --git a/crates/herald-server/src/sandbox/instance.rs b/crates/herald-server/src/sandbox/instance.rs new file mode 100644 index 0000000..c2bda2d --- /dev/null +++ b/crates/herald-server/src/sandbox/instance.rs @@ -0,0 +1,288 @@ +//! Le [`Sandbox`] : clone d'une pull request exécuté dans un devcontainer +//! éphémère, et sa configuration. + +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 { + /// 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, +} + +/// A cloned repository running inside an ephemeral devcontainer. +pub struct Sandbox { + // Owns the temporary directory; dropping it cleans up the clone. + _workspace: TempDir, + /// Path of the clone, as copied into the container. + repo_dir: PathBuf, + container: Container, +} + +impl Sandbox { + /// Clones the pull request head, builds the devcontainer and starts it. + /// + /// The clone is PR-aware: it fetches `refs/pull//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, + clone_url: &str, + token: &str, + pull_request_number: u64, + ) -> anyhow::Result { + let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?; + let repo_dir = workspace.path().join("repo"); + + clone_pull_request(clone_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 `{clone_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?; + + let sandbox = Self { + _workspace: workspace, + repo_dir, + container, + }; + + if let Err(err) = sandbox.check_workspace().await { + let _ = sandbox.container.remove().await; + return Err(err); + } + + Ok(sandbox) + } + + /// Vérifie que le clone est bien visible dans le container. + /// + /// Sans ce contrôle, un workspace vide — par exemple un daemon qui n'a pas pu + /// recevoir le clone — 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). + pub async fn exec(&self, cmd: &[&str]) -> anyhow::Result { + 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 { + 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(()) +} + +/// 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<()> { + 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); + } +} diff --git a/crates/herald-server/src/sandbox/mod.rs b/crates/herald-server/src/sandbox/mod.rs index 2d4ad28..a2eced6 100644 --- a/crates/herald-server/src/sandbox/mod.rs +++ b/crates/herald-server/src/sandbox/mod.rs @@ -7,290 +7,7 @@ //! tool-calling loop against OpenRouter. pub mod agent; +mod instance; 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 { - /// 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, -} - -/// A cloned repository running inside an ephemeral devcontainer. -pub struct Sandbox { - // Owns the temporary directory; dropping it cleans up the clone. - _workspace: TempDir, - /// Path of the clone, as copied into the container. - repo_dir: PathBuf, - container: Container, -} - -impl Sandbox { - /// Clones the pull request head, builds the devcontainer and starts it. - /// - /// The clone is PR-aware: it fetches `refs/pull//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 { - 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?; - make_readable(&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?; - - let sandbox = Self { - _workspace: workspace, - repo_dir, - container, - }; - - if let Err(err) = sandbox.check_workspace().await { - let _ = sandbox.container.remove().await; - return Err(err); - } - - Ok(sandbox) - } - - /// Vérifie que le clone est bien visible dans le container. - /// - /// Sans ce contrôle, un workspace vide — par exemple un daemon qui n'a pas pu - /// recevoir le clone — 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). - pub async fn exec(&self, cmd: &[&str]) -> anyhow::Result { - 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 { - 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(()) -} - -/// 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<()> { - 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); - } -} +pub use instance::{Sandbox, SandboxConfig}; diff --git a/crates/herald-server/src/sandbox/tools.rs b/crates/herald-server/src/sandbox/tools.rs index 919feaf..3d4f5a0 100644 --- a/crates/herald-server/src/sandbox/tools.rs +++ b/crates/herald-server/src/sandbox/tools.rs @@ -259,6 +259,7 @@ mod tests { }, repository: Repository { full_name: "owner/repo".to_string(), + clone_url: "https://github.com/owner/repo.git".to_string(), }, comment: Comment { id: 1, @@ -274,7 +275,7 @@ mod tests { .map(|tool| tool.function.name) .collect(); - assert_eq!(names, vec!["ls", "read_file", "grep", "find", "file_size"]); + assert_eq!(names, vec!["ls", "file_size", "read_file", "grep", "find"]); } #[test]