9 Commits
Author SHA1 Message Date
qpismont e7b8048992 add default image docker fallback
ci/woodpecker/push/tests Pipeline was successful
2026-09-22 20:57:21 +00:00
qpismont 589d337057 Merge pull request '1.2: Sandboxing' (#7) from 1.2 into main
ci/woodpecker/push/tests Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #7
2026-09-20 16:58:36 +02:00
qpismont fa03fd8589 add review type (perf, bug, security, ...) + fix tests
ci/woodpecker/push/tests Pipeline was successful
2026-09-20 14:42:14 +00:00
qpismont be7284c6d8 Fix file_size test
ci/woodpecker/push/tests Pipeline failed
2026-09-20 13:21:52 +00:00
qpismont 48e09aa373 Fix missing workspace when herald run in container
ci/woodpecker/push/tests Pipeline failed
2026-09-20 13:10:05 +00:00
qpismont 620ec6727e clean devcontainer-rs architecture
ci/woodpecker/push/tests Pipeline was successful
2026-09-17 20:37:54 +00:00
qpismont 624bc1e028 replace ContainerRuntime CLI to Bollard crate (default: docker socket)
ci/woodpecker/push/tests Pipeline was successful
limit tool result to open router tool response (with truncated info for
ai)

Hard kill if graceful shutdown is too long
2026-09-17 19:51:59 +00:00
qpismont 78ad2bf701 translate comment
ci/woodpecker/push/tests Pipeline was successful
2026-09-17 14:49:41 +00:00
qpismont 04cc172848 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.
2026-09-17 14:48:42 +00:00
29 changed files with 3989 additions and 1820 deletions
+2 -4
View File
@@ -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
CONTAINER_RUNTIME=docker
# Sandboxed tool execution
# DOCKER_HOST=
SANDBOX_MAX_ITERATIONS=8
Generated
+133 -509
View File
@@ -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"
@@ -338,17 +381,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 +406,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 +421,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 +447,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"
@@ -518,11 +475,17 @@ name = "devcontainer-rs"
version = "0.1.0"
dependencies = [
"anyhow",
"bollard",
"bytes",
"futures-util",
"serde",
"serde_json",
"tar",
"tempfile",
"thiserror 2.0.20",
"thiserror",
"tokio",
"tokio-stream",
"tracing",
]
[[package]]
@@ -552,24 +515,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"
@@ -612,6 +557,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"
@@ -749,10 +704,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 +716,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 +772,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",
@@ -954,19 +890,17 @@ dependencies = [
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
name = "hyper-named-pipe"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf"
dependencies = [
"http 1.5.0",
"hex",
"hyper",
"hyper-util",
"rustls",
"pin-project-lite",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -1008,6 +942,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"
@@ -1091,12 +1040,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 +1175,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 +1222,7 @@ dependencies = [
"metrics",
"metrics-util",
"quanta",
"thiserror 2.0.20",
"thiserror",
"tokio",
"tracing",
]
@@ -1301,7 +1238,7 @@ dependencies = [
"hashbrown 0.16.1",
"metrics",
"quanta",
"rand 0.9.5",
"rand",
"rand_xoshiro",
"rapidhash",
"sketches-ddsketch",
@@ -1313,16 +1250,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 +1489,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 +1672,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 +1687,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 +1694,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 +1704,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 +1716,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 +1752,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 +1789,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 +1815,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 +1846,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"
@@ -2115,41 +1868,15 @@ dependencies = [
"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",
]
[[package]]
name = "rustls-pki-types"
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 +1898,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 +1948,7 @@ dependencies = [
"cfg_aliases",
"httpdate",
"native-tls",
"reqwest 0.13.4",
"reqwest",
"sentry-actix",
"sentry-backtrace",
"sentry-contexts",
@@ -2315,7 +2017,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 +2091,10 @@ checksum = "fc71f5ca55942d9b2901af95d5df5c5d65c164134c22a83682cac3d0b6c7ef2d"
dependencies = [
"debugid",
"hex",
"rand 0.9.5",
"rand",
"serde",
"serde_json",
"thiserror 2.0.20",
"thiserror",
"time",
"url",
"uuid",
@@ -2428,17 +2130,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"
@@ -2463,6 +2154,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"
@@ -2534,29 +2236,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"
@@ -2599,6 +2278,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"
@@ -2606,39 +2296,19 @@ 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",
]
[[package]]
name = "thiserror"
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 +2371,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,16 +2409,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"
@@ -2929,12 +2574,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 +2640,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 +2756,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 +2777,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 +2786,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"
@@ -3303,6 +2917,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"
+1 -2
View File
@@ -6,7 +6,7 @@ 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"
@@ -15,7 +15,6 @@ 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"] }
+19 -3
View File
@@ -1,4 +1,4 @@
FROM rust:1.97-trixie as builder
FROM rust:1.98-trixie as builder
WORKDIR /app
@@ -10,6 +10,22 @@ 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/*
WORKDIR /app
COPY --from=builder /app/target/release/herald-server .
CMD [ "./herald-server" ]
COPY --from=builder /app/target/release/herald-server ./herald-server
# Exec form, so the binary is PID 1 and receives the SIGTERM it handles to shut
# down gracefully.
CMD ["./herald-server"]
+45 -11
View File
@@ -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,23 +37,58 @@ 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` |
| `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
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.
2. builds and starts the repository's devcontainer (`devcontainer-rs`) — or, when
the repository has none, a default one based on `debian:stable-slim`,
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`, `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.
The container runtime is selected with `CONTAINER_RUNTIME` (`docker` or
`podman`). The repository must contain a `.devcontainer/devcontainer.json`.
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. When the repository contains a
`.devcontainer/devcontainer.json` (or `.devcontainer.json`), Herald uses it;
otherwise it falls back to a default devcontainer that pulls `debian:stable-slim`
and runs the review in `/workspace`.
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 ignored: 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` /
@@ -67,7 +101,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:
+6
View File
@@ -4,7 +4,13 @@ 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 }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
+26
View File
@@ -0,0 +1,26 @@
//! 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);
pub(crate) const DEFAULT_IMAGE_NAME: &str = "debian:stable-slim";
+44 -599
View File
@@ -1,180 +1,13 @@
//! 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.
//! Un devcontainer en cours d'exécution : inspection et exécution de commandes.
use std::{
path::{Path, PathBuf},
process::Stdio,
time::{Duration, SystemTime, UNIX_EPOCH},
use std::time::Duration;
use crate::{
consts::DEFAULT_EXEC_TIMEOUT, errors::ContainerError, exec::ExecOutput,
runtime::ContainerRuntime,
};
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<Self, ContainerError> {
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<String>) -> 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<ExecOutput, ContainerError> {
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<ExecOutput, ContainerError> {
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.
/// Un devcontainer en cours d'exécution.
#[derive(Debug, Clone)]
pub struct Container {
runtime: ContainerRuntime,
@@ -182,10 +15,30 @@ pub struct Container {
workspace_folder: String,
remote_user: Option<String>,
network: Option<String>,
image: Option<String>,
owned_image: Option<String>,
}
impl Container {
/// Assemble un container démarré, avec son réseau et, le cas échéant, l'image
/// construite pour lui et à nettoyer.
pub(crate) fn new(
runtime: ContainerRuntime,
name: String,
workspace_folder: String,
remote_user: Option<String>,
network: Option<String>,
owned_image: Option<String>,
) -> Self {
Self {
runtime,
name,
workspace_folder,
remote_user,
network,
owned_image,
}
}
pub fn name(&self) -> &str {
&self.name
}
@@ -194,456 +47,48 @@ 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<ExecOutput, ContainerError> {
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,
) -> Result<ExecOutput, ContainerError> {
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
}
/// 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<ExecOutput, ContainerError> {
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
.run(&args)
.await?
.ensure_success(self.runtime.program(), &args)?;
Ok(())
self.runtime.stop_container(&self.name).await
}
/// 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(),
"-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;
if let Some(image) = &self.owned_image {
let _ = self.runtime.remove_image(image).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<String> {
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<String> {
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<Container, ContainerError> {
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<PathBuf> {
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 { .. }));
}
}
+166
View File
@@ -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<Item = Result<Bytes, std::io::Error>> + 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<Result<Bytes, std::io::Error>>,
}
impl Write for ChannelWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
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<u8> {
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::<Vec<_>>();
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"
);
}
}
+308
View File
@@ -0,0 +1,308 @@
//! 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 tracing::info;
use crate::{
consts::DEFAULT_IMAGE_NAME, container::Container, errors::ContainerError,
runtime::ContainerRuntime,
};
#[derive(Debug)]
pub struct DevContainer {
pub image: Option<String>,
pub container_file_path: Option<PathBuf>,
pub name: Option<String>,
pub build_args: HashMap<String, String>,
pub container_env: HashMap<String, String>,
pub workspace_folder: Option<String>,
pub post_create_command: Option<String>,
pub post_start_command: Option<String>,
pub remote_user: Option<String>,
}
impl DevContainer {
/// 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())
}
/// Tag de l'image construite localement à partir du Dockerfile.
fn local_image_tag(&self, id: &str) -> String {
format!("devcontainer-rs/{}:{}", self.sanitized_name(), id)
}
/// Nom du container de cette sandbox.
fn container_name(&self, id: &str) -> String {
format!("devcontainer-rs-{}-{}", self.sanitized_name(), id)
}
/// Nom du devcontainer nettoyé pour servir de nom d'image/container.
fn sanitized_name(&self) -> String {
sanitize(self.name.as_deref().unwrap_or("devcontainer"))
}
/// Construit l'image devcontainer sous `image_tag`.
async fn build(
&self,
runtime: &ContainerRuntime,
container_file_path: &Path,
image_tag: &str,
) -> Result<(), ContainerError> {
let context = container_file_path.parent().ok_or_else(|| {
ContainerError::Unexpected(String::from(
"the devcontainer file path has no parent directory",
))
})?;
let dockerfile = 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
}
pub async fn up(
&self,
runtime: &ContainerRuntime,
workspace_dir: &Path,
) -> Result<Container, ContainerError> {
let id = unique_id();
let image = self
.image
.clone()
.unwrap_or_else(|| self.local_image_tag(&id));
let owns_image = self.image.is_none();
info!(image = %image, "Starting sandbox container");
match owns_image {
true => {
let container_file_path = self.container_file_path.as_ref().ok_or_else(|| {
ContainerError::Unexpected(String::from(
"the devcontainer has neither an image nor a container file",
))
})?;
self.build(runtime, container_file_path, &image).await?;
}
false => {
runtime.pull_image(&image).await?;
}
}
let name = self.container_name(&id);
let network = format!("{name}-net");
runtime.create_network(&network).await?;
let workspace_folder = self.workspace_folder();
let body = ContainerCreateBody {
image: Some(image.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 {
network_mode: Some(network.clone()),
..Default::default()
}),
..Default::default()
};
if let Err(err) = runtime.create_container(&name, body).await {
let _ = runtime.remove_network(&network).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;
return Err(err);
}
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;
return Err(err);
}
let container = Container::new(
runtime.clone(),
name,
self.workspace_folder(),
self.remote_user.clone(),
Some(network.clone()),
owns_image.then_some(image),
);
// 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(())
}
}
impl Default for DevContainer {
fn default() -> Self {
Self {
name: Some("generic-devcontainer".to_string()),
image: Some(DEFAULT_IMAGE_NAME.to_string()),
post_create_command: None,
post_start_command: None,
build_args: HashMap::new(),
container_env: HashMap::new(),
container_file_path: None,
remote_user: None,
workspace_folder: None,
}
}
}
/// 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()
}
}
/// Identifiant unique à une exécution de sandbox, combinant l'identifiant de processus et un horodatage.
fn unique_id() -> 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 local_image_tag_is_sanitized() {
let dir = tempfile::tempdir().unwrap();
let dc = devcontainer(dir.path());
assert_eq!(dc.local_image_tag("abc"), "devcontainer-rs/my-project:abc");
}
#[test]
fn container_name_is_sanitized() {
let dir = tempfile::tempdir().unwrap();
let dc = devcontainer(dir.path());
assert_eq!(dc.container_name("abc"), "devcontainer-rs-my-project-abc");
}
#[test]
fn unique_ids_differ() {
assert_ne!(unique_id(), unique_id());
}
#[test]
fn default_devcontainer_pulls_a_base_image() {
let dc = DevContainer::default();
assert_eq!(dc.image.as_deref(), Some("debian:stable-slim"));
assert_eq!(dc.container_file_path, None);
}
}
+55
View File
@@ -0,0 +1,55 @@
//! 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("devcontainer file `{0}` declares neither `image` nor `build`")]
MissingImageOrBuild(PathBuf),
#[error("the devcontainer file path has no parent directory: `{0}`")]
InvalidDevContainerPath(PathBuf),
}
+16
View File
@@ -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
}
}
+40 -245
View File
@@ -1,248 +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 préparer l'image
//! devcontainer (construite depuis un Dockerfile, ou tirée d'un registre),
//! 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.
//!
//! 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 ignorés : 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<String, String>,
}
#[derive(Debug, Deserialize)]
pub struct DevContainerSchema {
#[serde(default)]
pub name: Option<String>,
pub build: DevContainerBuildSchema,
#[serde(rename = "workspaceFolder", default)]
pub workspace_folder: Option<String>,
#[serde(rename = "containerEnv", default)]
pub container_env: HashMap<String, String>,
#[serde(rename = "postCreateCommand", default)]
pub post_create_command: Option<String>,
#[serde(rename = "postStartCommand", default)]
pub post_start_command: Option<String>,
#[serde(rename = "remoteUser", default)]
pub remote_user: Option<String>,
#[serde(rename = "runArgs", default)]
pub run_args: Vec<String>,
}
#[derive(Debug)]
pub struct DevContainer {
pub container_file_path: PathBuf,
pub name: Option<String>,
pub build_args: HashMap<String, String>,
pub container_env: HashMap<String, String>,
pub workspace_folder: Option<String>,
pub post_create_command: Option<String>,
pub post_start_command: Option<String>,
pub remote_user: Option<String>,
pub run_args: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
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<Self, Self::Error> {
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,
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<Path>) -> Result<DevContainer, ParseError> {
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::<DevContainerSchema>(&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",
"runArgs": ["--userns=keep-id"]
}"#,
)
.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"));
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"
);
}
}
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::parse;
+40
View File
@@ -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<PathBuf> {
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);
}
}
+405
View File
@@ -0,0 +1,405 @@
//! 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, CreateImageOptionsBuilder,
RemoveContainerOptions, RemoveImageOptions, StartContainerOptions, StopContainerOptions,
UploadToContainerOptions,
},
};
use futures_util::StreamExt;
use tracing::info;
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<Self, ContainerError> {
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<T>(
&self,
operation: &str,
request: impl Future<Output = Result<T, BollardError>>,
) -> Result<T, ContainerError> {
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<String, String>,
) -> 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(()),
}
}
pub(crate) async fn pull_image(&self, image: &str) -> Result<(), ContainerError> {
info!("Pulling image `{image}`");
let options = CreateImageOptionsBuilder::default()
.from_image(image)
.build();
let mut stream = self.docker.create_image(Some(options), None, None);
let consume = async {
while let Some(info) = stream.next().await {
info?;
}
Ok::<_, BollardError>(())
};
match tokio::time::timeout(self.timeout, consume).await {
Ok(Ok(())) => Ok(()),
Ok(Err(source)) => Err(ContainerError::Request { source }),
Err(_) => Err(ContainerError::Timeout {
operation: format!("pull image `{image}`"),
timeout: self.timeout,
}),
}
}
/// 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 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",
self.docker
.start_container(name, None::<StartContainerOptions>),
)
.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<ExecOutput, ContainerError> {
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::<StartExecOptions>),
)
.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(())
}
}
+302
View File
@@ -0,0 +1,302 @@
//! 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)]
struct DevContainerBuildSchema {
#[serde(default)]
dockerfile: String,
#[serde(default)]
args: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
struct DevContainerSchema {
#[serde(default)]
name: Option<String>,
/// Image de base tirée du registre, alternative à `build`.
#[serde(default)]
image: Option<String>,
#[serde(default)]
build: Option<DevContainerBuildSchema>,
#[serde(rename = "workspaceFolder", default)]
workspace_folder: Option<String>,
#[serde(rename = "containerEnv", default)]
container_env: HashMap<String, String>,
#[serde(rename = "postCreateCommand", default)]
post_create_command: Option<String>,
#[serde(rename = "postStartCommand", default)]
post_start_command: Option<String>,
#[serde(rename = "remoteUser", default)]
remote_user: Option<String>,
}
impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
type Error = ParseError;
fn try_from(
(schema, devcontainer_path): (DevContainerSchema, PathBuf),
) -> Result<Self, Self::Error> {
let base_dir = devcontainer_path
.parent()
.ok_or_else(|| ParseError::InvalidDevContainerPath(devcontainer_path.clone()))?;
let DevContainerSchema {
name,
image,
build,
workspace_folder,
container_env,
post_create_command,
post_start_command,
remote_user,
} = schema;
let (image, container_file_path, build_args) = match (image, build) {
(Some(image), _) => (Some(substitute_local_env(&image)), None, HashMap::new()),
(None, Some(build)) => {
let container_file_path = base_dir.join(&build.dockerfile);
if !container_file_path.is_file() {
return Err(ParseError::ContainerFileNotFound(container_file_path));
}
let build_args = build
.args
.into_iter()
.map(|(k, v)| (k, substitute_local_env(&v)))
.collect();
(None, Some(container_file_path), build_args)
}
(None, None) => return Err(ParseError::MissingImageOrBuild(devcontainer_path)),
};
let container_env = container_env
.into_iter()
.map(|(k, v)| (k, substitute_local_env(&v)))
.collect();
Ok(Self {
image,
container_file_path,
name,
build_args,
container_env,
workspace_folder,
post_create_command,
post_start_command,
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<Path>) -> Result<DevContainer, ParseError> {
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::<DevContainerSchema>(&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.image, None);
assert_eq!(config.container_file_path, Some(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"));
}
#[tokio::test]
async fn parses_image_based_devcontainer() {
let dir = tempfile::tempdir().unwrap();
let devcontainer_path = dir.path().join("devcontainer.json");
fs::write(
&devcontainer_path,
r#"{
"name": "image-based",
"image": "mcr.microsoft.com/devcontainers/base:debian"
}"#,
)
.unwrap();
let config = parse(&devcontainer_path).await.unwrap();
assert_eq!(
config.image.as_deref(),
Some("mcr.microsoft.com/devcontainers/base:debian")
);
assert_eq!(config.container_file_path, None);
}
#[tokio::test]
async fn ignores_run_args() {
let dir = tempfile::tempdir().unwrap();
let devcontainer_path = dir.path().join("devcontainer.json");
// `runArgs` vient d'une pull request non fiable : il doit être accepté par
// l'analyse mais jamais repris dans le devcontainer.
fs::write(
&devcontainer_path,
r#"{
"image": "alpine",
"runArgs": ["--network", "host"]
}"#,
)
.unwrap();
let config = parse(&devcontainer_path).await.unwrap();
assert_eq!(config.image.as_deref(), Some("alpine"));
}
#[tokio::test]
async fn rejects_devcontainer_without_image_or_build() {
let dir = tempfile::tempdir().unwrap();
let devcontainer_path = dir.path().join("devcontainer.json");
fs::write(&devcontainer_path, r#"{ "name": "empty" }"#).unwrap();
let err = parse(&devcontainer_path).await.unwrap_err();
assert!(matches!(err, ParseError::MissingImageOrBuild(_)));
}
#[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"
);
}
}
-3
View File
@@ -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" }
+95 -5
View File
@@ -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,108 @@ pub struct ReviewResult {
pub struct ReviewItem {
pub filename: String,
pub line: Option<u64>,
#[serde(default, deserialize_with = "deserialize_side")]
pub side: Option<ReviewSide>,
#[serde(default, deserialize_with = "deserialize_severity")]
pub severity: Option<ReviewSeverity>,
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<Self> {
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,
}
}
}
/// 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<Self> {
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<Option<ReviewSide>, D::Error>
where
D: Deserializer<'de>,
{
let raw = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(raw
.as_ref()
.and_then(serde_json::Value::as_str)
.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<Option<ReviewSeverity>, D::Error>
where
D: Deserializer<'de>,
{
let raw = Option::<serde_json::Value>::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,
gitea_api: GiteaAPI,
open_router_client: OpenRouterClient,
http_client: reqwest::Client,
max_concurrent: usize,
open_router_model: String,
sandbox: SandboxConfig,
@@ -41,7 +134,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 +142,6 @@ impl Bot {
bot_name,
gitea_api,
open_router_client,
http_client,
max_concurrent,
open_router_model,
sandbox,
@@ -123,7 +214,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,
File diff suppressed because it is too large Load Diff
+45 -17
View File
@@ -9,43 +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) 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.
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 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.
The code is not provided. Read the files you need with the available tools
before answering.
IMPORTANT: the `line` field must be the line number shown before each line.
The provided code has the format: `filename:line:code`
`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.
(filename field must contain the full path with extension) 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\": ,
\"code\": \"\",
\"line\": 0,
\"side\": \"\",
\"severity\": \"\",
\"message\": \"\"
}
],
-9
View File
@@ -12,8 +12,6 @@ pub struct EnvConfig {
pub gitea_token: String,
pub gitea_timeout: u64,
pub metrics_bind_addr: Option<String>,
pub container_runtime: String,
pub sandbox_enabled: bool,
pub sandbox_max_iterations: usize,
}
@@ -28,11 +26,6 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
let gitea_token = try_get_env("GITEA_TOKEN")?;
let gitea_timeout = try_get_env("GITEA_TIMEOUT")?.parse()?;
let metrics_bind_addr = std::env::var("METRICS_BIND_ADDR").ok();
let container_runtime =
std::env::var("CONTAINER_RUNTIME").unwrap_or_else(|_| "docker".to_string());
let sandbox_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())
@@ -49,8 +42,6 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
gitea_token,
gitea_timeout,
metrics_bind_addr,
container_runtime,
sandbox_enabled,
sandbox_max_iterations,
})
}
+145 -27
View File
@@ -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, ReviewSeverity, 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 {
@@ -36,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<User> {
let url = format!("{}/api/v1/user", self.base_url);
@@ -135,6 +139,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<String> {
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<Vec<PullRequestFile>> {
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::<Vec<PullRequestFile>>().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 +216,31 @@ 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 severity = review
.severity
.unwrap_or(ReviewSeverity::Maintainability)
.label();
let body = format!("**[{severity}]** {}", review.message);
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::<Vec<_>>();
@@ -184,6 +264,23 @@ impl GiteaAPI {
}
}
/// Reads a response body, refusing to buffer more than [`MAX_DIFF_SIZE`].
async fn read_capped(response: reqwest::Response) -> anyhow::Result<String> {
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 +310,6 @@ pub struct ReviewPayload {
#[derive(Deserialize, Debug)]
pub struct PullRequest {
pub diff_url: String,
pub number: u64,
pub title: String,
}
@@ -232,6 +328,19 @@ 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.
#[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<String>,
/// `added`, `modified`, `deleted`, `renamed`…
pub status: String,
}
impl WebhookType {
@@ -277,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,
@@ -340,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,
@@ -372,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,
@@ -388,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]
@@ -408,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,
@@ -435,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,
+35 -11
View File
@@ -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,15 +88,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()),
runtime: devcontainer_rs::ContainerRuntime::connect()?,
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"
endpoint = sandbox.runtime.endpoint(),
"Container daemon is not reachable, every review will fail"
);
}
@@ -95,7 +103,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,
@@ -121,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");
+435 -54
View File
@@ -1,92 +1,473 @@
//! 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<f64>,
/// OpenRouter API root, version prefix included.
const BASE_URL: &str = "https://openrouter.ai/api/v1";
/// 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 {
pub message: Option<String>,
pub tool_calls: Vec<ToolCall>,
pub cost: Option<f64>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
impl Message {
pub fn new(role: Role, content: impl Into<String>) -> 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<String>,
tool_calls: Vec<ToolCall>,
) -> 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<String>, content: impl Into<String>) -> 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<String>, description: impl Into<String>, 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<Choice>,
#[serde(default)]
usage: Option<Usage>,
}
#[derive(Deserialize)]
struct Choice {
message: ResponseMessage,
}
#[derive(Deserialize)]
struct ResponseMessage {
#[serde(default)]
content: Option<Content>,
#[serde(default)]
tool_calls: Option<Vec<ToolCall>>,
}
/// 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<ContentPart>),
}
#[derive(Deserialize)]
struct ContentPart {
#[serde(default)]
text: Option<String>,
}
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::<Vec<_>>()
.join(""),
}
}
}
#[derive(Deserialize)]
struct Usage {
#[serde(default)]
cost: Option<f64>,
}
/// 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<Self> {
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<ChatResult> {
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.
///
/// The conversation is borrowed rather than taken by value: the caller keeps
/// appending to it between iterations, and a copy per iteration would be pure
/// waste.
#[instrument(skip(self, messages, tools), err)]
pub async fn chat_with_tools(
&self,
messages: Vec<Message>,
tools: Vec<Tool>,
messages: &[Message],
tools: &[Tool],
tool_choice: ToolChoice,
) -> anyhow::Result<ToolChatResult> {
let request = ChatCompletionRequest::builder()
.model(&self.model)
.enable_reasoning()
.messages(messages)
.tools(tools)
.tool_choice_auto()
.build()?;
let response = self.complete(messages, tools, tool_choice).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],
tool_choice: ToolChoice,
) -> anyhow::Result<ChatResponse> {
let request = ChatRequest {
model: &self.model,
messages,
reasoning: Reasoning { enabled: true },
tools,
tool_choice: tool_choice.as_str(),
};
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::<ChatResponse>()
.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::<ErrorResponse>(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: ToolChoice::Auto.as_str(),
})
.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 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!({
"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(" <html>bad gateway</html> "),
"<html>bad gateway</html>"
);
}
}
+215 -28
View File
@@ -5,22 +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 openrouter_rs::{
Message,
types::{Role, Tool, ToolCall},
};
use serde_json::Value;
use tracing::{debug, warn};
use tracing::{info, warn};
use crate::{
open_router::OpenRouterClient,
open_router::{Message, OpenRouterClient, Role, Tool, ToolCall, ToolChoice},
sandbox::{Sandbox, tools},
text::{excerpt, truncate_bytes},
};
/// Number of characters of the arguments of a tool call kept in the logs.
const MAX_LOGGED_ARGUMENTS: usize = 200;
/// Number of characters of a tool result kept in the logs.
///
/// Sizes alone make a broken tool invisible: a command that fails returns a short
/// `error: …` instead of the expected content, which is exactly what this excerpt
/// makes obvious.
const MAX_LOGGED_OUTPUT: usize = 120;
/// Maximum number of bytes of a tool result handed to the model.
///
/// A result stays in the conversation and is re-sent on every following
/// iteration, so an unbounded one (a whole file, a match on every line) inflates
/// the context and the cost of the whole rest of the run — and can overflow the
/// model context window outright.
const MAX_TOOL_OUTPUT_BYTES: usize = 32 * 1024;
/// Asked of the model when its answer does not satisfy the caller's contract.
const RETRY_PROMPT: &str = "
Your answer is not valid: {error}
Answer again with the requested format only: a raw json object, without any
markdown code fence and without any text before or after it.
";
/// Asked of the model on the last turn, in place of one more exploration turn.
///
/// Without it, a model still exploring on its last turn would ask for another
/// tool, and the run would end on the iteration budget with no answer at all.
const FINAL_PROMPT: &str = "
You are out of turns: this is your last one. Answer now, with the requested
format, from what you have already gathered, without calling any tool.
";
/// Final output of an agent run.
pub struct AgentResult {
pub message: String,
pub struct AgentResult<A> {
/// Answer of the model, parsed into the type the caller asked for.
pub answer: A,
pub cost: Option<f64>,
pub iterations: usize,
}
@@ -30,25 +66,64 @@ pub struct AgentResult {
///
/// `tool_definitions` is the set of tools the model may call; it is selected by
/// the caller based on the webhook action (see [`tools::for_webhook`]).
pub async fn run(
///
/// The answer of the model is parsed into `A`, which is how the caller states the
/// format it expects: while [`FromStr`] rejects the answer, the error is sent back
/// to the model so that it can correct itself, which costs an iteration. The
/// parsing happens here because that is where the conversation and the remaining
/// budget are at hand.
pub async fn run<A>(
open_router: &OpenRouterClient,
sandbox: &Sandbox,
tool_definitions: Vec<Tool>,
system_prompt: &str,
user_prompt: &str,
max_iterations: usize,
) -> anyhow::Result<AgentResult> {
) -> anyhow::Result<AgentResult<A>>
where
A: FromStr,
A::Err: std::fmt::Display,
{
let mut messages = vec![
Message::new(Role::System, system_prompt),
Message::new(Role::User, user_prompt),
];
info!(
tools = %tool_definitions
.iter()
.map(|tool| tool.function.name.as_str())
.collect::<Vec<_>>()
.join(", "),
max_iterations,
"Starting tool-calling loop"
);
let mut total_cost = 0.0_f64;
let mut has_cost = false;
let mut last_rejection: Option<String> = None;
for iteration in 1..=max_iterations {
// Le dernier tour n'est plus un tour d'exploration : le modèle doit rendre
// sa réponse, avec ce qu'il a déjà vu.
let last = iteration == max_iterations;
if last {
info!(iteration, "Last turn: asking for the final answer");
messages.push(Message::new(Role::User, FINAL_PROMPT));
}
let started = Instant::now();
let response = open_router
.chat_with_tools(messages.clone(), tool_definitions.clone())
.chat_with_tools(
&messages,
&tool_definitions,
if last {
ToolChoice::None
} else {
ToolChoice::Auto
},
)
.await?;
if let Some(cost) = response.cost {
@@ -56,12 +131,44 @@ pub async fn run(
has_cost = true;
}
if response.tool_calls.is_empty() {
return Ok(AgentResult {
message: response.message.unwrap_or_default(),
cost: has_cost.then_some(total_cost),
iterations: iteration,
});
info!(
iteration,
max_iterations,
last_turn = last,
tool_calls = response.tool_calls.len(),
answer_bytes = response.message.as_deref().map_or(0, str::len),
elapsed_ms = started.elapsed().as_millis() as u64,
"Model answered"
);
// Au dernier tour, les outils sont refusés : même si le fournisseur les
// renvoie malgré tout, on ne les exécute pas et on tente la réponse.
if last || response.tool_calls.is_empty() {
let answer = response.message.unwrap_or_default();
match answer.parse::<A>() {
Ok(parsed) => {
return Ok(AgentResult {
answer: parsed,
cost: has_cost.then_some(total_cost),
iterations: iteration,
});
}
Err(err) => {
warn!(iteration, %err, "Model answer was rejected, asking for another one");
// Keep the rejected answer in the history, so that the model
// sees what it has to fix.
messages.push(Message::new(Role::Assistant, answer));
messages.push(Message::new(
Role::User,
RETRY_PROMPT.replace("{error}", &err.to_string()),
));
last_rejection = Some(err.to_string());
continue;
}
}
}
messages.push(Message::assistant_with_tool_calls(
@@ -70,28 +177,71 @@ pub async fn run(
));
for call in &response.tool_calls {
debug!(tool = call.name(), "Executing tool call");
let content = execute(sandbox, call).await;
let started = Instant::now();
let (content, truncated) = execute(sandbox, call).await;
info!(
iteration,
tool = call.name(),
arguments = %excerpt(call.arguments_json(), MAX_LOGGED_ARGUMENTS),
output_bytes = content.len(),
truncated,
// Les sauts de ligne casseraient la lisibilité d'une ligne de log.
output = %excerpt(&content, MAX_LOGGED_OUTPUT).replace('\n', " "),
elapsed_ms = started.elapsed().as_millis() as u64,
"Tool call finished"
);
messages.push(Message::tool_response(call.id(), content));
}
}
warn!(max_iterations, "Agent reached the iteration limit");
anyhow::bail!("agent exceeded the maximum number of iterations ({max_iterations})")
warn!(
max_iterations,
"Agent used its last turn without a valid answer"
);
match last_rejection {
Some(error) => anyhow::bail!(
"agent exceeded the maximum number of iterations ({max_iterations}), \
last answer rejected: {error}"
),
None => anyhow::bail!("agent exceeded the maximum number of iterations ({max_iterations})"),
}
}
async fn execute(sandbox: &Sandbox, call: &ToolCall) -> String {
/// Runs a tool call and returns its result for the model, along with whether the
/// result had to be truncated.
async fn execute(sandbox: &Sandbox, call: &ToolCall) -> (String, bool) {
let args = match parse_args(call) {
Ok(args) => args,
Err(err) => return format!("error: {err}"),
Err(err) => return cap_output(format!("error: {err}")),
};
match tools::dispatch(sandbox, call.name(), &args).await {
Ok(output) => output,
Err(err) => format!("error: {err}"),
Ok(output) => cap_output(output),
Err(err) => cap_output(format!("error: {err}")),
}
}
/// Bounds [`MAX_TOOL_OUTPUT_BYTES`] of a tool result, telling the model what was
/// dropped so that it can narrow its request.
fn cap_output(mut output: String) -> (String, bool) {
let total = output.len();
if !truncate_bytes(&mut output, MAX_TOOL_OUTPUT_BYTES) {
return (output, false);
}
let kept = output.len();
output.push_str(&format!(
"\n… output truncated: {total} bytes in total, the first {kept} are shown. \
Narrow the request (path, pattern or line range) to see the rest."
));
(output, true)
}
fn parse_args(call: &ToolCall) -> anyhow::Result<Value> {
let raw = call.arguments_json().trim();
if raw.is_empty() {
@@ -105,10 +255,22 @@ fn parse_args(call: &ToolCall) -> anyhow::Result<Value> {
#[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 +279,38 @@ 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());
}
#[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('é'));
}
}
@@ -0,0 +1,292 @@
//! 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/<number>/head`, which works
/// for both same-repository and forked pull requests.
#[instrument(skip(runtime, token), fields(pr = pull_request_number))]
pub async fn create(
runtime: &ContainerRuntime,
clone_url: &str,
token: &str,
pull_request_number: u64,
) -> anyhow::Result<Self> {
let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?;
let repo_dir = workspace.path().join("repo");
clone_pull_request(clone_url, token, pull_request_number, &repo_dir).await?;
make_readable(&repo_dir).await?;
let devcontainer = match find_devcontainer(&repo_dir) {
Some(path) => devcontainer_rs::parse(&path).await?,
None => {
info!(
"no devcontainer found in the pull request, falling back to the default image"
);
devcontainer_rs::DevContainer::default()
}
};
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<ExecOutput> {
Ok(self.container.exec(cmd).await?)
}
/// Path of the repository inside the container.
pub fn workspace_folder(&self) -> &str {
self.container.workspace_folder()
}
/// Stops and removes the container. The temporary clone is removed on drop.
pub async fn cleanup(self) -> anyhow::Result<()> {
self.container.remove().await?;
Ok(())
}
}
fn find_devcontainer(repo_dir: &Path) -> Option<PathBuf> {
DEVCONTAINER_PATHS
.iter()
.map(|relative| repo_dir.join(relative))
.find(|candidate| candidate.is_file())
}
async fn clone_pull_request(
repo_url: &str,
token: &str,
pull_request_number: u64,
dest: &Path,
) -> anyhow::Result<()> {
let dest = dest.display().to_string();
run_git(
token,
&[
"clone".to_string(),
"--depth".to_string(),
"1".to_string(),
repo_url.to_string(),
dest.clone(),
],
)
.await?;
run_git(
token,
&[
"-C".to_string(),
dest.clone(),
"fetch".to_string(),
"--depth".to_string(),
"1".to_string(),
"origin".to_string(),
format!("refs/pull/{pull_request_number}/head"),
],
)
.await?;
run_git(
token,
&[
"-C".to_string(),
dest,
"checkout".to_string(),
"FETCH_HEAD".to_string(),
],
)
.await?;
Ok(())
}
/// 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);
}
}
+2 -186
View File
@@ -7,191 +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 {
/// Whether the bot should run its tools inside a sandbox container.
pub enabled: bool,
/// Container runtime binary to drive (e.g. `docker`, `podman`).
pub runtime: ContainerRuntime,
/// Maximum number of tool-calling iterations per agent run.
pub max_iterations: usize,
}
/// A cloned repository running inside an ephemeral devcontainer.
pub struct Sandbox {
// Owns the temporary directory; dropping it cleans up the clone.
_workspace: TempDir,
container: Container,
}
impl Sandbox {
/// Clones the pull request head, builds the devcontainer and starts it.
///
/// The clone is PR-aware: it fetches `refs/pull/<number>/head`, which works
/// for both same-repository and forked pull requests.
#[instrument(skip(runtime, token), fields(pr = pull_request_number))]
pub async fn create(
runtime: &ContainerRuntime,
repo_url: &str,
token: &str,
pull_request_number: u64,
) -> anyhow::Result<Self> {
let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?;
let repo_dir = workspace.path().join("repo");
clone_pull_request(repo_url, token, pull_request_number, &repo_dir).await?;
let devcontainer_path = find_devcontainer(&repo_dir)
.with_context(|| format!("no devcontainer found in `{repo_url}`"))?;
let devcontainer = devcontainer_rs::parse(&devcontainer_path).await?;
info!(image = %devcontainer.image_tag(), "Building and starting sandbox container");
let container = devcontainer.up(runtime, &repo_dir).await?;
Ok(Self {
_workspace: workspace,
container,
})
}
/// Executes a command in the container as an argv vector (no shell).
pub async fn exec(&self, cmd: &[&str]) -> anyhow::Result<ExecOutput> {
Ok(self.container.exec(cmd).await?)
}
/// Path of the repository inside the container.
pub fn workspace_folder(&self) -> &str {
self.container.workspace_folder()
}
/// Stops and removes the container. The temporary clone is removed on drop.
pub async fn cleanup(self) -> anyhow::Result<()> {
self.container.remove().await?;
Ok(())
}
}
fn find_devcontainer(repo_dir: &Path) -> Option<PathBuf> {
DEVCONTAINER_PATHS
.iter()
.map(|relative| repo_dir.join(relative))
.find(|candidate| candidate.is_file())
}
async fn clone_pull_request(
repo_url: &str,
token: &str,
pull_request_number: u64,
dest: &Path,
) -> anyhow::Result<()> {
let dest = dest.display().to_string();
run_git(
token,
&[
"clone".to_string(),
"--depth".to_string(),
"1".to_string(),
repo_url.to_string(),
dest.clone(),
],
)
.await?;
run_git(
token,
&[
"-C".to_string(),
dest.clone(),
"fetch".to_string(),
"--depth".to_string(),
"1".to_string(),
"origin".to_string(),
format!("refs/pull/{pull_request_number}/head"),
],
)
.await?;
run_git(
token,
&[
"-C".to_string(),
dest,
"checkout".to_string(),
"FETCH_HEAD".to_string(),
],
)
.await?;
Ok(())
}
/// Runs git with the token injected through `http.extraHeader`, keeping the
/// secret out of the process arguments.
async fn run_git(token: &str, args: &[String]) -> anyhow::Result<()> {
let output = tokio::process::Command::new("git")
.args(args)
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "http.extraHeader")
.env(
"GIT_CONFIG_VALUE_0",
format!("Authorization: token {token}"),
)
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::null())
.output()
.await
.context("failed to spawn git")?;
if !output.status.success() {
anyhow::bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_devcontainer_prefers_dot_devcontainer_dir() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join(".devcontainer");
std::fs::create_dir(&nested).unwrap();
std::fs::write(nested.join("devcontainer.json"), "{}").unwrap();
std::fs::write(dir.path().join(".devcontainer.json"), "{}").unwrap();
assert_eq!(
find_devcontainer(dir.path()),
Some(nested.join("devcontainer.json"))
);
}
#[test]
fn find_devcontainer_returns_none_when_absent() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(find_devcontainer(dir.path()), None);
}
}
pub use instance::{Sandbox, SandboxConfig};
+64 -9
View File
@@ -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.
///
@@ -42,9 +41,23 @@ fn review_tools() -> Vec<Tool> {
}
}),
),
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.",
"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": {
@@ -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<String> {
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<String> {
let path = resolve(sandbox, optional_str(args, "path").unwrap_or("."))?;
let output = sandbox.exec(&["ls", "-la", "--", &path]).await?;
@@ -129,18 +149,36 @@ async fn read_file(sandbox: &Sandbox, args: &Value) -> anyhow::Result<String> {
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::<Vec<_>>()
.join("\n")
}
async fn grep(sandbox: &Sandbox, args: &Value) -> anyhow::Result<String> {
@@ -216,12 +254,12 @@ 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(),
},
repository: Repository {
full_name: "owner/repo".to_string(),
clone_url: "https://github.com/owner/repo.git".to_string(),
},
comment: Comment {
id: 1,
@@ -237,7 +275,7 @@ mod tests {
.map(|tool| tool.function.name)
.collect();
assert_eq!(names, vec!["ls", "read_file", "grep", "find"]);
assert_eq!(names, vec!["ls", "file_size", "read_file", "grep", "find"]);
}
#[test]
@@ -245,4 +283,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), "");
}
}
+81
View File
@@ -0,0 +1,81 @@
//! Small text helpers shared by the modules that log what the model answered and
//! bound what the tools return to it.
/// First `limit` characters of `text`, for logs.
///
/// Cuts on character boundaries, so the excerpt stays valid UTF-8, and marks a
/// truncation with an ellipsis.
pub fn excerpt(text: &str, limit: usize) -> String {
let mut chars = text.chars();
let excerpt = chars.by_ref().take(limit).collect::<String>();
if chars.next().is_some() {
return format!("{excerpt}…");
}
excerpt
}
/// Truncates `text` in place to at most `limit` bytes, cutting on a character
/// boundary so the result stays valid UTF-8.
///
/// Returns `true` when something was dropped.
pub fn truncate_bytes(text: &mut String, limit: usize) -> bool {
if text.len() <= limit {
return false;
}
let mut end = limit;
while !text.is_char_boundary(end) {
end -= 1;
}
text.truncate(end);
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_short_text_is_kept_as_is() {
assert_eq!(excerpt("abc", 3), "abc");
}
#[test]
fn a_long_text_is_truncated_and_marked() {
assert_eq!(excerpt("abcdef", 3), "abc…");
}
#[test]
fn truncation_cuts_on_character_boundaries() {
assert_eq!(excerpt("ééé", 2), "éé…");
}
#[test]
fn a_short_text_is_not_truncated() {
let mut text = String::from("abc");
assert!(!truncate_bytes(&mut text, 3));
assert_eq!(text, "abc");
}
#[test]
fn a_long_text_is_truncated_within_the_limit() {
let mut text = String::from("abcdef");
assert!(truncate_bytes(&mut text, 4));
assert_eq!(text, "abcd");
}
#[test]
fn byte_truncation_never_splits_a_character() {
let mut text = String::from("ééé");
// The limit falls in the middle of the second `é`: it is dropped.
assert!(truncate_bytes(&mut text, 3));
assert_eq!(text, "é");
}
}