diff --git a/AGENTS.md b/AGENTS.md index 10457f994..9b8f4f85a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,17 +32,19 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24) - The packaged compose service mounts `/var/run/docker.sock` so the server can create sibling run containers on the host daemon. This is host-root-equivalent under Docker's security model; only use it in the trusted, single-tenant deployment model described by the sandbox code/docs. - Docker and Daytona are clone-based providers. When a run manifest has a GitHub origin, they clone it into the provider workspace. Present non-GitHub origins fail unless the provider has `skip_clone = true`; absent origins or `skip_clone = true` create an empty workspace without repository files. For an exact commit, the submitted branch names the working branch and the syntactically valid SHA is requested directly. No layer proves branch/SHA ancestry: a fetchable commit is checked out, an unavailable commit fails setup, and branch HEAD is never substituted. - The sandbox layer also accepts an optional exact commit for future admitted - runs. An exact commit always requires a non-empty branch. Docker initializes - an empty repository, shallow-fetches the SHA at the same depth as a branch - clone, and checks it out; Daytona uses its official SDK clone with both - `branch` and `commit_id`. Both providers then point the admitted branch at - the commit and verify HEAD, so the workspace still reports the admitted - branch name. Keep those provider transports distinct, never fall back to a - newer branch HEAD, and do not wire this capability directly from legacy - `GitContext.sha`. The sandbox layer does not verify that the commit is - reachable from the branch; admission owns that check. Current production - callers remain branch-only until the RunIntent admission cutover supplies a - validated branch/SHA pair. + runs. An exact commit always requires a non-empty branch. The sandbox driver + performs the pin the same way on every provider: it initializes an empty + repository, fetches the SHA directly at the requested depth, and attaches + the admitted branch to it, so the workspace reports the admitted branch + name. Daytona's native toolbox clone serves plain branch clones only; its + commit pin checks the branch head out first, so the driver does not use + it. A successful clone has the pin checked out; the driver's + conformance suite verifies that on every provider, and fabro does not + re-verify HEAD. Never fall back to a newer branch HEAD, and do not wire + this capability directly from legacy `GitContext.sha`. The sandbox layer + does not verify that the commit is reachable from the branch; admission + owns that check. Current production callers remain branch-only until the + RunIntent admission cutover supplies a validated branch/SHA pair. ### Release automation - `cargo dev release` — creates the next stable release tag. Use `cargo dev release --nightly` for a nightly prerelease. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation. diff --git a/Cargo.lock b/Cargo.lock index 7c90b7d4a..bc33cf930 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2063,7 +2063,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2190,7 +2190,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2246,7 +2246,7 @@ dependencies = [ [[package]] name = "fabro-acp" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-tokio", @@ -2266,7 +2266,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "chrono", "fabro-automation", @@ -2280,6 +2280,7 @@ dependencies = [ "progenitor-client", "regress", "reqwest 0.13.4", + "sandbox-driver", "serde", "serde_json", "serde_yaml", @@ -2289,7 +2290,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2314,7 +2315,7 @@ dependencies = [ [[package]] name = "fabro-automation" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2335,11 +2336,11 @@ dependencies = [ [[package]] name = "fabro-build-support" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" [[package]] name = "fabro-checkpoint" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "chrono", "fabro-config", @@ -2355,7 +2356,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -2433,6 +2434,7 @@ dependencies = [ "reqwest 0.13.4", "ring", "rustls", + "sandbox-driver", "scopeguard", "semver", "serde", @@ -2458,7 +2460,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -2487,7 +2489,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2516,7 +2518,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "async-trait", "fabro-types", @@ -2532,7 +2534,7 @@ dependencies = [ [[package]] name = "fabro-db" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2545,7 +2547,7 @@ dependencies = [ [[package]] name = "fabro-dev" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -2564,7 +2566,7 @@ dependencies = [ [[package]] name = "fabro-dump" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -2578,7 +2580,7 @@ dependencies = [ [[package]] name = "fabro-environment" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2600,7 +2602,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2625,7 +2627,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -2640,7 +2642,7 @@ dependencies = [ [[package]] name = "fabro-hooks" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "async-trait", "fabro-auth", @@ -2665,7 +2667,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "fabro-static", "http 1.4.0", @@ -2675,7 +2677,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2694,7 +2696,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "async-trait", "dialoguer", @@ -2709,7 +2711,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2738,7 +2740,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "clap", "fabro-options-metadata", @@ -2749,7 +2751,7 @@ dependencies = [ [[package]] name = "fabro-manifest" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "fabro-api", @@ -2773,7 +2775,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -2786,7 +2788,7 @@ dependencies = [ [[package]] name = "fabro-mcp-server" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2813,7 +2815,7 @@ dependencies = [ [[package]] name = "fabro-mcp-store" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "chrono", "fabro-db", @@ -2831,7 +2833,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "axum", @@ -2853,7 +2855,7 @@ dependencies = [ [[package]] name = "fabro-options-metadata" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "serde", "serde_json", @@ -2861,7 +2863,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "cc", "libc", @@ -2870,7 +2872,7 @@ dependencies = [ [[package]] name = "fabro-redact" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "aho-corasick", "ref-cast", @@ -2886,7 +2888,7 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2900,8 +2902,6 @@ dependencies = [ "fabro-types", "fabro-util", "futures", - "hex", - "hmac 0.12.1", "pebble-coding-agent", "reqwest 0.13.4", "sandbox-driver", @@ -2914,7 +2914,6 @@ dependencies = [ "sandbox-driver-testing", "serde", "serde_json", - "sha2 0.10.9", "strum 0.28.0", "tempfile", "thiserror 2.0.18", @@ -2927,7 +2926,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2995,6 +2994,7 @@ dependencies = [ "rand 0.9.4", "regex", "reqwest 0.12.28", + "sandbox-driver", "semver", "serde", "serde_json", @@ -3024,7 +3024,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "fabro-http", "fabro-interview", @@ -3046,18 +3046,18 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "rust-embed", ] [[package]] name = "fabro-static" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" [[package]] name = "fabro-store" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "async-trait", "bytes", @@ -3090,7 +3090,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -3116,7 +3116,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -3130,7 +3130,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -3155,7 +3155,7 @@ dependencies = [ [[package]] name = "fabro-tool" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -3176,7 +3176,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -3190,7 +3190,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "chrono", "clap", @@ -3200,6 +3200,7 @@ dependencies = [ "hex", "lithos-llm", "pebble-coding-agent", + "sandbox-driver", "serde", "serde_json", "sha2 0.10.9", @@ -3214,7 +3215,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "console 0.15.11", @@ -3237,7 +3238,7 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "fabro-acp", "fabro-graphviz", @@ -3250,7 +3251,7 @@ dependencies = [ [[package]] name = "fabro-variable" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -3267,7 +3268,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -3286,7 +3287,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -3358,7 +3359,7 @@ dependencies = [ [[package]] name = "fabro-workflow-version" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "fabro-config", "fabro-graphviz", @@ -4347,7 +4348,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -5293,7 +5294,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5858,7 +5859,7 @@ dependencies = [ [[package]] name = "pebble-agent" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/pebble?rev=29da922597873afba3d9ae8c2fb73425685eda9e#29da922597873afba3d9ae8c2fb73425685eda9e" +source = "git+https://github.com/lithoscomputer/pebble?rev=9ec23d00d37faebcb2da5e3c5f2c1dfc5eec4d7f#9ec23d00d37faebcb2da5e3c5f2c1dfc5eec4d7f" dependencies = [ "async-trait", "futures-util", @@ -5875,7 +5876,7 @@ dependencies = [ [[package]] name = "pebble-cli-core" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/pebble?rev=29da922597873afba3d9ae8c2fb73425685eda9e#29da922597873afba3d9ae8c2fb73425685eda9e" +source = "git+https://github.com/lithoscomputer/pebble?rev=9ec23d00d37faebcb2da5e3c5f2c1dfc5eec4d7f#9ec23d00d37faebcb2da5e3c5f2c1dfc5eec4d7f" dependencies = [ "anyhow", "async-trait", @@ -5904,7 +5905,7 @@ dependencies = [ [[package]] name = "pebble-coding-agent" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/pebble?rev=29da922597873afba3d9ae8c2fb73425685eda9e#29da922597873afba3d9ae8c2fb73425685eda9e" +source = "git+https://github.com/lithoscomputer/pebble?rev=9ec23d00d37faebcb2da5e3c5f2c1dfc5eec4d7f#9ec23d00d37faebcb2da5e3c5f2c1dfc5eec4d7f" dependencies = [ "async-trait", "futures-util", @@ -6274,7 +6275,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -6751,7 +6752,7 @@ dependencies = [ "errno 0.3.14", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6810,7 +6811,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6855,10 +6856,11 @@ dependencies = [ [[package]] name = "sandbox-driver" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=23062b6ad62ff4665cbbcb7dce037ec9c4c34318#23062b6ad62ff4665cbbcb7dce037ec9c4c34318" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" dependencies = [ "async-trait", "globset", + "humantime", "rand 0.10.1", "serde", "serde_json", @@ -6871,13 +6873,14 @@ dependencies = [ [[package]] name = "sandbox-driver-daytona" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=23062b6ad62ff4665cbbcb7dce037ec9c4c34318#23062b6ad62ff4665cbbcb7dce037ec9c4c34318" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" dependencies = [ "anyhow", "async-trait", "base64", "daytona-api-client", "daytona-sdk", + "hmac 0.12.1", "rand 0.10.1", "reqwest 0.13.4", "sandbox-driver", @@ -6887,6 +6890,7 @@ dependencies = [ "sandbox-driver-protocol", "serde", "serde_json", + "sha2 0.10.9", "tokio", "tokio-util", "tracing", @@ -6896,7 +6900,7 @@ dependencies = [ [[package]] name = "sandbox-driver-daytona-config" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=23062b6ad62ff4665cbbcb7dce037ec9c4c34318#23062b6ad62ff4665cbbcb7dce037ec9c4c34318" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" dependencies = [ "sandbox-driver-docker-config", "serde", @@ -6906,7 +6910,7 @@ dependencies = [ [[package]] name = "sandbox-driver-docker" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=23062b6ad62ff4665cbbcb7dce037ec9c4c34318#23062b6ad62ff4665cbbcb7dce037ec9c4c34318" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" dependencies = [ "anyhow", "async-trait", @@ -6927,7 +6931,7 @@ dependencies = [ [[package]] name = "sandbox-driver-docker-config" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=23062b6ad62ff4665cbbcb7dce037ec9c4c34318#23062b6ad62ff4665cbbcb7dce037ec9c4c34318" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" dependencies = [ "serde", "serde_json", @@ -6936,7 +6940,7 @@ dependencies = [ [[package]] name = "sandbox-driver-host" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=23062b6ad62ff4665cbbcb7dce037ec9c4c34318#23062b6ad62ff4665cbbcb7dce037ec9c4c34318" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" dependencies = [ "anyhow", "async-trait", @@ -6954,7 +6958,7 @@ dependencies = [ [[package]] name = "sandbox-driver-protocol" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=23062b6ad62ff4665cbbcb7dce037ec9c4c34318#23062b6ad62ff4665cbbcb7dce037ec9c4c34318" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" dependencies = [ "async-trait", "base64", @@ -6971,7 +6975,7 @@ dependencies = [ [[package]] name = "sandbox-driver-testing" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=23062b6ad62ff4665cbbcb7dce037ec9c4c34318#23062b6ad62ff4665cbbcb7dce037ec9c4c34318" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" dependencies = [ "async-trait", "sandbox-driver", @@ -7454,7 +7458,7 @@ version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ - "errno 0.2.8", + "errno 0.3.14", "libc", ] @@ -8019,7 +8023,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8054,7 +8058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8547,7 +8551,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "axum", "base64", @@ -9121,7 +9125,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b51b85fd3..8bbbfb212 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.348.0-nightly.0" +version = "0.354.0-nightly.0" license = "MIT" [workspace.dependencies] @@ -101,28 +101,30 @@ futures-util = "0.3" # sandbox-driver: the sandbox provider layer. Bundled Host, Docker, and # Daytona providers link in-process; third-party providers run as stdio # plugins through sandbox-driver-protocol. Pinned by rev; currently the head of -# the sandbox-driver PR stack #9-#15 (configured plugin kind, tag pins, classified -# git failures, stop grace, snapshot ensure, ownership scope, testing doubles), to -# move to main on merge. The CI plugin job installs the driver executables at the -# same rev, read from this file. +# the sandbox-driver `section-4-driver-items` branch (provider-owned scopes, the +# supervisor as provider, Host attach by directory, git retry and verbs in the +# driver, status image/snapshot/network, Daytona snapshot caching, services port +# wait and list, RFC 3339 timestamps), to move to main on merge. The CI plugin +# job installs the driver executables at the same rev, read from this file. +sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-docker = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-docker-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-daytona = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-daytona-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } # pebble: the coding agent loop fabro runs its agent stages, Ask Fabro -# sessions, hook evaluators, and `fabro exec` on. Pinned by rev to the pebble -# branch `embedder-concerns`, which merges the live exec output sink and -# `max_turns` into pebble's main (so fabro and petri pin one line); re-pin to -# the merge commit once it lands. Pebble pins the same lithos-llm rev as -# fabro, and its lockfile policy is that every shared crate resolves to the -# version lithos-llm locks. -pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "29da922597873afba3d9ae8c2fb73425685eda9e" } -pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "29da922597873afba3d9ae8c2fb73425685eda9e", features = ["mcp", "search-providers"] } -pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "29da922597873afba3d9ae8c2fb73425685eda9e" } -sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "23062b6ad62ff4665cbbcb7dce037ec9c4c34318" } -sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "23062b6ad62ff4665cbbcb7dce037ec9c4c34318" } -sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "23062b6ad62ff4665cbbcb7dce037ec9c4c34318" } -sandbox-driver-docker = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "23062b6ad62ff4665cbbcb7dce037ec9c4c34318" } -sandbox-driver-docker-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "23062b6ad62ff4665cbbcb7dce037ec9c4c34318" } -sandbox-driver-daytona = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "23062b6ad62ff4665cbbcb7dce037ec9c4c34318" } -sandbox-driver-daytona-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "23062b6ad62ff4665cbbcb7dce037ec9c4c34318" } -sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "23062b6ad62ff4665cbbcb7dce037ec9c4c34318" } +# sessions, hook evaluators, and `fabro exec` on. Pinned by rev to pebble's +# `sandbox-driver-section-4` branch (lithoscomputer/pebble#10), which is +# pebble main plus the sandbox-driver pin below: the `PreviewUrls` trait +# objects fabro hands pebble's MCP servers only cross when both sides name +# one sandbox-driver revision. Re-pin to main once that lands. Pebble pins +# the same lithos-llm rev as fabro, and its lockfile policy is that every +# shared crate resolves to the version lithos-llm locks. +pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "9ec23d00d37faebcb2da5e3c5f2c1dfc5eec4d7f" } +pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "9ec23d00d37faebcb2da5e3c5f2c1dfc5eec4d7f", features = ["mcp", "search-providers"] } +pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "9ec23d00d37faebcb2da5e3c5f2c1dfc5eec4d7f" } sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] } fork = "0.2" exec = "0.3" diff --git a/apps/fabro-web/app/components/run-summary-panel.test.tsx b/apps/fabro-web/app/components/run-summary-panel.test.tsx index 018184802..5ed6eca29 100644 --- a/apps/fabro-web/app/components/run-summary-panel.test.tsx +++ b/apps/fabro-web/app/components/run-summary-panel.test.tsx @@ -174,7 +174,7 @@ describe("RunSummaryPanelView", () => { const tree = render({ run: makeRun(), sandboxState: "running", - sandboxResources: { cpu_cores: 4, memory_bytes: 8 * 1024 * 1024 * 1024 } as any, + sandboxResources: { cpu_cores: 4, memory_mb: 8 * 1024 }, }); expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("4 CPU · 8 GiB"); }); diff --git a/apps/fabro-web/app/components/run-summary-panel.tsx b/apps/fabro-web/app/components/run-summary-panel.tsx index c3784a9ba..b462402a3 100644 --- a/apps/fabro-web/app/components/run-summary-panel.tsx +++ b/apps/fabro-web/app/components/run-summary-panel.tsx @@ -80,10 +80,10 @@ function SandboxValue({ }) { const display = SANDBOX_STATE_DISPLAY[state] ?? SANDBOX_STATE_DISPLAY.unknown; const cpu = resources?.cpu_cores; - const memory = resources?.memory_bytes; + const memoryMb = resources?.memory_mb; const valueText = - cpu != null && memory != null - ? `${formatCpuCores(cpu)} CPU · ${formatBytesAsMemory(memory)}` + cpu != null && memoryMb != null + ? `${formatCpuCores(cpu)} CPU · ${formatBytesAsMemory(memoryMb * 1024 * 1024)}` : display.label; return ( @@ -221,8 +221,8 @@ export function RunSummaryPanel({ runId }: { runId: string }) { = { unknown: { label: "Unknown", description: "The sandbox state could not be determined.", - dot: "bg-fg-muted", - text: "text-fg-muted", + ...QUIET, }, - provisioning: { - label: "Provisioning", - description: "The sandbox is being provisioned.", - dot: "bg-amber", - text: "text-amber", + creating: { + label: "Creating", + description: "The sandbox is being created.", + ...PENDING, }, starting: { label: "Starting", description: "The sandbox is starting up.", - dot: "bg-amber", - text: "text-amber", + ...PENDING, }, running: { label: "Running", @@ -44,55 +46,71 @@ export const SANDBOX_STATE_DISPLAY: Record = stopping: { label: "Stopping", description: "The sandbox is shutting down.", - dot: "bg-amber", - text: "text-amber", + ...PENDING, }, stopped: { label: "Stopped", description: "The sandbox is stopped.", - dot: "bg-fg-muted", - text: "text-fg-muted", + ...QUIET, + }, + pausing: { + label: "Pausing", + description: "The sandbox is being paused.", + ...PENDING, }, paused: { label: "Paused", description: "The sandbox is paused.", - dot: "bg-amber", - text: "text-amber", + ...PENDING, }, - deleting: { - label: "Deleting", - description: "The sandbox is being deleted.", - dot: "bg-amber", - text: "text-amber", + resuming: { + label: "Resuming", + description: "The sandbox is resuming.", + ...PENDING, }, - deleted: { - label: "Deleted", - description: "The sandbox has been deleted.", - dot: "bg-coral", - text: "text-coral", + archiving: { + label: "Archiving", + description: "The sandbox is being archived.", + ...PENDING, }, archived: { label: "Archived", description: "The sandbox has been archived.", - dot: "bg-fg-muted", - text: "text-fg-muted", + ...QUIET, }, restoring: { label: "Restoring", description: "The sandbox is being restored.", - dot: "bg-amber", - text: "text-amber", + ...PENDING, }, resizing: { label: "Resizing", description: "The sandbox resources are being resized.", - dot: "bg-amber", - text: "text-amber", + ...PENDING, + }, + forking: { + label: "Forking", + description: "The sandbox is being forked.", + ...PENDING, + }, + snapshotting: { + label: "Snapshotting", + description: "A snapshot of the sandbox is being taken.", + ...PENDING, + }, + deleting: { + label: "Deleting", + description: "The sandbox is being deleted.", + ...PENDING, + }, + deleted: { + label: "Deleted", + description: "The sandbox has been deleted.", + ...GONE, }, error: { label: "Error", description: "The sandbox encountered an error.", - dot: "bg-coral", - text: "text-coral", + ...GONE, }, }; diff --git a/apps/fabro-web/app/routes/run-sandbox.test.tsx b/apps/fabro-web/app/routes/run-sandbox.test.tsx index 958d0a217..8772f9283 100644 --- a/apps/fabro-web/app/routes/run-sandbox.test.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.test.tsx @@ -103,14 +103,15 @@ mock.restore(); const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; function sandboxDetails( - overrides: Partial & { + overrides: { sandbox?: Partial & { runtime?: Partial>; }; + status?: Partial; } = {}, ): SandboxDetails { const sandbox = overrides.sandbox ?? {}; - const { sandbox: _sandboxOverride, ...detailOverrides } = overrides; + const status = overrides.status ?? {}; return { sandbox: { provider: "docker", @@ -126,34 +127,27 @@ function sandboxDetails( }, ...sandbox, }, - state: "running", - native_state: null, - region: null, - resources: { cpu_cores: null, memory_bytes: null, disk_bytes: null }, - network: networkDetails(), - labels: {}, - timestamps: { created_at: null, last_activity_at: null }, - ...detailOverrides, + status: { + id: sandbox.runtime?.id ?? "", + state: "running", + provider_state: "", + error_reason: null, + resources: null, + sandbox_kind: null, + region: null, + labels: {}, + image: null, + snapshot: null, + network: null, + workspace_ownership: null, + web_url: null, + created_at: null, + updated_at: null, + ...status, + }, }; } -function networkDetails( - overrides: Partial = {}, -): SandboxDetails["network"] { - return { - egress: networkPolicy("unknown"), - ingress: networkPolicy("unknown"), - ...overrides, - }; -} - -function networkPolicy( - mode: SandboxDetails["network"]["egress"]["mode"], - cidrs: string[] = [], -): SandboxDetails["network"]["egress"] { - return { mode, cidrs }; -} - function textContent(renderer: TestRenderer.ReactTestRenderer): string { return renderer.root .findAll((node) => typeof node.type === "string") @@ -230,22 +224,18 @@ describe("RunSandbox route", () => { working_directory: "/workspace", }, }, - state: "running", - native_state: "running", - region: undefined, - resources: { - cpu_cores: 2, - memory_bytes: 4 * 1024 * 1024 * 1024, - disk_bytes: undefined, - }, - network: networkDetails({ - egress: networkPolicy("open"), - ingress: networkPolicy("blocked"), - }), - labels: { run: "abc" }, - timestamps: { - created_at: "2026-05-09T12:00:00Z", - last_activity_at: undefined, + status: { + state: "running", + provider_state: "running", + resources: { + cpu_cores: 2, + memory_mb: 4 * 1024, + disk_mb: null, + gpus: null, + }, + network: "allow_all", + labels: { run: "abc" }, + created_at: "2026-05-09T12:00:00Z", }, }); const renderer = renderRoute(); @@ -256,8 +246,8 @@ describe("RunSandbox route", () => { .filter((text): text is string => typeof text === "string"); expect(panelHeadings).toEqual(["Overview", "Resources", "Network", "Labels", "Timestamps"]); const copy = textContent(renderer); - expect(copy).toContain("Open"); - expect(copy).toContain("Blocked"); + expect(copy).toContain("Allow all"); + expect(copy).toContain("4 GiB"); }); test("links to the provider dashboard when a sandbox web URL is present", () => { @@ -269,8 +259,10 @@ describe("RunSandbox route", () => { working_directory: "/workspace", }, }, - web_url: - "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9", + status: { + web_url: + "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9", + }, }); const renderer = renderRoute(); @@ -296,18 +288,12 @@ describe("RunSandbox route", () => { working_directory: "/tmp/project", }, }, - state: "unknown", - native_state: undefined, - region: undefined, - resources: { - cpu_cores: undefined, - memory_bytes: undefined, - disk_bytes: undefined, - }, - labels: {}, - timestamps: { - created_at: undefined, - last_activity_at: undefined, + status: { + state: "unknown", + resources: { cpu_cores: null, memory_mb: null, disk_mb: null, gpus: null }, + labels: {}, + created_at: null, + updated_at: null, }, }); const renderer = renderRoute(); @@ -328,35 +314,29 @@ describe("RunSandbox route", () => { expect(noLabelsCopy).toHaveLength(1); }); - test("renders unknown network policies", () => { - currentDetails = sandboxDetails({ - network: networkDetails({ - egress: networkPolicy("unknown"), - ingress: networkPolicy("unknown"), - }), - }); + test("renders an unknown network policy", () => { + currentDetails = sandboxDetails({ status: { network: null } }); const renderer = renderRoute(); const copy = textContent(renderer); expect(copy).toContain("Network"); - expect(copy).toContain("Egress"); - expect(copy).toContain("Ingress"); + expect(copy).toContain("Policy"); expect(copy).toContain("Unknown"); }); - test("renders blocked, essentials, and CIDR network policies", () => { + test("renders blocked and CIDR allow list network policies", () => { currentDetails = sandboxDetails({ - network: networkDetails({ - egress: networkPolicy("cidr_allow_list", ["10.0.0.0/8", "192.168.0.0/16"]), - ingress: networkPolicy("essentials_only"), - }), + status: { network: { cidr_allow_list: { cidrs: ["10.0.0.0/8", "192.168.0.0/16"] } } }, }); const renderer = renderRoute(); const copy = textContent(renderer); expect(copy).toContain("CIDR allow list"); expect(copy).toContain("10.0.0.0/8, 192.168.0.0/16"); - expect(copy).toContain("Essentials only"); + + currentDetails = sandboxDetails({ status: { network: "block" } }); + const blocked = renderRoute(); + expect(textContent(blocked)).toContain("Blocked"); }); test("shows the empty state when no sandbox is reported", () => { diff --git a/apps/fabro-web/app/routes/run-sandbox.tsx b/apps/fabro-web/app/routes/run-sandbox.tsx index d190bd5e0..c93a3f139 100644 --- a/apps/fabro-web/app/routes/run-sandbox.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.tsx @@ -22,7 +22,7 @@ import { SANDBOX_STATE_DISPLAY } from "../lib/sandbox-state"; import type { RunSandbox, SandboxDetails, - SandboxNetwork, + SandboxNetworkPolicy, SandboxResources, } from "@qltysh/fabro-api-client"; import FilesystemPanel from "./run-sandbox/filesystem-panel"; @@ -57,27 +57,47 @@ function nullableTimestamp(value: string | null | undefined): string { return value ? formatAbsoluteTs(value) : EMPTY_VALUE; } -function nullableMemory(bytes: number | null | undefined): string { - return bytes != null ? formatBytesAsMemory(bytes) : EMPTY_VALUE; +function nullableMegabytes(megabytes: number | null | undefined): string { + return megabytes != null ? formatBytesAsMemory(megabytes * 1024 * 1024) : EMPTY_VALUE; } function nullableCpu(cores: number | null | undefined): string { return cores != null ? formatCpuCores(cores) : EMPTY_VALUE; } -type SandboxNetworkPolicy = SandboxNetwork["egress"]; -type SandboxNetworkPolicyMode = SandboxNetworkPolicy["mode"]; +function nullableCount(count: number | null | undefined): string { + return count != null ? String(count) : EMPTY_VALUE; +} -const NETWORK_POLICY_DISPLAY: Record = { - unknown: "Unknown", - open: "Open", - blocked: "Blocked", - cidr_allow_list: "CIDR allow list", - essentials_only: "Essentials only", +const NETWORK_POLICY_DISPLAY: Record = { + provider_default: "Provider default", + allow_all: "Allow all", + block: "Blocked", }; -function networkPolicySummary(policy: SandboxNetworkPolicy): string { - return NETWORK_POLICY_DISPLAY[policy.mode] ?? policy.mode; +/** The policy's name, and the entries of an allow list when it carries one. */ +function describeNetworkPolicy( + policy: SandboxNetworkPolicy | null | undefined, +): { summary: string; entries: { label: string; values: string[] } | null } { + if (policy == null) { + return { summary: "Unknown", entries: null }; + } + if (typeof policy === "string") { + return { summary: NETWORK_POLICY_DISPLAY[policy] ?? policy, entries: null }; + } + if ("cidr_allow_list" in policy) { + return { + summary: "CIDR allow list", + entries: { label: "Allowed CIDRs", values: policy.cidr_allow_list.cidrs }, + }; + } + if ("domain_allow_list" in policy) { + return { + summary: "Domain allow list", + entries: { label: "Allowed domains", values: policy.domain_allow_list.domains }, + }; + } + return { summary: "Unknown", entries: null }; } interface RowProps { @@ -142,11 +162,12 @@ function Panel({ title, children }: PanelProps) { } function StatusStrip({ details }: { details: SandboxDetails }) { - const display = SANDBOX_STATE_DISPLAY[details.state] ?? SANDBOX_STATE_DISPLAY.unknown; + const status = details.status; + const display = SANDBOX_STATE_DISPLAY[status.state] ?? SANDBOX_STATE_DISPLAY.unknown; const provider = details.sandbox.provider; + const providerState = status.provider_state ?? ""; const showNative = - details.native_state && - details.native_state.toLowerCase() !== details.state.toLowerCase(); + providerState.length > 0 && providerState.toLowerCase() !== status.state.toLowerCase(); return (
@@ -158,7 +179,7 @@ function StatusStrip({ details }: { details: SandboxDetails }) { {showNative && ( - ({details.native_state}) + ({providerState}) )}
@@ -167,20 +188,25 @@ function StatusStrip({ details }: { details: SandboxDetails }) { function OverviewPanel({ details }: { details: SandboxDetails }) { const sandbox = details.sandbox; + const status = details.status; const runtime = sandbox.runtime; return ( - + - - {details.web_url && ( + + {status.sandbox_kind && } + {status.web_url && ( - - - + + + + {resources?.gpus != null && } ); } -function NetworkPanel({ network }: { network: SandboxNetwork }) { - const cidrRows: Array<{ label: string; policy: SandboxNetworkPolicy }> = [ - { label: "Egress CIDRs", policy: network.egress }, - { label: "Ingress CIDRs", policy: network.ingress }, - ].filter(({ policy }) => policy.mode === "cidr_allow_list"); - +function NetworkPanel({ network }: { network: SandboxNetworkPolicy | null | undefined }) { + const { summary, entries } = describeNetworkPolicy(network); return ( - - - {cidrRows.map(({ label, policy }) => ( - - ))} + + {entries && ( + + )} ); } @@ -237,11 +259,8 @@ function LabelsPanel({ labels }: { labels: { [key: string]: string } | null | un function TimestampsPanel({ details }: { details: SandboxDetails }) { return ( - - + + ); } @@ -259,9 +278,9 @@ function DetailsColumn({ details }: { details: SandboxDetails | null }) {
- - - + + +
); diff --git a/apps/fabro-web/app/routes/run-sandbox/services-panel.test.tsx b/apps/fabro-web/app/routes/run-sandbox/services-panel.test.tsx index 38403196e..a20c95a49 100644 --- a/apps/fabro-web/app/routes/run-sandbox/services-panel.test.tsx +++ b/apps/fabro-web/app/routes/run-sandbox/services-panel.test.tsx @@ -26,10 +26,7 @@ function makeIdlePreview(): PreviewMutationShape { } function makeServicesData(data: SandboxService[]) { - return { - data, - meta: { source: "ss" as const }, - }; + return { data }; } const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; @@ -116,46 +113,6 @@ describe("ServicesPanelView", () => { expect(titles).toHaveLength(1); }); - test("shows an iproute2 tip when services were discovered from procfs", () => { - const service: SandboxService = { - port: 3000, - addresses: ["0.0.0.0:3000"], - processes: [], - preview_supported: true, - }; - const renderer = renderView({ - servicesQuery: { - ...makeIdleQuery(), - data: { - data: [service], - meta: { source: "procfs" }, - }, - }, - previewMutation: makeIdlePreview(), - }); - - const tipLabels = renderer.root.findAll( - (node) => - node.type === "span" && - Array.isArray(node.children) && - node.children.includes("Tip:"), - ); - expect(tipLabels).toHaveLength(1); - - const commands = renderer.root.findAll( - (node) => - node.type === "code" && - Array.isArray(node.children) && - node.children.includes("apt-get install iproute2"), - ); - expect(commands).toHaveLength(1); - - const tipText = JSON.stringify(renderer.toJSON()); - expect(tipText).toContain("Install "); - expect(tipText).toContain("ss"); - expect(tipText).toContain(" in the sandbox for improved services listing:"); - }); - test("shows API error state with the error message", () => { const renderer = renderView({ servicesQuery: { diff --git a/apps/fabro-web/app/routes/run-sandbox/services-panel.tsx b/apps/fabro-web/app/routes/run-sandbox/services-panel.tsx index 313cac6ba..400cb6365 100644 --- a/apps/fabro-web/app/routes/run-sandbox/services-panel.tsx +++ b/apps/fabro-web/app/routes/run-sandbox/services-panel.tsx @@ -77,7 +77,6 @@ export function ServicesPanelView({ const [previewError, setPreviewError] = useState(null); const services = servicesQuery.data?.data ?? []; - const discoverySource = servicesQuery.data?.meta.source; const queryErrorMessage = describeQueryError(servicesQuery.error); const showLoading = servicesQuery.isLoading && !servicesQuery.data; const showError = queryErrorMessage !== null && !servicesQuery.data; @@ -150,7 +149,6 @@ export function ServicesPanelView({ ) : ( <> - {discoverySource === "procfs" ? : null} - Tip:{" "} - Install ss in the sandbox - for improved services listing:{" "} - apt-get install iproute2 - - ); -} - function ServicesTable({ services, pendingPort, diff --git a/docs/internal/events-strategy.md b/docs/internal/events-strategy.md index b3aea81c0..ad7a63823 100644 --- a/docs/internal/events-strategy.md +++ b/docs/internal/events-strategy.md @@ -126,10 +126,15 @@ Never build the same `RunEvent` twice if multiple sinks receive it. ### 1. Add the typed event Add a variant to `Event`, `AgentEvent`, or `SandboxLifecycle` as appropriate. Sandbox -lifecycle facts come from two places: the pipeline emits `Initializing`, `Ready`, and -`InitializeFailed` around bringing the sandbox up, and `SandboxEventBridge` (in the -`fabro-workflow::event` module) translates the sandbox driver's own events — start, -stop, delete, image pulls, snapshot builds — into the rest. Fabro-sandbox emits no +facts come from two places: the pipeline emits `Initializing`, `Ready`, and +`InitializeFailed` around bringing the sandbox up, and the sandbox driver's own events +(operations and their outcome, progress inside a create such as an image pull, snapshot +builds, state observations, notices) are stored whole as `Event::SandboxDriver` by the +`DriverEventRecorder` in the `fabro-workflow::event` module. Their names derive from the +event (`fabro_types::sandbox_driver_event_name`): `..` such as +`sandbox.stop.completed` or `snapshot.create.started`, `.state`, and +`.notice`; their `properties` are the driver's event as the driver serializes +it, so the driver's `Event` is part of fabro's stored format. Fabro-sandbox emits no events of its own. ### 2. Add tracing diff --git a/docs/internal/events.md b/docs/internal/events.md index b2f9d7ad0..c58f041d2 100644 --- a/docs/internal/events.md +++ b/docs/internal/events.md @@ -1807,82 +1807,52 @@ Emitted after the engine completes sandbox initialization (distinct from `sandbo | `provider` | string | Sandbox provider name | | `error` | string | Error message | -### `sandbox.snapshot.pulling` +### Sandbox driver events -Emitted only when the Docker image cache misses and Fabro starts pulling the image. +Everything the sandbox driver reports about a run's sandbox is stored whole. The +event name derives from the driver's event: `..` for an +operation (`sandbox.start.started`, `sandbox.stop.completed`, `sandbox.delete.failed`, +`sandbox.create.progress` for an image pull inside the create, `snapshot.create.started` +and `snapshot.create.completed` for a snapshot build), `.state` for a state +observation, and `.notice` for a notice. `properties` is the driver's event as +the driver serializes it. ```json { "id": "...", "ts": "...", "run_id": "...", - "event": "sandbox.snapshot.pulling", + "event": "sandbox.stop.completed", "properties": { - "name": "my-image:latest" + "id": {"source_id": "9b2f…", "sequence": 4}, + "occurred_at": "2026-08-31T20:00:00Z", + "provider": "docker", + "subject": {"type": "sandbox", "id": "container-abc123"}, + "operation_id": "58a1…", + "correlation_id": "01JQ…", + "type": "operation_completed", + "action": "stop", + "duration": {"secs": 1, "nanos": 250000000} } } ``` | Property | Type | Description | |----------|------|-------------| -| `name` | string | Image/snapshot name | +| `id` | object | The driver's event id: `source_id` and `sequence` within that source | +| `occurred_at` | string | When the driver observed the event (RFC 3339) | +| `provider` | string | The driver's provider kind (`host`, `docker`, `daytona`, a plugin's kind) | +| `subject` | object | `type` (`sandbox`, `snapshot`, `volume`, `provider`) with the resource's `id` and `name` when known | +| `operation_id` | string | Groups the started, progress, and completed or failed events of one operation | +| `correlation_id` | string | The run id fabro attached | +| `type` | string | `operation_started`, `operation_progress`, `operation_completed`, `operation_failed`, `state_observed`, or `notice` | +| `action` | string | The operation (`create`, `start`, `stop`, `delete`, `snapshot`, …) on operation events | +| `progress` | object | `code` (`image.pull`, `snapshot.build`, …), `message`, and optional `completed`, `total`, `unit` on progress events | +| `duration` | object | `secs` and `nanos` on completed and failed events | +| `error` | object | `kind`, `message`, `retryable`, `causes` on failed events | -### `sandbox.snapshot.creating` - -Emitted only when a Daytona snapshot cache miss or inactive snapshot requires Fabro to create or wait for the snapshot. - -```json -{ - "id": "...", "ts": "...", "run_id": "...", - "event": "sandbox.snapshot.creating", - "properties": { - "name": "my-snapshot" - } -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `name` | string | Snapshot name | - -### `sandbox.snapshot.ready` - -Emitted when an image or snapshot ensure step succeeds. Cache hits still emit this event with a near-zero `duration_ms`; explicit no-op paths such as Docker `auto_pull = false` and the Daytona default snapshot path do not. - -```json -{ - "id": "...", "ts": "...", "run_id": "...", - "event": "sandbox.snapshot.ready", - "properties": { - "name": "my-snapshot", - "duration_ms": 30000 - } -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `name` | string | Snapshot name | -| `duration_ms` | number | Ensure duration | - -### `sandbox.snapshot.failed` - -Emitted when an image or snapshot ensure step fails. - -```json -{ - "id": "...", "ts": "...", "run_id": "...", - "event": "sandbox.snapshot.failed", - "properties": { - "name": "my-snapshot", - "error": "disk quota exceeded" - } -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `name` | string | Snapshot name | -| `error` | string | Error message | -| `causes` | string[] | Optional error cause chain | +Events stored under `sandbox.start.*`, `sandbox.stop.*`, `sandbox.delete.*`, and +`sandbox.snapshot.*` before the driver's events were kept whole carry fabro's earlier +`provider`, `name`, `duration_ms`, and `error` properties instead; readers treat them as +unknown bodies. ### `sandbox.git.started` diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 380efd09e..9ec0dbb2d 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -3907,7 +3907,7 @@ paths: operationId: retrieveRunSandbox tags: [Human-in-the-Loop] summary: Retrieve Run Sandbox Details - description: Returns provider-neutral details about the sandbox owned by this run, including identity, normalized state, image/snapshot, resources, labels, and timestamps. + description: Returns the sandbox owned by this run as fabro's record of it plus the sandbox driver's status (identity, state, image or snapshot, resources, network policy, labels, and timestamps). parameters: - $ref: "#/components/parameters/RunId" responses: @@ -13864,179 +13864,182 @@ components: example: docker exec -it fabro-run-01HY0000000000000000000000 sh -lc 'cd /workspace/fabro && exec sh -l' SandboxState: - description: Normalized sandbox lifecycle state used by the control plane and UI. The original provider-specific state string is preserved in `native_state`. + description: The sandbox driver's lifecycle state for a sandbox. The provider's own state string is preserved in `SandboxStatus.provider_state`. A reader must treat a value it does not know as `unknown`. type: string enum: - - unknown - - provisioning + - creating - starting - running - stopping - stopped + - pausing - paused - - deleting - - deleted + - resuming + - archiving - archived - restoring - resizing + - forking + - snapshotting + - deleting + - deleted - error + - unknown + + SandboxKind: + description: The kind of isolation a sandbox was provisioned with, as observed by the driver. Not an isolation guarantee. + type: string + enum: + - container + - virtual_machine + - unknown + + SandboxWorkspaceOwnership: + description: Who owns a local sandbox's workspace directory. `designated` is a caller-owned directory that deleting the sandbox never touches; `managed` is a directory the driver created and removes. + type: string + enum: + - designated + - managed SandboxResources: - description: Resource configuration for a sandbox. Fields are nullable when the provider does not surface a value or no limit is configured. + description: Compute resources of a sandbox, in the units the field names give. A field is null when the provider does not report a value or applies its default. type: object properties: cpu_cores: - type: number - format: double - description: Configured CPU cores. Null when unavailable. - memory_bytes: - type: integer + type: ["integer", "null"] format: int64 minimum: 0 - description: Memory limit in bytes. Null when unavailable or unlimited. - disk_bytes: - type: integer + memory_mb: + type: ["integer", "null"] + format: int64 + minimum: 0 + disk_mb: + type: ["integer", "null"] + format: int64 + minimum: 0 + gpus: + type: ["integer", "null"] format: int64 minimum: 0 - description: Disk size in bytes. Null when unavailable. - - SandboxNetworkPolicyMode: - description: Provider-neutral public-network policy for one direction. - type: string - enum: - - unknown - - open - - blocked - - cidr_allow_list - - essentials_only SandboxNetworkPolicy: - description: Public-network policy for one direction. + description: The network policy in force for a sandbox. A policy without parameters is its name; an allow list carries its entries. + oneOf: + - type: string + enum: + - provider_default + - allow_all + - block + - type: object + required: [cidr_allow_list] + properties: + cidr_allow_list: + type: object + required: [cidrs] + properties: + cidrs: + type: array + items: + type: string + - type: object + required: [domain_allow_list] + properties: + domain_allow_list: + type: object + required: [domains] + properties: + domains: + type: array + items: + type: string + + SandboxStatus: + description: What the sandbox driver reports about a sandbox. Only `id` and `state` are always present; every other field is null or empty when the provider does not report it. type: object required: - - mode - - cidrs + - id + - state properties: - mode: - $ref: "#/components/schemas/SandboxNetworkPolicyMode" - cidrs: - type: array - items: + id: + type: string + description: The provider's stable identifier for the sandbox. + name: + type: ["string", "null"] + description: The provider's display name, which is not the stable identifier. + state: + $ref: "#/components/schemas/SandboxState" + provider_state: + type: string + default: "" + description: The provider's own state string, for display and debugging. + error_reason: + type: ["string", "null"] + resources: + oneOf: + - $ref: "#/components/schemas/SandboxResources" + - type: "null" + sandbox_kind: + oneOf: + - $ref: "#/components/schemas/SandboxKind" + - type: "null" + region: + type: ["string", "null"] + description: The provider region or target the sandbox runs in. + labels: + type: object + additionalProperties: type: string - description: CIDR entries when `mode` is `cidr_allow_list`; empty for other modes. - - SandboxNetwork: - description: Provider-neutral public-network policy for sandbox egress and ingress. - type: object - required: - - egress - - ingress - properties: - egress: - $ref: "#/components/schemas/SandboxNetworkPolicy" - ingress: - $ref: "#/components/schemas/SandboxNetworkPolicy" - - SandboxTimestamps: - description: Lifecycle timestamps for a sandbox. Fields are nullable when the provider does not surface a value. - type: object - properties: + description: Provider-stored labels, including fabro's ownership labels. + image: + type: ["string", "null"] + description: The image the sandbox runs, when the provider knows it (a Docker container's image reference). + snapshot: + type: ["string", "null"] + description: The snapshot the sandbox was created from, when the provider knows it (a Daytona snapshot name). + network: + oneOf: + - $ref: "#/components/schemas/SandboxNetworkPolicy" + - type: "null" + description: The network policy in force, when the provider can read it back. + workspace_ownership: + oneOf: + - $ref: "#/components/schemas/SandboxWorkspaceOwnership" + - type: "null" + description: Local sandboxes only. + web_url: + type: ["string", "null"] + description: The provider's console page for the sandbox, when it has one. created_at: - type: string + type: ["string", "null"] format: date-time - description: When the sandbox was created. - last_activity_at: - type: string + updated_at: + type: ["string", "null"] format: date-time - description: Most recent activity timestamp reported by the provider. + description: The provider's most recent activity or update timestamp for the sandbox. SandboxDetails: - description: Provider-neutral details about the sandbox owned by a run. + description: The sandbox owned by a run, as fabro's record of it and the sandbox driver's status. type: object required: - sandbox - - state - - resources - - network - - labels - - timestamps + - status properties: sandbox: $ref: "#/components/schemas/RunSandboxInstance" - state: - $ref: "#/components/schemas/SandboxState" - native_state: - type: ["string", "null"] - description: Original provider state string before normalization. Display/debugging only; UI behavior keys off `state`. - region: - type: ["string", "null"] - description: Provider region or target. Null for local-style providers. - web_url: - type: ["string", "null"] - description: Provider dashboard URL for this sandbox when available. - resources: - $ref: "#/components/schemas/SandboxResources" - network: - $ref: "#/components/schemas/SandboxNetwork" - labels: - type: object - additionalProperties: - type: string - description: Provider-reported labels. - timestamps: - $ref: "#/components/schemas/SandboxTimestamps" + status: + $ref: "#/components/schemas/SandboxStatus" SandboxInfo: - description: Provider-backed inventory record for a Fabro-managed sandbox. + description: One sandbox of fabro's provider-backed inventory, as the provider fabro connected it through and the sandbox driver's status. type: object required: - provider - - id - - state - - resources - - network - - labels - - timestamps + - status properties: provider: $ref: "#/components/schemas/SandboxProviderKind" - id: - type: string - description: Provider-native sandbox id. - display_name: - type: ["string", "null"] - description: Provider display name when distinct from the native id. - state: - $ref: "#/components/schemas/SandboxState" - native_state: - type: ["string", "null"] - description: Original provider state string before normalization. Display/debugging only; UI behavior keys off `state`. - image: - type: ["string", "null"] - description: Provider image when surfaced by the sandbox provider. - snapshot: - type: ["string", "null"] - description: Provider snapshot when surfaced by the sandbox provider. - region: - type: ["string", "null"] - description: Provider region or target. Null for local-style providers. - web_url: - type: ["string", "null"] - description: Provider dashboard URL for this sandbox when available. - working_directory: - type: ["string", "null"] - description: Provider-reported or Fabro-default working directory when available. - resources: - $ref: "#/components/schemas/SandboxResources" - network: - $ref: "#/components/schemas/SandboxNetwork" - labels: - type: object - additionalProperties: - type: string - description: Provider-reported labels. - timestamps: - $ref: "#/components/schemas/SandboxTimestamps" + status: + $ref: "#/components/schemas/SandboxStatus" SandboxProviderLookupError: description: Provider error captured during fail-soft sandbox inventory lookup. @@ -14105,7 +14108,7 @@ components: $ref: "#/components/schemas/SandboxFileEntry" SandboxService: - description: A listening TCP service discovered inside a run sandbox. + description: A TCP port a process inside a run sandbox listens on, as the sandbox driver reports it. type: object required: - port @@ -14121,16 +14124,16 @@ components: example: 3000 addresses: type: array - description: Local bind addresses discovered from `ss` or `/proc/net/tcp*`. + description: Local bind addresses the sandbox reports for the port. items: type: string example: ["127.0.0.1:3000", "[::]:3000"] processes: type: array - description: Visible process summaries when available. Empty when the sandbox only supports `/proc/net/tcp*` discovery. + description: The listening processes, when the sandbox can name them (`node`, or `pid=1234`). Empty when it cannot. items: type: string - example: ['users:(("node",pid=42,fd=23))'] + example: ["node"] preview_supported: type: boolean description: Whether the provider supports an external preview URL for this port. @@ -14141,30 +14144,11 @@ components: type: object required: - data - - meta properties: data: type: array items: $ref: "#/components/schemas/SandboxService" - meta: - $ref: "#/components/schemas/SandboxServiceListMeta" - - SandboxServiceListMeta: - description: Metadata about sandbox service discovery. - type: object - required: - - source - properties: - source: - $ref: "#/components/schemas/SandboxServiceDiscoverySource" - - SandboxServiceDiscoverySource: - description: Tool or kernel interface used to discover sandbox services. - type: string - enum: - - ss - - procfs VncPreviewResponse: description: Response containing a signed noVNC preview URL for a Daytona sandbox. @@ -14966,9 +14950,10 @@ components: type: boolean default: false description: | - When true, Fabro-managed run-branch checkpoint commits bypass - local Git commit hooks. Does not affect Fabro `[[run.hooks]]` - or metadata-branch snapshots. Defaults to false. + Accepted for compatibility. Fabro-managed run-branch checkpoint + commits never run local Git commit hooks: the sandbox driver + disables repository hooks on every git command it runs. Does not + affect Fabro `[[run.hooks]]`. Defaults to false. RunCloneSettings: type: object diff --git a/docs/public/execution/run-configuration.mdx b/docs/public/execution/run-configuration.mdx index 587c92d8e..1a617e977 100644 --- a/docs/public/execution/run-configuration.mdx +++ b/docs/public/execution/run-configuration.mdx @@ -428,8 +428,8 @@ commit_timeout = "30s" | Field | Description | |---|---| | `exclude_globs` | Glob patterns for files to exclude from checkpoint commits. Uses git pathspec `:(glob,exclude)` syntax. | -| `skip_git_hooks` | When `true`, Fabro-managed run-branch checkpoint commits bypass local Git commit hooks (e.g. `pre-commit`, `commit-msg`). Defaults to `false`. Does not affect Fabro workflow `[[run.hooks]]` or metadata-branch snapshots. | -| `commit_timeout` | Max duration for the per-node run-branch checkpoint commit (e.g. `"30s"`, `"10m"`). This commit runs repository commit hooks unless `skip_git_hooks` is `true`. Defaults to `"30s"`. | +| `skip_git_hooks` | Accepted for compatibility. Fabro-managed run-branch checkpoint commits never run local Git commit hooks (e.g. `pre-commit`, `commit-msg`); the sandbox driver disables repository hooks on every git command it runs. Does not affect Fabro workflow `[[run.hooks]]`. | +| `commit_timeout` | Accepted for compatibility. The per-node run-branch checkpoint commit runs under the sandbox driver's git command budget; no repository hook can prolong it. | `exclude_globs` replaces across layers — the higher-precedence layer wins wholesale. `skip_git_hooks` and `commit_timeout` use normal override semantics: the highest layer that sets the field wins. diff --git a/docs/public/integrations/daytona.mdx b/docs/public/integrations/daytona.mdx index 4a7b7a266..26d089805 100644 --- a/docs/public/integrations/daytona.mdx +++ b/docs/public/integrations/daytona.mdx @@ -126,9 +126,9 @@ Set either `image.docker` or `image.dockerfile`. `image.docker` can name any ima dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git" ``` -Fabro computes an internal snapshot name and looks up that snapshot in Daytona. If it does not exist, Fabro creates it automatically and polls until it reaches `Active` state for up to 30 minutes. A Dockerfile can be inline content or `{ path = "..." }`; paths are resolved relative to the TOML file that declares them and are bundled into run manifests. If the snapshot already exists, Fabro reuses it immediately. +The sandbox driver builds the image or Dockerfile into a Daytona snapshot named by its inputs (the image reference or Dockerfile text, the resources, and the Daytona API key) and creates the sandbox from it. If that snapshot already exists, it is reused immediately; otherwise the driver builds it and waits for it to reach `Active` state. A Dockerfile can be inline content or `{ path = "..." }`; paths are resolved relative to the TOML file that declares them and are bundled into run manifests. -The exact `image.docker` value is part of the snapshot identity. Prefer a digest such as `registry.example.com/team/image@sha256:...` when the image must be reproducible. If a mutable tag moves without its text changing, Fabro continues to reuse the existing snapshot. +The exact `image.docker` value is part of the snapshot identity. Prefer a digest such as `registry.example.com/team/image@sha256:...` when the image must be reproducible. If a mutable tag moves without its text changing, the existing snapshot continues to be reused. If neither image source is configured, sandboxes are created from the `daytona-medium` snapshot, which includes standard dev tools such as Git. To force a new Dockerfile snapshot, change the Dockerfile text, for example by adding a comment. @@ -237,11 +237,11 @@ If doctor reports missing scopes, regenerate the Daytona key with `write:snapsho ### Custom snapshot did not roll -Custom Daytona snapshot names are computed from the image reference or Dockerfile, resource hints, tenant scope, and Daytona API key. For `image.docker`, use an immutable digest and update it when the image changes. For `image.dockerfile`, change the Dockerfile text under the selected `[environments..image]`. +Custom Daytona snapshot names (`sandbox-driver-`) are computed by the sandbox driver from the image reference or Dockerfile, the resources, and the Daytona API key. For `image.docker`, use an immutable digest and update it when the image changes. For `image.dockerfile`, change the Dockerfile text under the selected `[environments..image]`. ### "Timed out waiting for snapshot to become active" -Snapshot creation took longer than 30 minutes. This can happen with large Dockerfiles. Check the snapshot status in the Daytona dashboard — it may still be building. Subsequent runs will reuse the snapshot once it's active. +Snapshot creation took longer than the sandbox driver's build budget. This can happen with large Dockerfiles. Check the snapshot status in the Daytona dashboard — it may still be building. Subsequent runs will reuse the snapshot once it's active. ### Git clone fails for private repositories diff --git a/docs/public/integrations/github.mdx b/docs/public/integrations/github.mdx index 4533f416f..d28b954e0 100644 --- a/docs/public/integrations/github.mdx +++ b/docs/public/integrations/github.mdx @@ -303,7 +303,7 @@ Behavior notes: Workflow authors may name any repository reachable by the server's GitHub App installation; Fabro applies no second server-side repository intersection. The token is scoped server-side to exactly the declared set — a request to an undeclared repository fails at GitHub, and Fabro never mints an unscoped installation-wide token. With `contents = "write"`, **any stage can push to any declared repository**. Declare the smallest repository set and the weakest permissions that work. -Installation Access Tokens are short-lived. Fabro refreshes its own credentials before checkpoint pushes. For ACP/CLI agent turns launched with GitHub App push credentials, Fabro also re-mints the token and rewrites the sandbox's `origin` URL before the ACP process starts, then every 45 minutes for the lifetime of that turn. Refresh failures are logged and do not fail the stage. +Installation Access Tokens are short-lived. Fabro's own pushes present a fresh token on each call. Git commands the agent runs inside the sandbox read the token through a credential store the sandbox driver configures for the checkout; the token never appears in the repository's remote URL or configuration. For ACP/CLI agent turns launched with GitHub App push credentials, Fabro re-mints the token and rewrites that store before the ACP process starts, then every 45 minutes for the lifetime of that turn. Refresh failures are logged and do not fail the stage. `FABRO_PUSH_CRED_REFRESH_AHEAD` defaults to enabled; set it to `0`, `false`, `off`, `no`, or an empty value to disable both turn-entry and background refresh. `FABRO_PUSH_CRED_REFRESH_INTERVAL_SECONDS` overrides the background interval, and `0` disables only the background loop. This refresh loop is ACP-specific; command and native/API agent stages do not run it. Reconnected sandboxes for resumed or parked runs currently lack the App credentials needed for ACP refresh, so the refresh is skipped there. diff --git a/lib/apps/fabro-cli/Cargo.toml b/lib/apps/fabro-cli/Cargo.toml index 964a99de7..3d43c52f1 100644 --- a/lib/apps/fabro-cli/Cargo.toml +++ b/lib/apps/fabro-cli/Cargo.toml @@ -27,6 +27,7 @@ fabro-github = { path = "../../components/fabro-github" } pebble-agent.workspace = true pebble-coding-agent.workspace = true pebble-cli-core.workspace = true +sandbox-driver.workspace = true fabro-dump = { path = "../../components/fabro-dump" } fabro-hooks = { path = "../../components/fabro-hooks" } fabro-install = { path = "../../components/fabro-install" } @@ -120,6 +121,7 @@ assert_cmd = "2" fabro-acp = { path = "../../components/fabro-acp", features = ["test-support"] } fabro-mcp = { path = "../../components/fabro-mcp", features = ["test-support"] } fabro-build-support = { path = "../../foundation/build-support" } +fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["test-support"] } fabro-server = { path = "../fabro-server", features = ["test-support"] } fabro-workflow = { path = "../../components/fabro-workflow", features = ["test-support"] } fabro-types = { path = "../../foundation/fabro-types", features = ["clap", "test-support"] } diff --git a/lib/apps/fabro-cli/src/commands/run/events.rs b/lib/apps/fabro-cli/src/commands/run/events.rs index 247a22c6f..2149bfdcf 100644 --- a/lib/apps/fabro-cli/src/commands/run/events.rs +++ b/lib/apps/fabro-cli/src/commands/run/events.rs @@ -654,25 +654,35 @@ fn format_event_pretty_value(envelope: &serde_json::Value, styles: &Styles) -> O styles.dim.apply_to(&duration), )) } - "sandbox.snapshot.pulling" => { - let name = prop_str_field(envelope, "name").unwrap_or("?"); + "sandbox.create.progress" => { + let code = envelope + .pointer("/properties/progress/code") + .and_then(serde_json::Value::as_str)?; + if code != "image.pull" { + return None; + } + let message = envelope + .pointer("/properties/progress/message") + .and_then(serde_json::Value::as_str) + .unwrap_or("image"); + let name = message.strip_prefix("pulling image ").unwrap_or(message); Some(format!( "{} Sandbox: pulling {}", styles.dim.apply_to(&ts), name, )) } - "sandbox.snapshot.creating" => { - let name = prop_str_field(envelope, "name").unwrap_or("?"); + "snapshot.create.started" => { + let name = driver_subject_name(envelope); Some(format!( "{} Sandbox: building {}", styles.dim.apply_to(&ts), name, )) } - "sandbox.snapshot.ready" => { - let name = prop_str_field(envelope, "name").unwrap_or("?"); - let duration = format_duration_ms(prop_field(envelope, "duration_ms")); + "snapshot.create.completed" => { + let name = driver_subject_name(envelope); + let duration = format_duration_ms(driver_duration_ms(envelope).as_ref()); Some(format!( "{} Sandbox snapshot: {} {}", styles.dim.apply_to(&ts), @@ -680,9 +690,12 @@ fn format_event_pretty_value(envelope: &serde_json::Value, styles: &Styles) -> O styles.dim.apply_to(&duration), )) } - "sandbox.snapshot.failed" => { - let name = prop_str_field(envelope, "name").unwrap_or("?"); - let error = prop_str_field(envelope, "error").unwrap_or("unknown error"); + "snapshot.create.failed" => { + let name = driver_subject_name(envelope); + let error = envelope + .pointer("/properties/error/message") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown error"); Some(format!( "{} {} Sandbox snapshot {} failed: {}", styles.dim.apply_to(&ts), @@ -809,6 +822,30 @@ fn str_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> { value.get(key)?.as_str() } +/// The name of the resource a sandbox driver event is about, falling back +/// to its id. +fn driver_subject_name(envelope: &serde_json::Value) -> &str { + envelope + .pointer("/properties/subject/name") + .or_else(|| envelope.pointer("/properties/subject/id")) + .and_then(serde_json::Value::as_str) + .unwrap_or("?") +} + +/// A sandbox driver operation's duration, in milliseconds, as the number +/// [`format_duration_ms`] reads. +fn driver_duration_ms(envelope: &serde_json::Value) -> Option { + let duration = envelope.pointer("/properties/duration")?; + let secs = duration.get("secs").and_then(serde_json::Value::as_u64)?; + let nanos = duration + .get("nanos") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + Some(serde_json::Value::from( + secs.saturating_mul(1000).saturating_add(nanos / 1_000_000), + )) +} + fn prop_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> { value.get("properties")?.get(key) } @@ -1261,7 +1298,7 @@ mod tests { #[test] fn pretty_sandbox_snapshot_pulling() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.snapshot.pulling","properties":{"name":"buildpack-deps:noble"}}"#; + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.create.progress","properties":{"id":{"source_id":"t","sequence":1},"occurred_at":"2026-01-01T14:25:00Z","provider":"docker","subject":{"type":"sandbox"},"type":"operation_progress","action":"create","progress":{"code":"image.pull","message":"pulling image buildpack-deps:noble"}}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("Sandbox: pulling"), "got: {result}"); assert!(result.contains("buildpack-deps:noble"), "got: {result}"); @@ -1270,7 +1307,7 @@ mod tests { #[test] fn pretty_sandbox_snapshot_creating() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.snapshot.creating","properties":{"name":"fabro-v9-test"}}"#; + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"snapshot.create.started","properties":{"id":{"source_id":"t","sequence":1},"occurred_at":"2026-01-01T14:25:00Z","provider":"daytona","subject":{"type":"snapshot","name":"fabro-v9-test"},"type":"operation_started","action":"create"}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("Sandbox: building"), "got: {result}"); assert!(result.contains("fabro-v9-test"), "got: {result}"); @@ -1279,7 +1316,7 @@ mod tests { #[test] fn pretty_sandbox_snapshot_ready() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.snapshot.ready","properties":{"name":"buildpack-deps:noble","duration_ms":8200}}"#; + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"snapshot.create.completed","properties":{"id":{"source_id":"t","sequence":1},"occurred_at":"2026-01-01T14:25:00Z","provider":"daytona","subject":{"type":"snapshot","name":"buildpack-deps:noble"},"type":"operation_completed","action":"create","duration":{"secs":8,"nanos":200000000}}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("Sandbox snapshot:"), "got: {result}"); assert!(result.contains("buildpack-deps:noble"), "got: {result}"); @@ -1289,7 +1326,7 @@ mod tests { #[test] fn pretty_sandbox_snapshot_failed() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.snapshot.failed","properties":{"name":"buildpack-deps:noble","error":"pull failed"}}"#; + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"snapshot.create.failed","properties":{"id":{"source_id":"t","sequence":1},"occurred_at":"2026-01-01T14:25:00Z","provider":"docker","subject":{"type":"snapshot","name":"buildpack-deps:noble"},"type":"operation_failed","action":"create","duration":{"secs":1,"nanos":0},"error":{"kind":"provider","message":"pull failed","retryable":false,"causes":[]}}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!( result.contains("Sandbox snapshot buildpack-deps:noble failed: pull failed"), diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs index ab90eaa2f..895494d78 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs @@ -257,20 +257,7 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option { provider: props.provider.clone(), error: props.error.clone(), }), - EventBody::SnapshotPulling(props) => Some(ProgressEvent::SnapshotPulling { - name: props.name.clone(), - }), - EventBody::SnapshotCreating(props) => Some(ProgressEvent::SnapshotCreating { - name: props.name.clone(), - }), - EventBody::SnapshotReady(props) => Some(ProgressEvent::SnapshotReady { - name: props.name.clone(), - duration_ms: props.duration_ms, - }), - EventBody::SnapshotFailed(props) => Some(ProgressEvent::SnapshotFailed { - name: props.name.clone(), - error: props.error.clone(), - }), + EventBody::SandboxDriver { event, .. } => driver_progress_event(event), EventBody::SshAccessReady(props) => Some(ProgressEvent::SshAccessReady { ssh_command: props.ssh_command.clone(), }), @@ -537,6 +524,65 @@ fn agent_progress_event( } } +/// The setup progress a sandbox driver event stands for: the image pull +/// inside the sandbox's create, or a snapshot build. Every other driver +/// event is stored on the run but renders nothing here. +fn driver_progress_event(event: &sandbox_driver::Event) -> Option { + use sandbox_driver::{Action, EventBody as Body, EventSubject, ProgressCode}; + + match (&event.subject, &event.body) { + ( + EventSubject::Sandbox { .. }, + Body::OperationProgress { + action: Action::Create, + progress, + }, + ) if progress.code.as_str() == ProgressCode::IMAGE_PULL => { + Some(ProgressEvent::SnapshotPulling { + name: pulled_image_name(progress.message.as_deref()), + }) + } + (EventSubject::Snapshot { id, name }, body) => { + let name = name + .clone() + .or_else(|| id.as_ref().map(ToString::to_string)) + .unwrap_or_default(); + match body { + Body::OperationStarted { + action: Action::Create, + } => Some(ProgressEvent::SnapshotCreating { name }), + Body::OperationCompleted { + action: Action::Create, + duration, + } => Some(ProgressEvent::SnapshotReady { + name, + duration_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX), + }), + Body::OperationFailed { + action: Action::Create, + error, + .. + } => Some(ProgressEvent::SnapshotFailed { + name, + error: error.message.clone(), + }), + _ => None, + } + } + _ => None, + } +} + +/// The image an image pull progress report names. The Docker provider +/// says `pulling image `; the reference alone reads better. +fn pulled_image_name(message: Option<&str>) -> String { + let message = message.unwrap_or("image"); + message + .strip_prefix("pulling image ") + .unwrap_or(message) + .to_owned() +} + #[cfg(test)] mod tests { use fabro_types::{MetadataSnapshotFailureKind, MetadataSnapshotPhase, fixtures}; @@ -810,32 +856,76 @@ mod tests { )); } - #[test] - fn round_trip_snapshot_lifecycle_events() { - let pulling = to_run_event(&fixtures::RUN_1, &Event::Sandbox { - event: SandboxLifecycle::SnapshotPulling { - name: "buildpack-deps:noble".into(), - }, - }); - let creating = to_run_event(&fixtures::RUN_1, &Event::Sandbox { - event: SandboxLifecycle::SnapshotCreating { - name: "fabro-v9".into(), - }, - }); - let ready = to_run_event(&fixtures::RUN_1, &Event::Sandbox { - event: SandboxLifecycle::SnapshotReady { - name: "buildpack-deps:noble".into(), - duration_ms: 1200, - }, - }); - let failed = to_run_event(&fixtures::RUN_1, &Event::Sandbox { - event: SandboxLifecycle::SnapshotFailed { - name: "fabro-v9".into(), - error: "build failed".into(), - causes: Vec::new(), - }, - }); + fn driver_event(value: serde_json::Value) -> Event { + Event::SandboxDriver { + event: serde_json::from_value(value).expect("a driver event"), + } + } + #[test] + fn round_trip_driver_events_that_render_setup_progress() { + let pulling = to_run_event( + &fixtures::RUN_1, + &driver_event(serde_json::json!({ + "id": {"source_id": "test", "sequence": 1}, + "occurred_at": "2026-01-01T00:00:00Z", + "provider": "docker", + "subject": {"type": "sandbox"}, + "type": "operation_progress", + "action": "create", + "progress": {"code": "image.pull", "message": "pulling image buildpack-deps:noble"} + })), + ); + let creating = to_run_event( + &fixtures::RUN_1, + &driver_event(serde_json::json!({ + "id": {"source_id": "test", "sequence": 2}, + "occurred_at": "2026-01-01T00:00:00Z", + "provider": "daytona", + "subject": {"type": "snapshot", "name": "fabro-v9"}, + "type": "operation_started", + "action": "create" + })), + ); + let ready = to_run_event( + &fixtures::RUN_1, + &driver_event(serde_json::json!({ + "id": {"source_id": "test", "sequence": 3}, + "occurred_at": "2026-01-01T00:00:01Z", + "provider": "daytona", + "subject": {"type": "snapshot", "name": "fabro-v9"}, + "type": "operation_completed", + "action": "create", + "duration": {"secs": 1, "nanos": 200_000_000} + })), + ); + let failed = to_run_event( + &fixtures::RUN_1, + &driver_event(serde_json::json!({ + "id": {"source_id": "test", "sequence": 4}, + "occurred_at": "2026-01-01T00:00:02Z", + "provider": "daytona", + "subject": {"type": "snapshot", "name": "fabro-v9"}, + "type": "operation_failed", + "action": "create", + "duration": {"secs": 2, "nanos": 0}, + "error": {"kind": "provider", "message": "build failed", "retryable": false, "causes": []} + })), + ); + let stopped = to_run_event( + &fixtures::RUN_1, + &driver_event(serde_json::json!({ + "id": {"source_id": "test", "sequence": 5}, + "occurred_at": "2026-01-01T00:00:03Z", + "provider": "docker", + "subject": {"type": "sandbox", "id": "c1"}, + "type": "operation_completed", + "action": "stop", + "duration": {"secs": 0, "nanos": 0} + })), + ); + + assert_eq!(pulling.event_name(), "sandbox.create.progress"); assert!(matches!( from_run_event(&pulling).unwrap(), ProgressEvent::SnapshotPulling { name } if name == "buildpack-deps:noble" @@ -847,13 +937,18 @@ mod tests { assert!(matches!( from_run_event(&ready).unwrap(), ProgressEvent::SnapshotReady { name, duration_ms } - if name == "buildpack-deps:noble" && duration_ms == 1200 + if name == "fabro-v9" && duration_ms == 1200 )); assert!(matches!( from_run_event(&failed).unwrap(), ProgressEvent::SnapshotFailed { name, error } if name == "fabro-v9" && error == "build failed" )); + assert_eq!(stopped.event_name(), "sandbox.stop.completed"); + assert!( + from_run_event(&stopped).is_none(), + "a stop is stored on the run but renders no setup progress" + ); } #[test] diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs index c8d8ee5e2..0717e6a73 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs @@ -507,6 +507,55 @@ mod tests { .expect("valid utf-8") } + fn driver_event(value: serde_json::Value) -> Event { + Event::SandboxDriver { + event: serde_json::from_value(value).expect("a driver event"), + } + } + + /// A snapshot build reported by the driver: started, or completed after + /// `secs`. + fn snapshot_build_event(name: &str, kind: &str, secs: Option) -> Event { + let mut value = serde_json::json!({ + "id": {"source_id": "test", "sequence": 1}, + "occurred_at": "2026-01-01T00:00:00Z", + "provider": "daytona", + "subject": {"type": "snapshot", "name": name}, + "type": kind, + "action": "create" + }); + if let Some(secs) = secs { + value["duration"] = serde_json::json!({"secs": secs, "nanos": 0}); + } + driver_event(value) + } + + fn snapshot_build_failed_event(name: &str, error: &str) -> Event { + driver_event(serde_json::json!({ + "id": {"source_id": "test", "sequence": 1}, + "occurred_at": "2026-01-01T00:00:00Z", + "provider": "docker", + "subject": {"type": "snapshot", "name": name}, + "type": "operation_failed", + "action": "create", + "duration": {"secs": 1, "nanos": 0}, + "error": {"kind": "provider", "message": error, "retryable": false, "causes": []} + })) + } + + /// The Docker provider pulling the sandbox's image inside its create. + fn image_pull_event(image: &str) -> Event { + driver_event(serde_json::json!({ + "id": {"source_id": "test", "sequence": 1}, + "occurred_at": "2026-01-01T00:00:00Z", + "provider": "docker", + "subject": {"type": "sandbox"}, + "type": "operation_progress", + "action": "create", + "progress": {"code": "image.pull", "message": format!("pulling image {image}")} + })) + } + fn emit(ui: &mut ProgressUI, event: Event) { let stored = to_run_event(&fixtures::RUN_1, &event); ui.handle_event(&stored); @@ -1080,17 +1129,14 @@ mod tests { provider: "daytona".into(), }, }); - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::SnapshotCreating { - name: "fabro-v9-test".into(), - }, - }); - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::SnapshotReady { - name: "fabro-v9-test".into(), - duration_ms: 210_000, - }, - }); + emit( + &mut ui, + snapshot_build_event("fabro-v9-test", "operation_started", None), + ); + emit( + &mut ui, + snapshot_build_event("fabro-v9-test", "operation_completed", Some(210)), + ); emit(&mut ui, Event::Sandbox { event: SandboxLifecycle::Ready { provider: "daytona".into(), @@ -1116,17 +1162,7 @@ mod tests { provider: "docker".into(), }, }); - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::SnapshotPulling { - name: "buildpack-deps:noble".into(), - }, - }); - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::SnapshotReady { - name: "buildpack-deps:noble".into(), - duration_ms: 8_200, - }, - }); + emit(&mut ui, image_pull_event("buildpack-deps:noble")); emit(&mut ui, Event::Sandbox { event: SandboxLifecycle::Ready { provider: "docker".into(), @@ -1172,13 +1208,10 @@ mod tests { provider: "docker".into(), }, }); - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::SnapshotFailed { - name: "buildpack-deps:noble".into(), - error: "pull failed".into(), - causes: Vec::new(), - }, - }); + emit( + &mut ui, + snapshot_build_failed_event("buildpack-deps:noble", "pull failed"), + ); emit(&mut ui, Event::Sandbox { event: SandboxLifecycle::InitializeFailed { provider: "docker".into(), @@ -1205,12 +1238,10 @@ mod tests { }); assert!(ui.setup.sandbox_bar.is_some()); - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::SnapshotReady { - name: "buildpack-deps:noble".into(), - duration_ms: 10, - }, - }); + emit( + &mut ui, + snapshot_build_event("buildpack-deps:noble", "operation_completed", Some(0)), + ); assert!(ui.setup.sandbox_bar.is_some()); emit(&mut ui, Event::Sandbox { diff --git a/lib/apps/fabro-cli/tests/it/cmd/attach.rs b/lib/apps/fabro-cli/tests/it/cmd/attach.rs index 4d279727d..7f75eee71 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/attach.rs @@ -1097,6 +1097,91 @@ fn attach_json_errors_without_prompting_for_human_input() { "run_id": "[ULID]", "ts": "[TIMESTAMP]" }, + { + "actor": { + "kind": "worker", + "run_id": "[ULID]" + }, + "event": "sandbox.create.started", + "id": "[EVENT_ID]", + "properties": { + "action": "create", + "correlation_id": "[ULID]", + "id": { + "sequence": 1, + "source_id": "[HEX]" + }, + "occurred_at": "[TIMESTAMP]", + "operation_id": "[HEX]", + "provider": "host", + "subject": { + "id": "host-dir-[HEX]", + "type": "sandbox" + }, + "type": "operation_started" + }, + "run_id": "[ULID]", + "ts": "[TIMESTAMP]" + }, + { + "actor": { + "kind": "worker", + "run_id": "[ULID]" + }, + "event": "sandbox.create.progress", + "id": "[EVENT_ID]", + "properties": { + "action": "create", + "correlation_id": "[ULID]", + "id": { + "sequence": 2, + "source_id": "[HEX]" + }, + "occurred_at": "[TIMESTAMP]", + "operation_id": "[HEX]", + "progress": { + "code": "sandbox.provision" + }, + "provider": "host", + "subject": { + "id": "host-dir-[HEX]", + "type": "sandbox" + }, + "type": "operation_progress" + }, + "run_id": "[ULID]", + "ts": "[TIMESTAMP]" + }, + { + "actor": { + "kind": "worker", + "run_id": "[ULID]" + }, + "event": "sandbox.create.completed", + "id": "[EVENT_ID]", + "properties": { + "action": "create", + "correlation_id": "[ULID]", + "duration": { + "nanos": "[NANOS]", + "secs": 0 + }, + "id": { + "sequence": 3, + "source_id": "[HEX]" + }, + "occurred_at": "[TIMESTAMP]", + "operation_id": "[HEX]", + "provider": "host", + "subject": { + "id": "host-dir-[HEX]", + "type": "sandbox" + }, + "type": "operation_completed" + }, + "run_id": "[ULID]", + "ts": "[TIMESTAMP]" + }, { "actor": { "kind": "worker", @@ -1119,9 +1204,12 @@ fn attach_json_errors_without_prompting_for_human_input() { "event": "sandbox.initialized", "id": "[EVENT_ID]", "properties": { - "id": "local:[ULID]", + "id": "host-dir-[HEX]", "provider": "local", - "working_directory": "[TEMP_DIR]" + "repo_cloned": false, + "repos_root": "[TEMP_DIR]/.repos", + "working_directory": "[TEMP_DIR]", + "workspace_root": "[TEMP_DIR]" }, "run_id": "[ULID]", "ts": "[TIMESTAMP]" diff --git a/lib/apps/fabro-cli/tests/it/cmd/dump.rs b/lib/apps/fabro-cli/tests/it/cmd/dump.rs index 532257f18..f56f590b2 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/dump.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/dump.rs @@ -261,9 +261,9 @@ fn dump_exports_completed_run_snapshot() { "); assert_snapshot!(dump_file_summary(&output_dir), @" - checkpoints/0014.json - checkpoints/0018.json - checkpoints/0022.json + checkpoints/0017.json + checkpoints/0021.json + checkpoints/0025.json events.jsonl graph.fabro run.json diff --git a/lib/apps/fabro-cli/tests/it/cmd/run.rs b/lib/apps/fabro-cli/tests/it/cmd/run.rs index 5f1752b38..6f3856a0d 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/run.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/run.rs @@ -993,8 +993,24 @@ fn dry_run_persists_event_history_in_store() { "event": "sandbox.stop.completed", "id": "[EVENT_ID]", "properties": { - "duration_ms": "[DURATION_MS]", - "provider": "local" + "action": "stop", + "correlation_id": "[ULID]", + "duration": { + "nanos": "[NANOS]", + "secs": 0 + }, + "id": { + "sequence": 5, + "source_id": "[HEX]" + }, + "occurred_at": "[TIMESTAMP]", + "operation_id": "[HEX]", + "provider": "host", + "subject": { + "id": "host-dir-[HEX]", + "type": "sandbox" + }, + "type": "operation_completed" }, "run_id": "[ULID]", "ts": "[TIMESTAMP]" diff --git a/lib/apps/fabro-cli/tests/it/cmd/support.rs b/lib/apps/fabro-cli/tests/it/cmd/support.rs index 51e179298..3d52f599b 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/support.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/support.rs @@ -1107,7 +1107,7 @@ async fn append_seeded_simple_completion_events( serde_json::json!({ "working_directory": context.temp_dir.display().to_string(), "provider": "local", - "id": format!("local:{}", run.run_id), + "id": fabro_sandbox::test_support::local_sandbox_id(&context.temp_dir).await, "repo_cloned": false, "clone_origin_url": null, "clone_branch": null, @@ -1276,7 +1276,7 @@ async fn append_seeded_git_completion_events( serde_json::json!({ "working_directory": context.temp_dir.display().to_string(), "provider": "local", - "id": format!("local:{}", run.run_id), + "id": fabro_sandbox::test_support::local_sandbox_id(&context.temp_dir).await, "repo_cloned": false, "clone_origin_url": null, "clone_branch": null, diff --git a/lib/apps/fabro-server/Cargo.toml b/lib/apps/fabro-server/Cargo.toml index 4793838ff..811dec41f 100644 --- a/lib/apps/fabro-server/Cargo.toml +++ b/lib/apps/fabro-server/Cargo.toml @@ -35,6 +35,7 @@ fabro-workflow = { path = "../../components/fabro-workflow" } fabro-workflow-version = { path = "../../components/fabro-workflow-version" } fabro-validate = { path = "../../components/fabro-validate" } fabro-sandbox = { path = "../../components/fabro-sandbox" } +sandbox-driver.workspace = true fabro-github = { path = "../../components/fabro-github" } pebble-agent.workspace = true pebble-coding-agent.workspace = true diff --git a/lib/apps/fabro-server/src/demo/mod.rs b/lib/apps/fabro-server/src/demo/mod.rs index eeb78804c..f03c01ca5 100644 --- a/lib/apps/fabro-server/src/demo/mod.rs +++ b/lib/apps/fabro-server/src/demo/mod.rs @@ -23,7 +23,6 @@ use fabro_api::types::{ RunFilesMeta, RunFilesMetaScope, RunFilesMetaSource, SandboxService, SandboxServiceListResponse, }; -use fabro_types::{SandboxServiceDiscoverySource, SandboxServiceListMeta}; use serde_json::json; use crate::error::ApiError; @@ -405,19 +404,16 @@ pub(crate) async fn list_sandbox_services_stub( SandboxService { port: 3000, addresses: vec!["0.0.0.0:3000".to_string()], - processes: vec![r#"users:(("node",pid=42,fd=23))"#.to_string()], + processes: vec!["node".to_string()], preview_supported: true, }, SandboxService { port: 2500, addresses: vec!["127.0.0.1:2500".to_string()], - processes: vec![r#"users:(("debug",pid=84,fd=19))"#.to_string()], + processes: vec!["debug".to_string()], preview_supported: false, }, ], - meta: SandboxServiceListMeta { - source: SandboxServiceDiscoverySource::Ss, - }, }), ) .into_response() diff --git a/lib/apps/fabro-server/src/diagnostics.rs b/lib/apps/fabro-server/src/diagnostics.rs index 6a6367446..c94c83769 100644 --- a/lib/apps/fabro-server/src/diagnostics.rs +++ b/lib/apps/fabro-server/src/diagnostics.rs @@ -691,7 +691,7 @@ fn cloud_sandbox_probe_check(probe: anyhow::Result) -> remediation: Some(format!( "Regenerate the Daytona API key with scopes: {}, then \ `fabro secret set DAYTONA_API_KEY`.", - daytona::required_perms_display() + check.required_display() )), }, Err(err) => { diff --git a/lib/apps/fabro-server/src/install.rs b/lib/apps/fabro-server/src/install.rs index 5efda3ba0..e4c75a0e5 100644 --- a/lib/apps/fabro-server/src/install.rs +++ b/lib/apps/fabro-server/src/install.rs @@ -1005,13 +1005,12 @@ async fn check_install_daytona_api_key( state: &InstallAppState, api_key: String, ) -> anyhow::Result { - let credentials = DaytonaCredentials { - api_key, - api_url: state.upstreams.daytona_api_base_url.clone(), - organization_id: state.upstreams.daytona_organization_id.clone(), - target: None, - http_client: Some(fabro_http::http_client().context("failed to build HTTP client")?), - }; + let credentials = DaytonaCredentials::new(api_key) + .with_api_url(state.upstreams.daytona_api_base_url.clone()) + .with_organization_id(state.upstreams.daytona_organization_id.clone()) + .with_http_client(Some( + fabro_http::http_client().context("failed to build HTTP client")?, + )); daytona::check_daytona_api_key(&credentials, daytona::DAYTONA_CREDENTIAL_PROBE_TIMEOUT).await } diff --git a/lib/apps/fabro-server/src/run_files.rs b/lib/apps/fabro-server/src/run_files.rs index 27f9a149c..348165c42 100644 --- a/lib/apps/fabro-server/src/run_files.rs +++ b/lib/apps/fabro-server/src/run_files.rs @@ -20,7 +20,7 @@ use std::future::Future; use std::num::NonZeroU64; use std::panic::AssertUnwindSafe; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use axum::Json; use axum::extract::{Path, Query, State}; @@ -35,13 +35,17 @@ use fabro_api::types::{ RunFilesMetaToSha, }; use fabro_sandbox::reconnect::reconnect_for_run; -use fabro_sandbox::{RunSandbox, shell_quote}; +use fabro_sandbox::{RunSandbox, Termination}; use fabro_types::RunId; +use fabro_util::shell; use fabro_workflow::sandbox_git::{ DiffError, DiffNumstat, RawDiffEntry, SubmoduleChange, SymlinkChange, list_changed_files_raw, list_diff_numstat, stream_blob_metadata, stream_blobs, }; use futures_util::FutureExt; +use sandbox_driver::{ + Git as _, GitCommit, GitDiffOptions, GitFacet, GitLogOptions, GitRevisionRange, +}; use serde::Deserialize; use tokio::sync::{Mutex, watch}; @@ -59,6 +63,7 @@ pub(crate) const AGGREGATE_BYTES_CAP: u64 = 5 * 1024 * 1024; pub(crate) const FILE_COUNT_CAP: usize = 200; /// Sandbox git timeout. Matches Unit 3 helpers (10 s). const SANDBOX_GIT_TIMEOUT_MS: u64 = 10_000; +const SANDBOX_GIT_TIMEOUT: Duration = Duration::from_millis(SANDBOX_GIT_TIMEOUT_MS); /// Below this SHA count the phase-1 `cat-file --batch-check` pre-filter is /// skipped — its ~100 ms round-trip dominates for small diffs, and phase-2 @@ -332,8 +337,7 @@ async fn materialize_run_commits( .ok_or_else(|| ApiError::new(StatusCode::CONFLICT, "Run has no base SHA."))?; let sandbox = reconnect_run_sandbox(state, run_id, &projection).await?; let (head_sha, _) = resolve_ref_sha_and_time(&sandbox, "HEAD").await?; - let output = git_log_commits(&sandbox, &base_sha, &head_sha, limit + 1).await?; - let mut commits = parse_git_log_commits(&output)?; + let mut commits = git_log_commits(&sandbox, &base_sha, &head_sha, limit + 1).await?; let truncated = commits.len() > usize::try_from(limit).unwrap_or(usize::MAX); commits.truncate(usize::try_from(limit).unwrap_or(usize::MAX)); let total_returned = u64::try_from(commits.len()).unwrap_or(u64::MAX); @@ -356,57 +360,26 @@ async fn git_log_commits( base_sha: &str, head_sha: &str, limit: u64, -) -> std::result::Result { - let base_q = shell_quote(base_sha); - let head_q = shell_quote(head_sha); - let format_q = - shell_quote("%H%x1f%T%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1f%B%x1e"); - sandbox_git_stdout( - sandbox, - &format!( - "git -c maintenance.auto=0 -c gc.auto=0 -c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.quotePath=false log --first-parent --reverse --max-count={limit} --format={format_q} {base_q}..{head_q}" - ), - "git log", - ) - .await +) -> std::result::Result, ApiError> { + let git = sandbox_git(sandbox)?; + let options = GitLogOptions::new(GitRevisionRange::new(base_sha).to(head_sha)) + .first_parent() + .reverse() + .max_count(limit) + .timeout(SANDBOX_GIT_TIMEOUT); + let commits = git + .log(sandbox.working_directory(), &options) + .await + .map_err(|error| sandbox_git_error("git log", &error))?; + commits.iter().map(run_commit).collect() } -fn parse_git_log_commits(stdout: &str) -> std::result::Result, ApiError> { - stdout - .split('\x1e') - .filter_map(|record| { - let record = record.trim_matches('\n'); - (!record.is_empty()).then_some(record) - }) - .map(parse_git_log_commit) - .collect() -} - -fn parse_git_log_commit(record: &str) -> std::result::Result { - let mut fields = record.splitn(10, '\x1f'); - let sha = fields.next().unwrap_or_default(); - let tree_sha = fields.next().unwrap_or_default(); - let parents = fields.next().unwrap_or_default(); - let author_name = fields.next().unwrap_or_default(); - let author_email = fields.next().unwrap_or_default(); - let author_date = fields.next().unwrap_or_default(); - let committer_name = fields.next().unwrap_or_default(); - let committer_email = fields.next().unwrap_or_default(); - let committer_date = fields.next().unwrap_or_default(); - let message = fields - .next() - .unwrap_or_default() - .trim_end_matches('\n') - .to_string(); - if sha.is_empty() { - return Err(ApiError::bad_request( - "Malformed git log output: missing commit SHA.", - )); - } - +fn run_commit(commit: &GitCommit) -> std::result::Result { + let message = commit.message.trim_end_matches('\n').to_string(); let (subject, body) = split_commit_message(&message); - let parents = parents - .split_whitespace() + let parents = commit + .parents + .iter() .map(|parent| { Ok(RunCommitParent { sha: sha_newtype::(parent)?, @@ -416,27 +389,27 @@ fn parse_git_log_commit(record: &str) -> std::result::Result, ApiError>>()?; Ok(RunCommit { - sha: sha_newtype::(sha)?, - short_sha: short_sha_newtype::(sha)?, + sha: sha_newtype::(&commit.sha)?, + short_sha: short_sha_newtype::(&commit.sha)?, parents, author: RunCommitPerson { - name: author_name.to_string(), - email: author_email.to_string(), - date: parse_git_date(author_date), + name: commit.author.name.clone(), + email: commit.author.email.clone(), + date: parse_git_date(&commit.author.date), }, committer: RunCommitPerson { - name: committer_name.to_string(), - email: committer_email.to_string(), - date: parse_git_date(committer_date), + name: commit.committer.name.clone(), + email: commit.committer.email.clone(), + date: parse_git_date(&commit.committer.date), }, subject, body, message: message.clone(), trailers: parse_commit_trailers(&message), - tree_sha: if tree_sha.is_empty() { + tree_sha: if commit.tree.is_empty() { None } else { - Some(sha_newtype::(tree_sha)?) + Some(sha_newtype::(&commit.tree)?) }, }) } @@ -732,15 +705,15 @@ async fn materialize_working_tree_sandbox_path( start: Instant, ) -> ListRunFilesResult { let (to_sha, to_sha_committed_at) = resolve_head_sha_and_time(sandbox).await?; - let base_q = shell_quote(base_ref); - let patch = sandbox_git_stdout( - sandbox, - &format!( - "git -c maintenance.auto=0 -c gc.auto=0 -c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.quotePath=false diff --patch --find-renames=50% {base_q}" - ), - "git diff --patch", - ) - .await?; + let git = sandbox_git(sandbox)?; + // No head: the driver diffs `base_ref` against the working tree. + let options = GitDiffOptions::new(GitRevisionRange::new(base_ref)) + .find_renames(50) + .timeout(SANDBOX_GIT_TIMEOUT); + let patch = git + .diff_patch(sandbox.working_directory(), &options) + .await + .map_err(|error| sandbox_git_error("git diff --patch", &error))?; let entries: Vec = split_patch_sections(&patch) .into_iter() @@ -762,22 +735,25 @@ async fn materialize_working_tree_sandbox_path( )) } -async fn sandbox_git_stdout( - sandbox: &RunSandbox, - command: &str, - op: &str, -) -> std::result::Result { - let res = sandbox - .exec_command(command, SANDBOX_GIT_TIMEOUT_MS, None, None, None) - .await - .map_err(|err| ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.display_with_causes()))?; - if res.is_timed_out() { - return Err(transient_503(op, "command timed out")); +/// The sandbox's git facet; a provider without git cannot serve files. +fn sandbox_git(sandbox: &RunSandbox) -> std::result::Result, ApiError> { + sandbox + .git() + .map_err(|err| ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.display_with_causes())) +} + +/// A driver git failure as the endpoint's transient 503, so the client +/// retries; a command that timed out says so. +fn sandbox_git_error(op: &str, error: &sandbox_driver::Error) -> ApiError { + let timed_out = matches!( + error, + sandbox_driver::Error::Git(failure) + if failure.output().is_some_and(|output| output.termination() == Termination::TimedOut) + ); + if timed_out { + return transient_503(op, "command timed out"); } - if !res.is_success() { - return Err(transient_503(op, res.stderr.trim())); - } - Ok(res.stdout) + transient_503(op, &fabro_sandbox::display_for_log(error)) } /// Build the degraded response from the stored terminal diff patch. @@ -1204,7 +1180,7 @@ async fn reconnect_run_sandbox( .provider_access() .await .map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; - let sandbox = reconnect_for_run(&record, &access, Some(*run_id)) + let sandbox = reconnect_for_run(&record, &access, Some(*run_id), None) .await .map_err(|err| ApiError::new(StatusCode::CONFLICT, err.to_string()))?; sandbox @@ -1228,7 +1204,7 @@ async fn resolve_ref_sha_and_time( sandbox: &RunSandbox, git_ref: &str, ) -> std::result::Result<(String, Option>), ApiError> { - let ref_q = shell_quote(git_ref); + let ref_q = shell::shell_quote(git_ref); let res = sandbox .exec_command( &format!("git -c core.hooksPath=/dev/null show -s --format=%H\\ %cI {ref_q}"), @@ -1239,13 +1215,13 @@ async fn resolve_ref_sha_and_time( ) .await .map_err(|err| ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.display_with_causes()))?; - if !res.is_success() { + if !res.success() { return Err(ApiError::new( StatusCode::SERVICE_UNAVAILABLE, "Failed to resolve sandbox git ref.", )); } - parse_head_show_output(&res.stdout).ok_or_else(|| { + parse_head_show_output(&res.stdout_lossy()).ok_or_else(|| { ApiError::new( StatusCode::SERVICE_UNAVAILABLE, "Sandbox HEAD resolved to an empty value.", @@ -1703,7 +1679,9 @@ fn count_flags(data: &[FileDiff]) -> (u64, u64, u64, u64) { mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; - use fabro_types::{CommandTermination, RunId, test_support}; + use fabro_sandbox::Termination; + use fabro_sandbox::test_support::exec_result; + use fabro_types::{RunId, test_support}; use tokio::time::{Duration, sleep}; use super::*; @@ -1749,7 +1727,7 @@ mod tests { sandbox.respond_with(|command| { let stdout = if command.contains(" show -s --format=") { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 2026-05-09T17:12:40Z\n".to_string() - } else if command.contains(" diff --patch --find-renames=50% ") { + } else if command.contains("'diff'") && command.contains("'--find-renames=50%'") { "\ diff --git a/src/live.rs b/src/live.rs --- a/src/live.rs @@ -1762,13 +1740,7 @@ diff --git a/src/live.rs b/src/live.rs } else { return None; }; - Some(fabro_sandbox::ExecResult { - stdout, - stderr: String::new(), - exit_code: Some(0), - termination: CommandTermination::Exited, - duration_ms: 0, - }) + Some(exec_result(&stdout, "", Some(0), Termination::Exited, 0)) }); let body = materialize_working_tree_sandbox_path( @@ -1784,15 +1756,21 @@ diff --git a/src/live.rs b/src/live.rs assert_eq!(body.meta.source, RunFilesMetaSource::Sandbox); assert_eq!(body.meta.scope, RunFilesMetaScope::Uncommitted); assert_eq!(body.data.len(), 1); - let commands = sandbox.captured_commands(); + let commands = sandbox.driver().scripted_exec().commands(); assert_eq!(commands.len(), 2); assert!(commands[0].contains(" show -s --format=")); - assert!(commands[1].contains(" diff --patch --find-renames=50% HEAD")); + assert!( + commands[1].contains("'diff'") + && commands[1].contains("'--find-renames=50%'") + && commands[1].contains("'HEAD'"), + "{}", + commands[1] + ); assert!(!commands.iter().any(|command| command.contains("ls-files"))); } - #[test] - fn parse_git_log_commits_keeps_external_and_fabro_metadata() { + #[tokio::test] + async fn git_log_commits_keeps_external_and_fabro_metadata() { let stdout = concat!( "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\x1f", "cccccccccccccccccccccccccccccccccccccccc\x1f", @@ -1807,8 +1785,26 @@ diff --git a/src/live.rs b/src/live.rs "Alice\x1falice@example.com\x1f2026-05-09T18:00:00Z\x1f", "external tool update\n\nLonger body.\n\x1e", ); + let sandbox = fabro_sandbox::test_support::MockSandbox::default(); + sandbox + .driver() + .scripted_exec() + .push_result(fabro_sandbox::test_support::exec_result( + stdout, + "", + Some(0), + Termination::Exited, + 1, + )); - let commits = parse_git_log_commits(stdout).expect("git log should parse"); + let commits = git_log_commits( + &sandbox.sandbox(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "dddddddddddddddddddddddddddddddddddddddd", + 50, + ) + .await + .expect("git log should parse"); assert_eq!(commits.len(), 2); assert_eq!(commits[0].subject, "fabro(run_1): implement (succeeded)"); @@ -1821,6 +1817,11 @@ diff --git a/src/live.rs b/src/live.rs assert_eq!(commits[1].subject, "external tool update"); assert_eq!(commits[1].body.as_deref(), Some("Longer body.")); assert!(commits[1].trailers.is_empty()); + let command = &sandbox.driver().scripted_exec().commands()[0]; + assert!( + command.contains("'--first-parent'") && command.contains("'--max-count=50'"), + "{command}" + ); } #[tokio::test] @@ -2873,23 +2874,11 @@ rename to .env.production } fn ok_exec(stdout: &str) -> ExecResult { - ExecResult { - stdout: stdout.to_string(), - stderr: String::new(), - exit_code: Some(0), - termination: CommandTermination::Exited, - duration_ms: 0, - } + exec_result(stdout, "", Some(0), Termination::Exited, 0) } fn fail_exec(stderr: &str) -> ExecResult { - ExecResult { - stdout: String::new(), - stderr: stderr.to_string(), - exit_code: Some(1), - termination: CommandTermination::Exited, - duration_ms: 0, - } + exec_result("", stderr, Some(1), Termination::Exited, 0) } #[tokio::test] diff --git a/lib/apps/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs index be188f515..dbad49938 100644 --- a/lib/apps/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -17,10 +17,8 @@ use fabro_graphviz::render::apply_direction; use fabro_llm::FabroClient; use fabro_llm::lithos_catalog::Catalog; use fabro_llm::probe::{self, ModelTestStatus}; -use fabro_sandbox::redact::redact_auth_url; use fabro_sandbox::{ - ProviderAccess, ProviderSandboxSpec, RunSandbox, SandboxSpec, - local_working_directory_from_environment, options_from_environment, unresolved_env, + CloneRequest, ProviderAccess, RunSandbox, SandboxSpec, sandbox_spec_for_environment, }; use fabro_static::EnvVars; use fabro_types::settings::ModelRef; @@ -874,7 +872,10 @@ async fn check_git_remote_ref( run_ls_remote(command) .await - .map_err(|message| redact_auth_url(&message, auth_url.as_ref())) + .map_err(|message| match &auth_url { + Some(auth_url) => auth_url.redact_in(&message), + None => message, + }) } /// Run a prepared `git ls-remote` invocation with a 10s timeout, reducing a @@ -918,31 +919,36 @@ fn preflight_sandbox_spec( let clone_branch = prepared.git.as_ref().map(|git| git.branch.clone()); if sandbox_provider.bundled() == Some(BundledProvider::Local) { - let working_directory = local_working_directory_from_environment( - &resolved_run.environment, - Some(&prepared.source_directory), - )?; - return Ok(SandboxSpec::Local { working_directory }); + let working_directory = resolved_run + .environment + .local_working_directory(Some(&prepared.source_directory)) + .map_err(|err| { + fabro_sandbox::Error::context( + "Failed to resolve local environment working directory", + err, + ) + })?; + return Ok(SandboxSpec::local(working_directory, access.clone())); } // No vault is available on this path, so a `{{ secrets.* }}` value keeps - // its source form. - let mut options = options_from_environment( + // its source form. Preflight never clones. + let spec = sandbox_spec_for_environment( &resolved_run.environment, - &resolved_run.clone, - unresolved_env(&resolved_run.environment), + resolved_run.environment.unresolved_env(), )?; - options.skip_clone = true; - Ok(SandboxSpec::Provider(Box::new(ProviderSandboxSpec { + let clone = CloneRequest { + origin_url: clone_origin_url, + branch: clone_branch, + ..CloneRequest::none() + }; + Ok(SandboxSpec { kind: sandbox_provider.clone(), access: access.clone(), - options, + spec, + clone, github_app, run_id: None, - clone_origin_url, - clone_branch, - clone_tag: None, - clone_commit_sha: None, - }))) + }) } async fn run_sandbox_check( @@ -993,7 +999,7 @@ async fn run_sandbox_check( warn: true, }); } - if let Err(err) = sandbox.cleanup().await { + if let Err(err) = sandbox.delete().await { checks.push(CheckResult { name: "Sandbox".into(), status: CheckStatus::Error, @@ -1013,7 +1019,7 @@ async fn run_sandbox_check( true } Err(err) => { - let cleanup_error = sandbox.cleanup().await.err(); + let cleanup_error = sandbox.delete().await.err(); checks.push(CheckResult { name: "Sandbox".into(), status: CheckStatus::Error, @@ -1494,23 +1500,21 @@ async fn probe_github_repository( /// Retry auth-shaped failures with the SAME token: replication of a given /// token only makes progress, while re-minting would restart the replication -/// clock. The sandbox git retry executor owns attempt limits, -/// classification, and pacing. +/// clock. The driver's git retry owns the decision and the pacing; fabro's +/// probe policy owns the attempt count. async fn probe_with_replication_retry( snapshot: TokenSnapshot, - mut run: F, + run: F, ) -> std::result::Result<(), String> where F: FnMut() -> Fut, Fut: Future>, { - let credential_context = fabro_sandbox::CredentialContext::from_snapshot(Some(&snapshot)); - fabro_sandbox::retry_git_operation( - SandboxProviderKind::LOCAL, + fabro_sandbox::retry_git_messages( + &fabro_sandbox::repository_probe_policy(), + Some(&snapshot), "repository probe", - &fabro_sandbox::RetryPlan::repository_probe(), - |_attempt| run(), - |message| fabro_sandbox::classify_failure(message, credential_context), + run, ) .await } @@ -2215,18 +2219,14 @@ provider = "local" &ProviderAccess::default(), ); - match spec { - Ok(SandboxSpec::Provider(spec)) => { - assert_eq!(spec.kind, SandboxProviderKind::DOCKER); - assert!(spec.options.skip_clone); - assert_eq!( - spec.clone_origin_url.as_deref(), - Some("https://github.com/acme/widgets") - ); - assert_eq!(spec.clone_branch.as_deref(), Some("main")); - } - _ => panic!("expected Docker preflight sandbox spec"), - } + let spec = spec.expect("Docker preflight sandbox spec"); + assert_eq!(spec.kind, SandboxProviderKind::DOCKER); + assert!(spec.clone.skip); + assert_eq!( + spec.clone.origin_url.as_deref(), + Some("https://github.com/acme/widgets") + ); + assert_eq!(spec.clone.branch.as_deref(), Some("main")); } #[test] diff --git a/lib/apps/fabro-server/src/serve.rs b/lib/apps/fabro-server/src/serve.rs index 0c1c49aaf..dd174630e 100644 --- a/lib/apps/fabro-server/src/serve.rs +++ b/lib/apps/fabro-server/src/serve.rs @@ -799,7 +799,7 @@ where github_api_base_url: None, active_config_path, http_client: None, - sandbox_provider_registry: None, + sandbox_inventory: None, shutdown: shutdown.clone(), #[cfg(test)] worker_control_bus: None, diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index cf67745f0..83e409751 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -65,10 +65,7 @@ use fabro_redact::redact_jsonl_line; use fabro_sandbox::details::sandbox_details; use fabro_sandbox::driver::{DaytonaCredentials, ProviderAccess, ProviderConnectOptions}; use fabro_sandbox::reconnect::reconnect_for_run; -use fabro_sandbox::{ - DriverInventoryProvider, LocalSandboxProvider, SandboxProvider, SandboxProviderRegistry, - daytona, -}; +use fabro_sandbox::{SandboxInventory, daytona}; use fabro_slack::client::{PostedMessage as SlackPostedMessage, SlackClient}; use fabro_slack::config::{ SlackCredentialResolution, @@ -1137,7 +1134,7 @@ pub struct AppState { pub(crate) github_api_base_url: String, active_config_path: PathBuf, http_client: Option, - sandbox_provider_registry: SandboxProviderRegistry, + sandbox_inventory: SandboxInventory, shutdown: CancellationToken, shutting_down: AtomicBool, registry_factory_override: Option>, @@ -1282,7 +1279,7 @@ pub(crate) struct AppStateConfig { pub(crate) github_api_base_url: Option, pub(crate) active_config_path: PathBuf, pub(crate) http_client: Option, - pub(crate) sandbox_provider_registry: Option, + pub(crate) sandbox_inventory: Option, pub(crate) shutdown: CancellationToken, #[cfg(test)] pub(crate) worker_control_bus: Option>, @@ -1475,15 +1472,8 @@ impl AppState { /// the server's HTTP client. The process environment is consulted only /// through the configured lookup. pub(crate) fn daytona_credentials(&self, api_key: String) -> DaytonaCredentials { - DaytonaCredentials { - api_key, - api_url: self - .config_env_lookup(EnvVars::DAYTONA_API_URL) - .or_else(|| self.config_env_lookup(EnvVars::DAYTONA_SERVER_URL)), - organization_id: self.config_env_lookup(EnvVars::DAYTONA_ORGANIZATION_ID), - target: None, - http_client: self.http_client().ok(), - } + DaytonaCredentials::from_api_key(api_key, |name| self.config_env_lookup(name)) + .with_http_client(self.http_client().ok()) } /// Everything a reconnect needs to reach a run's provider: the server's @@ -1540,8 +1530,8 @@ impl AppState { &self.session_runtimes } - pub(crate) fn sandbox_provider_registry(&self) -> &SandboxProviderRegistry { - &self.sandbox_provider_registry + pub(crate) fn sandbox_inventory(&self) -> &SandboxInventory { + &self.sandbox_inventory } pub(crate) fn server_secret(&self, name: &str) -> Option { @@ -2338,51 +2328,45 @@ fn worker_token_keys_from_server_secrets( .map_err(|err| jwt_auth::session_secret_key_error(&err)) } -fn build_sandbox_provider_registry( +fn build_sandbox_inventory( server_settings: &ServerSettings, daytona_api_key: Option, env_lookup: &EnvLookup, http_client: Option, -) -> SandboxProviderRegistry { +) -> SandboxInventory { let provider_settings = &server_settings.server.sandbox.providers; - let mut providers: Vec> = Vec::new(); + let mut inventory = SandboxInventory::empty(); if provider_settings.is_enabled(&SandboxProviderKind::LOCAL) { - providers.push(Arc::new(LocalSandboxProvider)); + inventory = inventory.with_host_directories(SandboxProviderKind::LOCAL); } if let Some(docker) = provider_settings.get(&SandboxProviderKind::DOCKER) { if docker.enabled { - providers.push(Arc::new(DriverInventoryProvider::lazy( + inventory = inventory.with_lazy( SandboxProviderKind::DOCKER, docker.clone(), ProviderConnectOptions::default(), - ))); + ); } } if let Some(daytona) = provider_settings.get(&SandboxProviderKind::DAYTONA) { if let Some(api_key) = daytona_api_key.filter(|_| daytona.enabled) { - let credentials = DaytonaCredentials { - api_key, - api_url: env_lookup(EnvVars::DAYTONA_API_URL) - .or_else(|| env_lookup(EnvVars::DAYTONA_SERVER_URL)), - organization_id: env_lookup(EnvVars::DAYTONA_ORGANIZATION_ID), - target: None, - http_client, - }; - providers.push(Arc::new(DriverInventoryProvider::lazy( + let credentials = DaytonaCredentials::from_api_key(api_key, |name| env_lookup(name)) + .with_http_client(http_client); + inventory = inventory.with_lazy( SandboxProviderKind::DAYTONA, daytona.clone(), ProviderConnectOptions { host_registry_root: None, daytona: Some(credentials), }, - ))); + ); } } - SandboxProviderRegistry::new(providers) + inventory } pub(crate) fn automation_dir_for_active_config(active_config_path: &std::path::Path) -> PathBuf { @@ -2435,7 +2419,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result anyhow::Result anyhow::Result sandbox, Err(err) if force || delete_started => { tracing::warn!( diff --git a/lib/apps/fabro-server/src/server/handler/automations.rs b/lib/apps/fabro-server/src/server/handler/automations.rs index f8fbbbd79..6f59e3c8a 100644 --- a/lib/apps/fabro-server/src/server/handler/automations.rs +++ b/lib/apps/fabro-server/src/server/handler/automations.rs @@ -302,10 +302,9 @@ pub(in crate::server) fn resolve_automation_environment( )); } if !state - .sandbox_provider_registry() - .providers() - .iter() - .any(|sandbox_provider| sandbox_provider.kind() == provider) + .sandbox_inventory() + .kinds() + .any(|kind| *kind == provider) { return Err(ApiError::with_code( status, diff --git a/lib/apps/fabro-server/src/server/handler/sandbox.rs b/lib/apps/fabro-server/src/server/handler/sandbox.rs index 7cd27fa31..c76f8f155 100644 --- a/lib/apps/fabro-server/src/server/handler/sandbox.rs +++ b/lib/apps/fabro-server/src/server/handler/sandbox.rs @@ -1,19 +1,16 @@ use std::collections::BTreeMap; -use std::net::{Ipv4Addr, Ipv6Addr}; use std::num::NonZeroU64; use std::sync::Arc; use std::time::Duration; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use fabro_sandbox::{ - FileKind, ProviderAccess, RunSandbox, TerminalSize, open_terminal_for_run, - reconnect_driver_for_run, -}; -use fabro_types::{ - RunSandboxInstance, SandboxProviderKind, SandboxServiceDiscoverySource, SandboxServiceListMeta, + FileKind, ProviderAccess, PtySize, RunSandbox, open_terminal_for_run, reconnect_for_run, }; +use fabro_types::{RunSandboxInstance, SandboxProviderKind}; use futures_util::FutureExt; use futures_util::future::BoxFuture; +use sandbox_driver::{ListeningPort, Services as _}; use super::super::{ ApiError, AppState, Bytes, HeaderMap, IntoResponse, Json, NamedTempFile, Path, @@ -29,20 +26,7 @@ const DEFAULT_VNC_NO_VNC_PORT: u16 = 6080; const DEFAULT_VNC_TTL_SECS: i32 = 3600; /// Header a Daytona unsigned preview needs; surfaced as the response token. const PREVIEW_TOKEN_HEADER: &str = "x-daytona-preview-token"; -const LIST_SANDBOX_SERVICES_COMMAND: &str = r#"if command -v ss >/dev/null 2>&1; then - ss -H -ltnp && exit 0 -fi -printf 'FABRO_PROC_NET_TCP procfs\n' -for file in /proc/net/tcp /proc/net/tcp6; do - if [ -r "$file" ]; then - printf 'FABRO_PROC_NET_TCP %s\n' "$file" - while IFS= read -r line; do - printf '%s\n' "$line" - done < "$file" - fi -done"#; -const LIST_SANDBOX_SERVICES_FAILURE_LABEL: &str = "sandbox service discovery command"; -const LIST_SANDBOX_SERVICES_TIMEOUT_MS: u64 = 5_000; +const LIST_SANDBOX_SERVICES_FAILURE_LABEL: &str = "sandbox service discovery"; // Daytona's signed preview points at the noVNC service root, which serves a // directory listing. Force the iframe to the actual viewer page with // autoconnect+scale so the user lands on the desktop, not a file index. @@ -133,7 +117,7 @@ struct SandboxFileParams { #[derive(Debug, PartialEq, Eq)] enum TerminalClientMessage { - Resize(TerminalSize), + Resize(PtySize), Close, } @@ -150,7 +134,7 @@ fn parse_terminal_control_message(text: &str) -> Result(text) { Ok(TerminalClientControl::Resize { cols, rows }) if cols > 0 && rows > 0 => { - Ok(TerminalClientMessage::Resize(TerminalSize { cols, rows })) + Ok(TerminalClientMessage::Resize(PtySize { cols, rows })) } Ok(TerminalClientControl::Resize { .. }) => { Err("Terminal resize dimensions must be greater than zero.") @@ -235,19 +219,19 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc, id: Run return; } }; - let session = - match open_terminal_for_run(&record, &access, Some(id), TerminalSize::default()).await { - Ok(session) => session, - Err(err) => { - let _ = socket - .send(terminal_server_text( - "error", - Some(&err.display_with_causes()), - )) - .await; - return; - } - }; + let session = match open_terminal_for_run(&record, &access, Some(id), PtySize::default()).await + { + Ok(session) => session, + Err(err) => { + let _ = socket + .send(terminal_server_text( + "error", + Some(&err.display_with_causes()), + )) + .await; + return; + } + }; if socket .send(terminal_server_text("ready", None)) @@ -268,7 +252,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc, id: Run Ok(WsMessage::Binary(bytes)) => { if let Err(err) = session.write_input(&bytes).await { let _ = socket - .send(terminal_server_text("error", Some(&err.display_with_causes()))) + .send(terminal_server_text("error", Some(&fabro_sandbox::display_for_log(&err)))) .await; break; } @@ -278,7 +262,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc, id: Run Ok(TerminalClientMessage::Resize(size)) => { if let Err(err) = session.resize(size).await { let _ = socket - .send(terminal_server_text("error", Some(&err.display_with_causes()))) + .send(terminal_server_text("error", Some(&fabro_sandbox::display_for_log(&err)))) .await; break; } @@ -313,7 +297,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc, id: Run } Err(err) => { let _ = socket - .send(terminal_server_text("error", Some(&err.display_with_causes()))) + .send(terminal_server_text("error", Some(&fabro_sandbox::display_for_log(&err)))) .await; break; } @@ -322,7 +306,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc, id: Run } } if let Err(err) = session.close().await { - tracing::warn!(error = %err.display_with_causes(), run_id = %id, "failed to close run terminal session"); + tracing::warn!(error = %fabro_sandbox::display_for_log(&err), run_id = %id, "failed to close run terminal session"); } } @@ -587,143 +571,43 @@ async fn list_sandbox_services( Ok(sandbox) => sandbox, Err(response) => return response, }; - let result = match sandbox - .exec_command( - LIST_SANDBOX_SERVICES_COMMAND, - LIST_SANDBOX_SERVICES_TIMEOUT_MS, - None, - None, - None, - ) - .await - { - Ok(result) => result, + let services = match sandbox.services() { + Ok(services) => services, Err(err) => { - return ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response(); + return ApiError::new(StatusCode::NOT_IMPLEMENTED, err.display_with_causes()) + .into_response(); + } + }; + let ports = match services.listening_ports().await { + Ok(ports) => ports, + Err(err) => { + return ApiError::new( + StatusCode::CONFLICT, + format!("{LIST_SANDBOX_SERVICES_FAILURE_LABEL} failed: {err}"), + ) + .into_response(); } }; - if !result.is_success() { - return ApiError::new( - StatusCode::CONFLICT, - sandbox_service_command_failure_detail(&result), - ) - .into_response(); - } - - let discovery = parse_sandbox_services(&result.stdout, &provider); Json(SandboxServiceListResponse { - data: discovery.services, - meta: SandboxServiceListMeta { - source: discovery.source, - }, + data: services_from_ports(ports, &provider), }) .into_response() } -fn sandbox_service_command_failure_detail(result: &fabro_sandbox::ExecResult) -> String { - let stderr = result.stderr.trim(); - if !stderr.is_empty() { - return stderr.to_string(); - } - let stdout = result.stdout.trim(); - if !stdout.is_empty() { - return stdout.to_string(); - } - format!("{LIST_SANDBOX_SERVICES_FAILURE_LABEL} failed") -} - -struct SandboxServiceDiscovery { - services: Vec, - source: SandboxServiceDiscoverySource, -} - -fn parse_sandbox_services(output: &str, provider: &SandboxProviderKind) -> SandboxServiceDiscovery { - if output - .lines() - .any(|line| line.trim_start().starts_with("FABRO_PROC_NET_TCP ")) - { - SandboxServiceDiscovery { - services: parse_proc_net_listening_services(output, provider), - source: SandboxServiceDiscoverySource::Procfs, - } - } else { - SandboxServiceDiscovery { - services: parse_ss_listening_services(output, provider), - source: SandboxServiceDiscoverySource::Ss, - } - } -} - -fn parse_ss_listening_services( - output: &str, +/// The driver's listeners grouped by port, previewable ports first. +fn services_from_ports( + ports: Vec, provider: &SandboxProviderKind, ) -> Vec { let mut services = BTreeMap::::new(); - for line in output - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - { - let fields = line.split_whitespace().collect::>(); - let Some(address) = fields.get(3).copied() else { - continue; - }; - let Some(port) = parse_ss_local_port(address) else { - continue; - }; - let process = (fields.len() > 5).then(|| fields[5..].join(" ")); - push_service(&mut services, provider, port, address.to_string(), process); - } - sorted_services(services) -} - -fn parse_ss_local_port(address: &str) -> Option { - let port = address.rsplit_once(':')?.1.parse::().ok()?; - (port > 0).then_some(port) -} - -#[derive(Clone, Copy)] -enum ProcNetFamily { - Ipv4, - Ipv6, -} - -fn parse_proc_net_listening_services( - output: &str, - provider: &SandboxProviderKind, -) -> Vec { - let mut services = BTreeMap::::new(); - let mut family = None; - for line in output - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - { - if let Some(path) = line.strip_prefix("FABRO_PROC_NET_TCP ") { - family = if path.ends_with("/tcp6") { - Some(ProcNetFamily::Ipv6) - } else { - Some(ProcNetFamily::Ipv4) - }; - continue; - } - if line.starts_with("sl") { - continue; - } - let Some(family) = family else { - continue; - }; - let fields = line.split_whitespace().collect::>(); - let (Some(local_address), Some(state)) = (fields.get(1), fields.get(3)) else { - continue; - }; - if *state != "0A" { - continue; - } - let Some((address, port)) = parse_proc_net_local_address(local_address, family) else { - continue; - }; - push_service(&mut services, provider, port, address, None); + for listener in ports { + push_service( + &mut services, + provider, + listener.port, + listener.address, + listener.process, + ); } sorted_services(services) } @@ -734,40 +618,6 @@ fn sorted_services(services: BTreeMap) -> Vec Option<(String, u16)> { - let (address_hex, port_hex) = value.split_once(':')?; - let port = u16::from_str_radix(port_hex, 16).ok()?; - if port == 0 { - return None; - } - let address = match family { - ProcNetFamily::Ipv4 => format!("{}:{port}", parse_proc_net_ipv4(address_hex)?), - ProcNetFamily::Ipv6 => format!("[{}]:{port}", parse_proc_net_ipv6(address_hex)?), - }; - Some((address, port)) -} - -fn parse_proc_net_ipv4(value: &str) -> Option { - if value.len() != 8 { - return None; - } - let raw = u32::from_str_radix(value, 16).ok()?; - Some(Ipv4Addr::from(raw.to_le_bytes())) -} - -fn parse_proc_net_ipv6(value: &str) -> Option { - if value.len() != 32 { - return None; - } - let mut bytes = [0_u8; 16]; - for (chunk_index, chunk) in value.as_bytes().chunks_exact(8).enumerate() { - let chunk = std::str::from_utf8(chunk).ok()?; - let raw = u32::from_str_radix(chunk, 16).ok()?; - bytes[chunk_index * 4..chunk_index * 4 + 4].copy_from_slice(&raw.to_le_bytes()); - } - Some(Ipv6Addr::from(bytes)) -} - fn push_service( services: &mut BTreeMap, provider: &SandboxProviderKind, @@ -885,7 +735,7 @@ async fn reconnect_run_sandbox_instance( record: &RunSandboxInstance, ) -> Result { let access = load_provider_access(state).await?; - let sandbox = reconnect_driver_for_run(record, &access, Some(*run_id), None) + let sandbox = reconnect_for_run(record, &access, Some(*run_id), None) .await .map_err(|err| { let detail = render_with_causes(&err.to_string(), &collect_causes(err.as_ref())); @@ -934,7 +784,7 @@ mod tests { fn terminal_control_accepts_resize_and_close() { assert_eq!( parse_terminal_control_message(r#"{"type":"resize","cols":120,"rows":32}"#), - Ok(TerminalClientMessage::Resize(TerminalSize { + Ok(TerminalClientMessage::Resize(PtySize { cols: 120, rows: 32, })) @@ -1014,104 +864,28 @@ mod tests { } #[test] - fn ss_parser_extracts_addresses_processes_and_preview_support() { - let services = parse_ss_listening_services( - r#" -LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=42,fd=23)) -LISTEN 0 4096 0.0.0.0:5173 0.0.0.0:* users:(("vite",pid=84,fd=19)) -LISTEN 0 4096 [::]:8080 [::]:* users:(("server",pid=126,fd=9)) -LISTEN 0 4096 [::1]:2500 [::]:* users:(("debug",pid=168,fd=7)) -"#, - &SandboxProviderKind::DAYTONA, - ); - - assert_eq!(services.len(), 4); - assert_eq!(services[0].port, 3000); - assert_eq!(services[0].addresses, vec!["127.0.0.1:3000"]); - assert_eq!(services[0].processes, vec![ - r#"users:(("node",pid=42,fd=23))"# - ]); - assert!(services[0].preview_supported); - assert_eq!(services[1].port, 5173); - assert_eq!(services[1].addresses, vec!["0.0.0.0:5173"]); - assert!(services[1].preview_supported); - assert_eq!(services[2].port, 8080); - assert_eq!(services[2].addresses, vec!["[::]:8080"]); - assert!(services[2].preview_supported); - assert_eq!(services[3].port, 2500); - assert_eq!(services[3].addresses, vec!["[::1]:2500"]); - assert_eq!(services[3].processes, vec![ - r#"users:(("debug",pid=168,fd=7))"# - ]); - assert!(!services[3].preview_supported); - } - - #[test] - fn ss_parser_ignores_malformed_and_non_numeric_ports() { - let services = parse_ss_listening_services( - r#" -LISTEN 0 4096 127.0.0.1:not-a-port 0.0.0.0:* users:(("node",pid=42,fd=23)) -LISTEN 0 4096 missing-peer -not enough fields -LISTEN 0 4096 127.0.0.1:0 0.0.0.0:* users:(("zero",pid=1,fd=2)) -LISTEN 0 4096 127.0.0.1:65536 0.0.0.0:* users:(("large",pid=1,fd=2)) -"#, - &SandboxProviderKind::DAYTONA, - ); - - assert!(services.is_empty()); - } - - #[test] - fn ss_parser_groups_duplicate_ports_and_deduplicates_values() { - let services = parse_ss_listening_services( - r#" -LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=42,fd=23)) -LISTEN 0 4096 0.0.0.0:3000 0.0.0.0:* users:(("node",pid=42,fd=23)) -LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=42,fd=23)) -LISTEN 0 4096 [::]:3000 [::]:* users:(("vite",pid=84,fd=19)) -"#, - &SandboxProviderKind::DAYTONA, - ); - - assert_eq!(services, vec![SandboxService { - port: 3000, - addresses: vec![ - "127.0.0.1:3000".to_string(), - "0.0.0.0:3000".to_string(), - "[::]:3000".to_string(), + fn listening_ports_group_by_port_and_sort_previewable_first() { + let mut node = ListeningPort::new(3000, "127.0.0.1:3000"); + node.process = Some("node".to_string()); + let mut node_v6 = ListeningPort::new(3000, "[::]:3000"); + node_v6.process = Some("node".to_string()); + let mut debug = ListeningPort::new(2500, "[::1]:2500"); + debug.process = Some("pid=168".to_string()); + let services = services_from_ports( + vec![ + debug, + node, + node_v6, + ListeningPort::new(5173, "0.0.0.0:5173"), ], - processes: vec![ - r#"users:(("node",pid=42,fd=23))"#.to_string(), - r#"users:(("vite",pid=84,fd=19))"#.to_string(), - ], - preview_supported: true, - }]); - } - - #[test] - fn proc_net_parser_extracts_listening_tcp_services_without_processes() { - let discovery = parse_sandbox_services( - r" -FABRO_PROC_NET_TCP /proc/net/tcp - sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode - 0: 0100007F:0BB8 00000000:0000 0A 00000000:00000000 00:00000000 00000000 501 0 11111 - 1: 00000000:1435 00000000:0000 0A 00000000:00000000 00:00000000 00000000 501 0 22222 - 2: 0100007F:2328 00000000:0000 01 00000000:00000000 00:00000000 00000000 501 0 33333 -FABRO_PROC_NET_TCP /proc/net/tcp6 - sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode - 0: 00000000000000000000000000000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 501 0 44444 - 1: 00000000000000000000000001000000:09C4 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 501 0 55555 -", &SandboxProviderKind::DAYTONA, ); - assert_eq!(discovery.source, SandboxServiceDiscoverySource::Procfs); - assert_eq!(discovery.services, vec![ + assert_eq!(services, vec![ SandboxService { port: 3000, - addresses: vec!["127.0.0.1:3000".to_string()], - processes: vec![], + addresses: vec!["127.0.0.1:3000".to_string(), "[::]:3000".to_string()], + processes: vec!["node".to_string()], preview_supported: true, }, SandboxService { @@ -1120,16 +894,10 @@ FABRO_PROC_NET_TCP /proc/net/tcp6 processes: vec![], preview_supported: true, }, - SandboxService { - port: 8080, - addresses: vec!["[::]:8080".to_string()], - processes: vec![], - preview_supported: true, - }, SandboxService { port: 2500, addresses: vec!["[::1]:2500".to_string()], - processes: vec![], + processes: vec!["pid=168".to_string()], preview_supported: false, }, ]); @@ -1144,33 +912,6 @@ FABRO_PROC_NET_TCP /proc/net/tcp6 assert!(!preview_supported(&SandboxProviderKind::DOCKER, 3000)); } - #[test] - fn sandbox_service_command_failure_prefers_stderr_then_stdout() { - let mut result = fabro_sandbox::ExecResult { - stdout: "stdout detail".to_string(), - stderr: "stderr detail".to_string(), - exit_code: Some(127), - termination: fabro_types::CommandTermination::Exited, - duration_ms: 10, - }; - assert_eq!( - sandbox_service_command_failure_detail(&result), - "stderr detail" - ); - - result.stderr.clear(); - assert_eq!( - sandbox_service_command_failure_detail(&result), - "stdout detail" - ); - - result.stdout.clear(); - assert_eq!( - sandbox_service_command_failure_detail(&result), - "sandbox service discovery command failed" - ); - } - struct FakeVncSandbox { error: Option<&'static str>, viewer_url: &'static str, @@ -1270,6 +1011,7 @@ FABRO_PROC_NET_TCP /proc/net/tcp6 mod retrieve_sandbox_tests { use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; + use fabro_sandbox::test_support::local_sandbox_id; use fabro_types::{Graph, RunId, WorkflowSettings, test_support}; use serde_json::{Value, json}; use tower::ServiceExt; @@ -1322,6 +1064,26 @@ mod retrieve_sandbox_tests { run_store: &fabro_store::RunDatabase, run_id: &RunId, provider: &str, + ) { + append_sandbox_initialized_in( + run_store, + run_id, + provider, + &format!("{provider}:sandbox-id"), + "/workspace", + ) + .await; + } + + /// A local sandbox reconnects by the id the Host provider derives from + /// its working directory, so a test that reaches one records an + /// existing directory under the id fabro would have written for it. + async fn append_sandbox_initialized_in( + run_store: &fabro_store::RunDatabase, + run_id: &RunId, + provider: &str, + id: &str, + working_directory: &str, ) { let payload = fabro_store::EventPayload::new( json!({ @@ -1331,8 +1093,8 @@ mod retrieve_sandbox_tests { "event": "sandbox.initialized", "properties": { "provider": provider, - "id": format!("{provider}:sandbox-id"), - "working_directory": "/workspace", + "id": id, + "working_directory": working_directory, }, }), run_id, @@ -1466,7 +1228,10 @@ mod retrieve_sandbox_tests { .await .expect("test run should be creatable"); append_run_created(&run_store, &run_id).await; - append_sandbox_initialized(&run_store, &run_id, "local").await; + let workspace = tempfile::tempdir().expect("scratch directory"); + let working_directory = workspace.path().to_str().expect("utf-8").to_owned(); + let id = local_sandbox_id(workspace.path()).await; + append_sandbox_initialized_in(&run_store, &run_id, "local", &id, &working_directory).await; let response = app .oneshot(req_get(&format!("/api/v1/runs/{run_id}/sandbox"))) @@ -1475,18 +1240,22 @@ mod retrieve_sandbox_tests { assert_eq!(response.status(), StatusCode::OK); let body = body_json(response).await; assert_eq!(body["sandbox"]["provider"], "local"); - assert_eq!(body["sandbox"]["runtime"]["id"], "local:sandbox-id"); + assert_eq!(body["sandbox"]["runtime"]["id"], id); assert_eq!( body["sandbox"]["runtime"]["working_directory"], - "/workspace" + working_directory ); - assert_eq!(body["state"], "running"); - assert!(body.get("name").is_none()); + assert_eq!(body["status"]["state"], "running"); + assert_eq!(body["status"]["workspace_ownership"], "designated"); + assert!( + body["status"]["id"] + .as_str() + .is_some_and(|id| id.starts_with("host-dir-")), + "{}", + body["status"]["id"] + ); + assert!(body.get("state").is_none(), "the status is not flattened"); assert!(body.get("identifier").is_none()); - assert!(body["resources"].is_object()); - assert_eq!(body["network"]["egress"]["mode"], "unknown"); - assert_eq!(body["network"]["ingress"]["mode"], "unknown"); - assert!(body["timestamps"].is_object()); } #[tokio::test] @@ -1500,7 +1269,15 @@ mod retrieve_sandbox_tests { .await .expect("test run should be creatable"); append_run_created(&run_store, &run_id).await; - append_sandbox_initialized(&run_store, &run_id, "local").await; + let workspace = tempfile::tempdir().expect("scratch directory"); + append_sandbox_initialized_in( + &run_store, + &run_id, + "local", + &local_sandbox_id(workspace.path()).await, + workspace.path().to_str().expect("utf-8"), + ) + .await; let response = app .oneshot(req_post(&format!("/api/v1/runs/{run_id}/sandbox/vnc"))) diff --git a/lib/apps/fabro-server/src/server/handler/sandboxes.rs b/lib/apps/fabro-server/src/server/handler/sandboxes.rs index 0a630648d..d8b92cde2 100644 --- a/lib/apps/fabro-server/src/server/handler/sandboxes.rs +++ b/lib/apps/fabro-server/src/server/handler/sandboxes.rs @@ -21,7 +21,7 @@ async fn list_sandboxes( State(state): State>, _auth: RequiredRunManagementActor, ) -> Json { - Json(state.sandbox_provider_registry().list_managed().await) + Json(state.sandbox_inventory().list_managed().await) } async fn retrieve_sandbox( @@ -30,7 +30,7 @@ async fn retrieve_sandbox( _auth: RequiredRunManagementActor, ) -> Result, ApiError> { state - .sandbox_provider_registry() + .sandbox_inventory() .get_managed_by_native_id(&id) .await .map(Json) @@ -79,23 +79,49 @@ fn provider_list(providers: &[SandboxProviderKind]) -> String { mod tests { use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; - use fabro_sandbox::SandboxProviderRegistry; - use fabro_sandbox::test_support::{ - FakeGet, FakeList, FakeSandboxProvider, fake_registry, fake_sandbox_info, - }; + use fabro_sandbox::SandboxInventory; + use fabro_sandbox::driver::{ConnectedProvider, ProviderConnectOptions}; + use fabro_sandbox::test_support::{managed_scripted_sandbox, scripted_inventory_provider}; use fabro_types::SandboxProviderKind; + use fabro_types::settings::server::{SandboxPluginSettings, ServerSandboxProviderSettings}; use serde_json::{Value, json}; use tower::ServiceExt; use crate::test_support::{TestAppStateBuilder, build_test_router}; - fn app_with_registry(registry: SandboxProviderRegistry) -> axum::Router { + fn app_with_inventory(inventory: SandboxInventory) -> axum::Router { let state = TestAppStateBuilder::new() - .sandbox_provider_registry(registry) + .sandbox_inventory(inventory) .build(); build_test_router(state) } + /// A connected provider of `kind` holding fabro-managed sandboxes `ids`. + fn provider(kind: SandboxProviderKind, ids: &[&str]) -> ConnectedProvider { + scripted_inventory_provider( + kind, + ids.iter().map(|id| managed_scripted_sandbox(id)).collect(), + ) + } + + /// A plugin kind whose executable does not exist, so every lookup fails + /// to connect. + fn with_unreachable_plugin(inventory: SandboxInventory, name: &str) -> SandboxInventory { + let settings = ServerSandboxProviderSettings { + enabled: true, + plugin: Some(SandboxPluginSettings { + path: Some(format!("/nonexistent/fabro-sandbox-{name}")), + dev: true, + ..SandboxPluginSettings::default() + }), + }; + inventory.with_lazy( + SandboxProviderKind::try_new(name).expect("valid kind"), + settings, + ProviderConnectOptions::default(), + ) + } + fn req_get(uri: &str) -> Request { Request::builder() .method("GET") @@ -113,37 +139,28 @@ mod tests { #[tokio::test] async fn list_returns_provider_backed_data_without_run_projection_state() { - let docker = fake_sandbox_info(SandboxProviderKind::DOCKER, "docker-native-id"); - let app = app_with_registry(fake_registry(vec![FakeSandboxProvider::new( - SandboxProviderKind::DOCKER, - FakeList::Ok(vec![docker]), - FakeGet::Missing, - )])); + let app = app_with_inventory( + SandboxInventory::empty() + .with_connected(provider(SandboxProviderKind::DOCKER, &["docker-native-id"])), + ); let response = app.oneshot(req_get("/api/v1/sandboxes")).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = body_json(response).await; - assert_eq!(body["data"][0]["id"], "docker-native-id"); + assert_eq!(body["data"][0]["status"]["id"], "docker-native-id"); assert_eq!(body["data"][0]["provider"], "docker"); + assert_eq!(body["data"][0]["status"]["state"], "running"); assert_eq!(body["meta"]["provider_errors"], json!([])); } #[tokio::test] async fn retrieve_searches_all_configured_providers() { - let daytona = fake_sandbox_info(SandboxProviderKind::DAYTONA, "native-id"); - let app = app_with_registry(fake_registry(vec![ - FakeSandboxProvider::new( - SandboxProviderKind::DOCKER, - FakeList::Ok(Vec::new()), - FakeGet::Missing, - ), - FakeSandboxProvider::new( - SandboxProviderKind::DAYTONA, - FakeList::Ok(Vec::new()), - FakeGet::Found(Box::new(daytona)), - ), - ])); + let app = app_with_inventory( + SandboxInventory::empty() + .with_connected(provider(SandboxProviderKind::DOCKER, &[])) + .with_connected(provider(SandboxProviderKind::DAYTONA, &["native-id"])), + ); let response = app .oneshot(req_get("/api/v1/sandboxes/native-id")) @@ -152,24 +169,17 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); let body = body_json(response).await; - assert_eq!(body["id"], "native-id"); + assert_eq!(body["status"]["id"], "native-id"); assert_eq!(body["provider"], "daytona"); } #[tokio::test] async fn no_matching_sandbox_returns_404() { - let app = app_with_registry(fake_registry(vec![ - FakeSandboxProvider::new( - SandboxProviderKind::DOCKER, - FakeList::Ok(Vec::new()), - FakeGet::Missing, - ), - FakeSandboxProvider::new( - SandboxProviderKind::DAYTONA, - FakeList::Ok(Vec::new()), - FakeGet::Missing, - ), - ])); + let app = app_with_inventory( + SandboxInventory::empty() + .with_connected(provider(SandboxProviderKind::DOCKER, &[])) + .with_connected(provider(SandboxProviderKind::DAYTONA, &[])), + ); let response = app .oneshot(req_get("/api/v1/sandboxes/missing")) @@ -181,24 +191,11 @@ mod tests { #[tokio::test] async fn duplicate_native_ids_return_409() { - let app = app_with_registry(fake_registry(vec![ - FakeSandboxProvider::new( - SandboxProviderKind::DOCKER, - FakeList::Ok(Vec::new()), - FakeGet::Found(Box::new(fake_sandbox_info( - SandboxProviderKind::DOCKER, - "same-id", - ))), - ), - FakeSandboxProvider::new( - SandboxProviderKind::DAYTONA, - FakeList::Ok(Vec::new()), - FakeGet::Found(Box::new(fake_sandbox_info( - SandboxProviderKind::DAYTONA, - "same-id", - ))), - ), - ])); + let app = app_with_inventory( + SandboxInventory::empty() + .with_connected(provider(SandboxProviderKind::DOCKER, &["same-id"])) + .with_connected(provider(SandboxProviderKind::DAYTONA, &["same-id"])), + ); let response = app .oneshot(req_get("/api/v1/sandboxes/same-id")) @@ -217,18 +214,10 @@ mod tests { #[tokio::test] async fn provider_lookup_uncertainty_returns_502() { - let app = app_with_registry(fake_registry(vec![ - FakeSandboxProvider::new( - SandboxProviderKind::DOCKER, - FakeList::Ok(Vec::new()), - FakeGet::Missing, - ), - FakeSandboxProvider::new( - SandboxProviderKind::DAYTONA, - FakeList::Ok(Vec::new()), - FakeGet::Err("daytona unavailable"), - ), - ])); + let app = app_with_inventory(with_unreachable_plugin( + SandboxInventory::empty().with_connected(provider(SandboxProviderKind::DOCKER, &[])), + "e2b", + )); let response = app .oneshot(req_get("/api/v1/sandboxes/maybe-missing")) @@ -241,7 +230,7 @@ mod tests { body["errors"][0]["detail"] .as_str() .unwrap_or_default() - .contains("daytona unavailable") + .contains("e2b: Failed to connect to the e2b provider") ); } } diff --git a/lib/apps/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs index 20f19abbd..3f63337b5 100644 --- a/lib/apps/fabro-server/src/server/handler/sessions.rs +++ b/lib/apps/fabro-server/src/server/handler/sessions.rs @@ -723,7 +723,7 @@ async fn build_agent( .provider_access() .await .map_err(|err| AskFabroBuildError::Agent(anyhow::Error::new(err)))?; - let sandbox = reconnect_for_run(sandbox_instance, &access, Some(run_id)) + let sandbox = reconnect_for_run(sandbox_instance, &access, Some(run_id), None) .await .map_err(AskFabroBuildError::SandboxUnavailable)?; sandbox diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index 54ab13f06..3be6e1a29 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -2107,7 +2107,7 @@ fn slack_app_state_with_settings_and_secret_sources( github_api_base_url: None, active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"), http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), - sandbox_provider_registry: None, + sandbox_inventory: None, shutdown: tokio_util::sync::CancellationToken::new(), worker_control_bus: None, worker_runtime: None, @@ -2268,7 +2268,7 @@ fn slack_service_respects_disabled_server_config_even_with_vault_tokens() { github_api_base_url: None, active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"), http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), - sandbox_provider_registry: None, + sandbox_inventory: None, shutdown: tokio_util::sync::CancellationToken::new(), worker_control_bus: None, worker_runtime: None, @@ -2622,7 +2622,7 @@ methods = ["dev-token"] github_api_base_url: None, active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"), http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), - sandbox_provider_registry: None, + sandbox_inventory: None, shutdown: tokio_util::sync::CancellationToken::new(), worker_control_bus: None, worker_runtime: None, @@ -8457,7 +8457,7 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings( github_api_base_url, active_config_path, http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), - sandbox_provider_registry: None, + sandbox_inventory: None, shutdown: tokio_util::sync::CancellationToken::new(), worker_control_bus: None, worker_runtime: None, diff --git a/lib/apps/fabro-server/src/test_support.rs b/lib/apps/fabro-server/src/test_support.rs index 8522206c1..0f6b0e2d5 100644 --- a/lib/apps/fabro-server/src/test_support.rs +++ b/lib/apps/fabro-server/src/test_support.rs @@ -19,7 +19,7 @@ use fabro_config::{LlmLayer, RunLayer, ServerSettingsBuilder, Storage, envfile}; use fabro_db::DbPool; use fabro_interview::Interviewer; use fabro_llm::lithos_catalog::Catalog; -use fabro_sandbox::SandboxProviderRegistry; +use fabro_sandbox::SandboxInventory; use fabro_static::EnvVars; use fabro_store::{ArtifactStore, Database, test_support as store_test_support}; use fabro_types::settings::ServerAuthMethod; @@ -90,7 +90,7 @@ pub struct TestAppStateBuilder { manifest_run_defaults: RunLayer, max_concurrent_runs: usize, registry_factory_override: Option>, - sandbox_provider_registry: Option, + sandbox_inventory: Option, store_bundle: Option<(Arc, ArtifactStore)>, vault_path: Option, vault_entries: Vec<(String, String)>, @@ -112,7 +112,7 @@ impl Default for TestAppStateBuilder { manifest_run_defaults: RunLayer::default(), max_concurrent_runs: 5, registry_factory_override: None, - sandbox_provider_registry: None, + sandbox_inventory: None, store_bundle: None, vault_path: None, vault_entries: Vec::new(), @@ -160,11 +160,8 @@ impl TestAppStateBuilder { self } - pub fn sandbox_provider_registry( - mut self, - sandbox_provider_registry: SandboxProviderRegistry, - ) -> Self { - self.sandbox_provider_registry = Some(sandbox_provider_registry); + pub fn sandbox_inventory(mut self, sandbox_inventory: SandboxInventory) -> Self { + self.sandbox_inventory = Some(sandbox_inventory); self } @@ -312,7 +309,7 @@ impl TestAppStateBuilder { http_client: Some( fabro_http::test_http_client().expect("test HTTP client should build"), ), - sandbox_provider_registry: self.sandbox_provider_registry, + sandbox_inventory: self.sandbox_inventory, shutdown: CancellationToken::new(), #[cfg(test)] worker_control_bus: None, diff --git a/lib/components/fabro-acp/src/transport.rs b/lib/components/fabro-acp/src/transport.rs index a479bacde..39636fb90 100644 --- a/lib/components/fabro-acp/src/transport.rs +++ b/lib/components/fabro-acp/src/transport.rs @@ -10,9 +10,9 @@ use agent_client_protocol::{ }; use fabro_sandbox::{ DEFAULT_EXEC_OUTPUT_TAIL_BYTES, Error as SandboxError, Result as SandboxResult, RunSandbox, - StderrCollector, StdioProcessHandle, StdioProcessTermination, + StderrTail, StdioProcessHandle, Termination, command_termination, program_exit_code, }; -use fabro_types::{CommandTermination, ExecOutputTail}; +use fabro_types::ExecOutputTail; use futures::io::BufReader; use futures::sink::unfold; use futures::{AsyncBufReadExt, AsyncWriteExt, Stream}; @@ -27,8 +27,8 @@ const CLEAN_EXIT_PROTOCOL_GRACE: Duration = Duration::from_millis(500); #[derive(Clone)] pub(crate) struct TransportState { - handle: Arc>>, - stderr: Arc>>, + handle: Arc>>>, + stderr: Arc>>, startup_error: Arc>>, process_exit: Arc>>, } @@ -43,7 +43,7 @@ impl TransportState { } } - async fn set_process(&self, handle: StdioProcessHandle, stderr: StderrCollector) { + async fn set_process(&self, handle: Arc, stderr: StderrTail) { *self.handle.lock().await = Some(handle); *self.stderr.lock().await = Some(stderr); } @@ -52,10 +52,15 @@ impl TransportState { *self.startup_error.lock().await = Some(error); } - async fn set_process_exit(&self, termination: StdioProcessTermination, stderr: &str) { + async fn set_process_exit( + &self, + termination: Termination, + exit_code: Option, + stderr: &str, + ) { *self.process_exit.lock().await = Some(AcpProcessExit { - termination: termination.termination, - exit_code: termination.exit_code, + termination: command_termination(termination), + exit_code: program_exit_code(termination, exit_code), exec_output_tail: redacted_stderr_tail(stderr), }); } @@ -70,14 +75,14 @@ impl TransportState { pub(crate) async fn terminate(&self) -> SandboxResult<()> { if let Some(handle) = self.handle.lock().await.as_ref().cloned() { - handle.terminate().await?; + handle.terminate().await; } Ok(()) } pub(crate) async fn stderr_tail(&self) -> String { if let Some(stderr) = self.stderr.lock().await.as_ref().cloned() { - return stderr.tail_string().await; + return stderr.to_string_lossy(); } String::new() } @@ -125,7 +130,6 @@ impl ConnectTo for SandboxAcpTransport { &self.command.to_shell_command(), Some(&self.cwd), Some(&env), - None, ) .await { @@ -136,9 +140,11 @@ impl ConnectTo for SandboxAcpTransport { } }; - let handle = process.handle.clone(); - let stderr = process.stderr.clone(); - self.state.set_process(handle.clone(), stderr.clone()).await; + let handle: Arc = Arc::from(process.handle); + let stderr = process.stderr_tail.clone(); + self.state + .set_process(Arc::clone(&handle), stderr.clone()) + .await; let incoming_lines = Box::pin(BufReader::new(process.stdout.compat()).lines()) as Pin> + Send>>; @@ -158,25 +164,20 @@ impl ConnectTo for SandboxAcpTransport { )); tokio::select! { result = &mut protocol => { - if let Err(err) = handle.terminate().await { - tracing::warn!(error = %err, "Failed to terminate ACP process after protocol completion"); - } + handle.terminate().await; let _ = timeout(Duration::from_millis(500), handle.wait()).await; result } - termination = handle.wait() => { - let termination = termination.map_err(ProtocolError::into_internal_error)?; - let stderr = stderr.tail_string().await; - if termination.termination == CommandTermination::Exited - && termination.exit_code == Some(0) - { + (termination, exit_code) = handle.wait() => { + let stderr = stderr.to_string_lossy(); + if termination == Termination::Exited && exit_code == Some(0) { // Stdio agents commonly exit immediately after writing their final response. // Process wait can observe that exit before the line reader drains stdout. if let Ok(result) = timeout(CLEAN_EXIT_PROTOCOL_GRACE, &mut protocol).await { return result; } } - self.state.set_process_exit(termination, &stderr).await; + self.state.set_process_exit(termination, exit_code, &stderr).await; Err(process_exited_before_protocol_completed()) } } diff --git a/lib/components/fabro-acp/tests/session.rs b/lib/components/fabro-acp/tests/session.rs index a98c45c76..1fcdb4f51 100644 --- a/lib/components/fabro-acp/tests/session.rs +++ b/lib/components/fabro-acp/tests/session.rs @@ -10,8 +10,9 @@ use fabro_acp::{ run_acp_turn, }; use fabro_sandbox::test_support::{MockSandbox, MockStdioProcess}; -use fabro_sandbox::{RunSandbox, local_sandbox, shell_quote}; +use fabro_sandbox::{RunSandbox, local_sandbox}; use fabro_util::error::collect_chain; +use fabro_util::shell; use pebble_coding_agent::SteeringMessage; use tokio::fs::{read_to_string, write}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream}; @@ -104,7 +105,10 @@ async fn session_lifecycle_initializes_sends_prompt_and_aggregates_text() { .await .expect("write fake ACP agent"); - let raw_command = format!("python3 {}", shell_quote(&script_path.to_string_lossy())); + let raw_command = format!( + "python3 {}", + shell::shell_quote(&script_path.to_string_lossy()) + ); let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command"); let sandbox: Arc = Arc::new( local_sandbox(tempdir.path().to_path_buf()) @@ -149,7 +153,10 @@ async fn steering_sends_followup_session_prompt_over_acp() { .await .expect("write fake ACP agent"); - let raw_command = format!("python3 {}", shell_quote(&script_path.to_string_lossy())); + let raw_command = format!( + "python3 {}", + shell::shell_quote(&script_path.to_string_lossy()) + ); let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command"); let sandbox: Arc = Arc::new( local_sandbox(tempdir.path().to_path_buf()) @@ -215,7 +222,10 @@ async fn interrupt_then_steer_sends_cancel_then_followup_session_prompt_over_acp .await .expect("write fake ACP agent"); - let raw_command = format!("python3 {}", shell_quote(&script_path.to_string_lossy())); + let raw_command = format!( + "python3 {}", + shell::shell_quote(&script_path.to_string_lossy()) + ); let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command"); let sandbox: Arc = Arc::new( local_sandbox(tempdir.path().to_path_buf()) @@ -292,7 +302,10 @@ async fn inline_interrupt_terminates_agent_that_ignores_cancel() { .await .expect("write fake ACP agent"); - let raw_command = format!("python3 {}", shell_quote(&script_path.to_string_lossy())); + let raw_command = format!( + "python3 {}", + shell::shell_quote(&script_path.to_string_lossy()) + ); let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command"); let sandbox: Arc = Arc::new( local_sandbox(tempdir.path().to_path_buf()) @@ -683,7 +696,10 @@ async fn run_fake_agent_with_activity( write(&script_path, fake_acp_agent_script()) .await .expect("write fake ACP agent"); - let raw_command = format!("python3 {}", shell_quote(&script_path.to_string_lossy())); + let raw_command = format!( + "python3 {}", + shell::shell_quote(&script_path.to_string_lossy()) + ); let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command"); let sandbox: Arc = Arc::new( local_sandbox(tempdir.to_path_buf()) diff --git a/lib/components/fabro-hooks/src/executor.rs b/lib/components/fabro-hooks/src/executor.rs index a054d7d96..0bd6fac70 100644 --- a/lib/components/fabro-hooks/src/executor.rs +++ b/lib/components/fabro-hooks/src/executor.rs @@ -8,7 +8,7 @@ use fabro_llm::credentials::CredentialProvider; use fabro_llm::lithos_catalog::Catalog; use fabro_llm::{Client, ClientOptions, Request}; use fabro_redact::redacted_url_for_log; -use fabro_sandbox::{RunSandbox, SecretRedactor}; +use fabro_sandbox::{ExecResultExt as _, RunSandbox, SecretRedactor}; use fabro_types::PermissionLevel; use fabro_types::settings::{InterpString, ResolveCtx, ResolveError}; use pebble_coding_agent::extensions::{ @@ -189,7 +189,10 @@ impl HookExecutorImpl { ) .await { - Ok(result) => Self::parse_decision(result.exit_code.unwrap_or(-1), &result.stdout), + Ok(result) => Self::parse_decision( + result.program_exit_code().unwrap_or(-1), + &result.stdout_lossy(), + ), Err(e) => HookDecision::Block { reason: Some(format!("sandbox exec failed: {e}")), }, diff --git a/lib/components/fabro-sandbox/Cargo.toml b/lib/components/fabro-sandbox/Cargo.toml index 7f8524ab2..093047e01 100644 --- a/lib/components/fabro-sandbox/Cargo.toml +++ b/lib/components/fabro-sandbox/Cargo.toml @@ -38,9 +38,6 @@ strum.workspace = true tracing.workspace = true reqwest.workspace = true base64.workspace = true -hmac.workspace = true -sha2.workspace = true -hex.workspace = true uuid.workspace = true fabro-proc = { path = "../../foundation/fabro-proc" } fabro-static.workspace = true diff --git a/lib/components/fabro-sandbox/src/clone.rs b/lib/components/fabro-sandbox/src/clone.rs index 2342361dd..0d0f38226 100644 --- a/lib/components/fabro-sandbox/src/clone.rs +++ b/lib/components/fabro-sandbox/src/clone.rs @@ -6,29 +6,33 @@ //! the repository checks out under `//` and the //! run works in `/`, a symlink to the checkout. An //! exact commit or a tag is pinned by the driver's clone options, which -//! fetch a tag by its fully qualified ref so a same-named branch is never -//! consulted; fabro verifies the checked-out head afterwards. Neither path -//! ever falls back to the branch head. +//! fetch the pin directly and attach the branch to it; an unavailable pin +//! fails the clone and never falls back to the branch head. The GitHub App +//! token travels with the clone per call and is then installed as the +//! checkout's ambient credentials, so the agent's own git commands can +//! push; the remote URL never carries it. use std::time::Duration; -use fabro_github::token_source::ResolvedToken; -use fabro_redact::DisplaySafeUrl; use fabro_types::SandboxProviderKind; -use sandbox_driver::{Git as _, GitCloneOptions, GitCredentials, Sandbox as DriverHandle}; +use sandbox_driver::{ + ExecResult, Git as _, GitCloneOptions, GitFailureKind, Sandbox as DriverHandle, +}; use tokio::time; -use crate::ExecResult; -use crate::clone_source::{self, GitHubRepoLayout, PinnedRevision}; -use crate::exec::SandboxExec; -use crate::git_retry::{self, CredentialContext, GitRetryReason, RetryPlan}; -use crate::push_credentials::PushCredentialState; -use crate::redact::redact_auth_url; -use crate::sandbox::shell_quote; +use crate::clone_source::{self, GitHubRepoLayout}; +use crate::credentials::{self, RepoCredentials}; +use crate::exec::{ExecResultExt, SandboxExec}; +use crate::git_policy; /// Whole-clone budget, shared by every network and local step. pub(crate) const GIT_CLONE_TIMEOUT: Duration = Duration::from_mins(5); -const STEP_TIMEOUT: Duration = Duration::from_secs(10); + +/// What the operator hears when the image has no `git`: the driver classifies +/// the failing command, and fabro names the fix. +const GIT_UNAVAILABLE_MESSAGE: &str = "The sandbox image must include git for repository \ + clone and git lifecycle operations. Use an image with \ + bash and git, such as buildpack-deps:noble."; /// A GitHub clone fabro decided to perform. #[derive(Clone, Debug, PartialEq, Eq)] @@ -40,8 +44,7 @@ pub(crate) struct GitHubClone { pub(crate) depth: Option, } -/// What the clone left behind: the layout and the token now embedded in -/// `origin`, if any. +/// What the clone left behind: the layout it checked out into. pub(crate) struct CloneOutcome { pub(crate) layout: GitHubRepoLayout, } @@ -54,14 +57,10 @@ enum CloneStep { Local, } -struct CloneFailure { - error: crate::Error, - retry_reason: Option, -} - /// Clone `plan` into `handle`, laid out under `workspace_root` and -/// `repos_root`, embedding a GitHub App token from `credentials` when one -/// is available. +/// `repos_root`, with a GitHub App token from `credentials` when one is +/// available: the clone carries it per call, and the checkout keeps it as +/// ambient credentials afterwards. pub(crate) async fn clone_github_repo( kind: &SandboxProviderKind, handle: &dyn DriverHandle, @@ -69,34 +68,10 @@ pub(crate) async fn clone_github_repo( plan: &GitHubClone, workspace_root: &str, repos_root: &str, - credentials: &PushCredentialState, + credentials: &RepoCredentials, ) -> crate::Result { - verify_git_available(exec).await?; let layout = clone_source::github_repo_layout(&plan.origin_url, workspace_root, repos_root)?; - // The clone mints its own token (never a warm-cache reuse) and seeds the - // shared source, so the first refresh compares against the clone token - // instead of believing nothing was ever embedded. - let resolved_token = match credentials.source() { - Some(source) => Some(source.mint_for_clone().await.map_err(|err| { - crate::Error::context_anyhow("Failed to get GitHub App credentials for clone", err) - })?), - None => None, - }; - let credential_context = - CredentialContext::from_snapshot(resolved_token.as_ref().map(|token| &token.snapshot)); - let auth_url = match &resolved_token { - Some(token) => Some( - fabro_github::embed_token_in_url(&plan.origin_url, token.token.expose()).map_err( - |err| { - crate::Error::context_anyhow( - "Failed to build authenticated GitHub clone URL", - err, - ) - }, - )?, - ), - None => None, - }; + let token = credentials.mint_for_clone().await?; let fs = handle.fs(); for dir in [workspace_root, layout.repos_owner_path.as_str()] { @@ -106,7 +81,7 @@ pub(crate) async fn clone_github_repo( } let deadline = time::Instant::now() + GIT_CLONE_TIMEOUT; - let has_app = credentials.source().is_some(); + let has_app = credentials.managed(); let git = handle.git().ok_or_else(|| { crate::Error::message(format!( "sandbox provider `{kind}` does not support git operations" @@ -123,84 +98,47 @@ pub(crate) async fn clone_github_repo( options.commit = plan.commit_sha.clone(); options.tag = plan.tag.clone().filter(|_| plan.commit_sha.is_none()); options.depth = plan.depth; - options.credentials = resolved_token - .as_ref() - .map(|token| GitCredentials::new("x-access-token", token.token.expose())); - let retry_plan = RetryPlan::clone_default(Some(deadline)); + options.credentials = token.as_ref().map(credentials::git_credentials); + // The driver retries a clone the remote refused while the token may + // still be replicating, inside what is left of the clone budget. + let policy = git_policy::clone_policy(deadline.saturating_duration_since(time::Instant::now())); let target = layout.primary_repo_path.clone(); - git_retry::retry_git_operation( - kind.clone(), - "clone", - &retry_plan, - |_attempt| { - let options = options.clone(); - let target = target.clone(); - let origin_url = plan.origin_url.clone(); + sandbox_driver::retry_git( + &policy, + options.credentials.as_ref(), + "git clone", + |_attempt, _timeout| { let git = &git; - async move { - git.clone_repo(&origin_url, &target, &options) - .await - .map_err(|error| CloneFailure { - retry_reason: git_retry::classify_driver_failure( - &error, - credential_context, - ), - error: clone_failure_error( - crate::Error::driver_error(error), - CloneStep::Network, - has_app, - ), - }) - } + let options = &options; + let target = ⌖ + let origin_url = &plan.origin_url; + async move { git.clone_repo(origin_url, target, options).await } }, - |failure: &CloneFailure| failure.retry_reason, ) .await - .map_err(|failure| failure.error)?; - if let Some(pin) = - PinnedRevision::from_selectors(plan.tag.as_deref(), plan.commit_sha.as_deref()) - { - let head = run_local_step( - exec, - &clone_source::exact_head_revision_command(&layout.primary_repo_path), - "git rev-parse HEAD (pinned checkout)", - deadline, - auth_url.as_ref(), + .map_err(|failure| { + clone_failure_error( + crate::Error::from(failure.error), + CloneStep::Network, has_app, ) - .await?; - pin.verify_head(&head.stdout)?; - } + })?; run_local_step( exec, &clone_source::repo_symlink_command(&layout), "create workspace repo symlink", deadline, - auth_url.as_ref(), has_app, ) .await?; - if let Some(token) = resolved_token { - embed_origin_credentials(exec, &layout, auth_url.as_ref(), token, credentials).await; + if let Some(token) = &token { + RepoCredentials::install(&git, &layout.primary_repo_path, token).await?; } Ok(CloneOutcome { layout }) } -async fn verify_git_available(exec: &SandboxExec<'_>) -> crate::Result<()> { - let result = exec - .run("git --version", Some(STEP_TIMEOUT), Some("/"), None, None) - .await?; - if !result.is_success() { - return Err(crate::Error::message( - "The sandbox image must include git for repository clone and git lifecycle \ - operations. Use an image with bash and git, such as buildpack-deps:noble.", - )); - } - Ok(()) -} - /// Run a local (non-network) step under the shared clone deadline. /// /// Materializing a large working tree takes far longer than the short fixed @@ -211,7 +149,6 @@ async fn run_local_step( command: &str, label: &'static str, deadline: time::Instant, - auth_url: Option<&DisplaySafeUrl>, has_app: bool, ) -> crate::Result { let remaining = deadline.saturating_duration_since(time::Instant::now()); @@ -224,17 +161,20 @@ async fn run_local_step( .run(command, Some(remaining), Some("/"), None, None) .await .map_err(|error| crate::Error::context(format!("{label} transport failed"), error))?; - if result.is_success() { + if result.success() { return Ok(result); } Err(clone_failure_error( - result.into_exec_error_with_redactor(label, |output| redact_auth_url(output, auth_url)), + result.into_exec_error(label), CloneStep::Local, has_app, )) } fn clone_failure_error(error: crate::Error, step: CloneStep, has_app: bool) -> crate::Error { + if git_unavailable(&error) { + return crate::Error::context(GIT_UNAVAILABLE_MESSAGE, error); + } let message = match step { CloneStep::Network if !has_app => { "Git clone failed. If this is a private repository, configure a GitHub App with \ @@ -246,51 +186,195 @@ fn clone_failure_error(error: crate::Error, step: CloneStep, has_app: bool) -> c crate::Error::context(message, error) } -/// Point `origin` at the authenticated URL so pushes from the checkout -/// carry the clone token, and record that generation for refreshes. A -/// failure here is logged, not fatal: the checkout is complete, and the -/// first push will re-embed. -async fn embed_origin_credentials( - exec: &SandboxExec<'_>, - layout: &GitHubRepoLayout, - auth_url: Option<&DisplaySafeUrl>, - token: ResolvedToken, - credentials: &PushCredentialState, -) { - credentials.record_embedded(token).await; - let Some(auth_url) = auth_url else { - return; - }; - let command = format!( - "git -c maintenance.auto=0 remote set-url origin {}", - shell_quote(auth_url.as_raw_url().as_str()) - ); - match exec - .run( - &command, - Some(STEP_TIMEOUT), - Some(&layout.execution_directory), - None, - None, - ) - .await - { - Ok(result) if result.is_success() => {} - Ok(result) => { - let err = result - .into_exec_error_with_redactor("git remote set-url origin (post-clone)", |s| { - redact_auth_url(s, Some(auth_url)) - }); - tracing::warn!( - error = %err, - "Failed to set sandbox push credentials on origin; git push from this sandbox will fail" - ); - } - Err(err) => { - tracing::warn!( - error = %redact_auth_url(&crate::display_for_log(&err), Some(auth_url)), - "Failed to set sandbox push credentials on origin; git push from this sandbox will fail" - ); +/// Whether the driver found no usable `git` in the sandbox. +fn git_unavailable(error: &crate::Error) -> bool { + matches!( + error.driver(), + Some(sandbox_driver::Error::Git(failure)) + if failure.kind() == GitFailureKind::GitUnavailable + ) +} + +#[cfg(test)] +mod tests { + use fabro_github::token_source::InstallationTokenSource; + use sandbox_driver::{ExecFailure, GitFailure, Termination}; + use sandbox_driver_testing::ScriptedSandbox; + + use super::*; + + const ORIGIN: &str = "https://github.com/acme/widgets"; + + fn ok() -> ExecResult { + ExecResult::new(Termination::Exited, Some(0), Duration::from_millis(1)) + } + + /// A scripted sandbox whose `origin` answers with the fixture URL and + /// whose every other command succeeds. + fn scripted_handle() -> ScriptedSandbox { + let handle = ScriptedSandbox::with_id_and_working_dir("scripted", "/workspace") + .runtime_directory("/tmp/sandbox-driver/runtime"); + handle.scripted_exec().respond_with(|spec| { + let script = spec.args.last().map(String::as_str).unwrap_or_default(); + script.contains("'remote' 'get-url' 'origin'").then(|| { + let mut result = ok(); + result.stdout = format!("{ORIGIN}\n").into_bytes(); + result + }) + }); + handle.scripted_exec().set_default(ok()); + handle + } + + fn plan() -> GitHubClone { + GitHubClone { + origin_url: ORIGIN.to_owned(), + branch: Some("main".to_owned()), + tag: None, + commit_sha: None, + depth: Some(1), } } + + async fn clone_with(handle: &ScriptedSandbox, credentials: &RepoCredentials) -> CloneOutcome { + let exec = SandboxExec::new(handle.exec()); + clone_github_repo( + &SandboxProviderKind::DOCKER, + handle, + &exec, + &plan(), + "/workspace", + "/repos", + credentials, + ) + .await + .expect("clone succeeds") + } + + #[tokio::test] + async fn a_clone_carries_the_token_per_call_and_installs_it_for_the_checkout() { + let handle = scripted_handle(); + let credentials = + RepoCredentials::new(Some(InstallationTokenSource::pat("ghp_test".to_owned()))); + + let outcome = clone_with(&handle, &credentials).await; + assert_eq!(outcome.layout.primary_repo_path, "/repos/acme/widgets"); + + let commands = handle.scripted_exec().commands(); + assert!( + commands + .iter() + .all(|command| !command.contains("git --version")), + "no probe runs ahead of the clone: {commands:#?}" + ); + assert!( + commands.iter().all(|command| !command.contains("set-url")), + "the remote URL is never rewritten: {commands:#?}" + ); + let clone = commands + .iter() + .find(|command| command.contains("'clone'")) + .expect("the clone ran"); + assert!( + clone.contains( + "x-access-token:ghp_test@github.com/acme/widgets.insteadOf=https://github.com/acme/widgets" + ), + "the clone carries the token per call: {clone}" + ); + assert!( + commands.iter().any(|command| command.starts_with("ln -s ")), + "{commands:#?}" + ); + let install = commands + .iter() + .find(|command| command.contains("--add credential.helper")) + .expect("the checkout's credential store is installed"); + assert!( + install.contains("/tmp/sandbox-driver/runtime/git-credentials/"), + "{install}" + ); + assert!( + commands + .iter() + .all(|command| !command.contains("ghp_test") || command.contains("insteadOf")), + "the secret enters no command but the clone's own rewrite: {commands:#?}" + ); + assert!( + handle.scripted_exec().recorded().iter().any(|spec| { + spec.env + .get("SANDBOX_DRIVER_GIT_CREDENTIAL") + .map(String::as_str) + == Some("https://x-access-token:ghp_test@github.com") + }), + "the store line travels in the environment" + ); + } + + #[tokio::test] + async fn a_clone_without_managed_credentials_installs_nothing() { + let handle = scripted_handle(); + + clone_with(&handle, &RepoCredentials::none()).await; + + let commands = handle.scripted_exec().commands(); + assert!( + commands.iter().any(|command| command.contains("'clone'")), + "{commands:#?}" + ); + assert!( + commands + .iter() + .all(|command| !command.contains("insteadOf") + && !command.contains("credential.helper")), + "{commands:#?}" + ); + } + + fn git_failure(exit_code: i32, stderr: &str) -> crate::Error { + crate::Error::from(sandbox_driver::Error::Git(GitFailure::from_command( + "git clone", + ExecFailure::new( + "git clone", + Termination::Exited, + Some(exit_code), + Vec::new(), + stderr.as_bytes().to_vec(), + ), + ))) + } + + #[test] + fn a_missing_git_executable_names_the_image_requirement() { + let error = clone_failure_error( + git_failure(127, "bash: line 1: git: command not found"), + CloneStep::Network, + true, + ); + assert!( + error.to_string().contains("image must include git"), + "{error}" + ); + } + + #[test] + fn other_network_failures_keep_the_credential_guidance() { + let without_app = clone_failure_error( + git_failure(128, "remote: Repository not found."), + CloneStep::Network, + false, + ); + assert!(without_app.to_string().contains("fabro install")); + let with_app = clone_failure_error( + git_failure(128, "remote: Repository not found."), + CloneStep::Network, + true, + ); + assert!( + with_app + .to_string() + .contains("Failed to clone repository into the sandbox") + ); + let local = clone_failure_error(git_failure(1, "ln: failed"), CloneStep::Local, true); + assert!(local.to_string().contains("prepare the cloned repository")); + } } diff --git a/lib/components/fabro-sandbox/src/clone_source.rs b/lib/components/fabro-sandbox/src/clone_source.rs index 2487ab22e..dbd0e3264 100644 --- a/lib/components/fabro-sandbox/src/clone_source.rs +++ b/lib/components/fabro-sandbox/src/clone_source.rs @@ -1,3 +1,5 @@ +use fabro_util::shell; + use crate::sandbox; #[derive(Clone, Debug, PartialEq, Eq)] @@ -68,24 +70,26 @@ fn validate_path_component(label: &str, component: &str) -> crate::Result<()> { pub(crate) fn repo_symlink_command(layout: &GitHubRepoLayout) -> String { format!( "ln -s {} {}", - sandbox::shell_quote(&layout.primary_repo_path), - sandbox::shell_quote(&layout.primary_repo_link), + shell::shell_quote(&layout.primary_repo_path), + shell::shell_quote(&layout.primary_repo_link), ) } -/// A revision the checkout is pinned to instead of the branch's current HEAD. +/// The kind of revision a checkout is pinned to instead of the branch's +/// current HEAD. /// /// The working branch names the checkout the run works on; it never constrains -/// which revision is fetched. No layer proves branch/revision ancestry, and an -/// unavailable revision fails without falling back to branch HEAD. -#[derive(Debug, Clone, PartialEq, Eq)] +/// which revision is fetched. No layer proves branch/revision ancestry. The +/// driver fetches the pin directly and attaches the branch to it, so an +/// unavailable revision fails the clone without falling back to branch HEAD, +/// and a successful clone has the pin checked out. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PinnedRevision { - /// An exact commit SHA, already normalized by - /// [`normalize_exact_commit_sha`]. - Commit(String), + /// An exact commit SHA. + Commit, /// A bare tag name; the driver fetches it as `refs/tags/` so a /// same-named branch is never consulted. - Tag(String), + Tag, } impl PinnedRevision { @@ -93,60 +97,19 @@ impl PinnedRevision { /// target as durable identity but does not drive the checkout. pub(crate) fn from_selectors(tag: Option<&str>, commit_sha: Option<&str>) -> Option { match (commit_sha, tag) { - (Some(sha), _) => Some(Self::Commit(sha.to_string())), - (None, Some(tag)) => Some(Self::Tag(tag.to_string())), + (Some(_), _) => Some(Self::Commit), + (None, Some(_)) => Some(Self::Tag), (None, None) => None, } } /// Human-readable prefix for error messages. - pub(crate) fn label(&self) -> &'static str { + pub(crate) fn label(self) -> &'static str { match self { - Self::Commit(_) => "Exact commit checkout", - Self::Tag(_) => "Tag checkout", + Self::Commit => "Exact commit checkout", + Self::Tag => "Tag checkout", } } - - /// The commit HEAD must resolve to after checkout, when one is known. - pub(crate) fn expected_sha(&self) -> Option<&str> { - match self { - Self::Commit(sha) => Some(sha), - Self::Tag(_) => None, - } - } - - /// Validate the `rev-parse HEAD` output of a pinned checkout and return the - /// resolved commit ID. - pub(crate) fn verify_head(&self, output: &str) -> crate::Result { - let actual_sha = verify_resolved_head(output)?; - if self - .expected_sha() - .is_some_and(|expected| expected != actual_sha) - { - return Err(crate::Error::message( - "Exact checkout HEAD did not match the requested commit", - )); - } - Ok(actual_sha) - } -} - -/// Print the current HEAD commit and nothing else, for -/// [`PinnedRevision::verify_head`]. -pub(crate) fn exact_head_revision_command(checkout_path: &str) -> String { - format!( - "{git} -C {path} rev-parse HEAD", - path = sandbox::shell_quote(checkout_path), - git = sandbox::GIT, - ) -} - -/// Validate that a `rev-parse HEAD` output is a single commit ID and return it -/// normalized. -pub(crate) fn verify_resolved_head(output: &str) -> crate::Result { - normalize_exact_commit_sha(output.trim()).map_err(|err| { - crate::Error::context("Pinned checkout produced an invalid HEAD commit ID", err) - }) } fn trim_root(root: &str) -> &str { @@ -334,13 +297,17 @@ mod tests { } #[test] - fn pinned_revision_prefers_exact_commit_and_qualifies_tags() { + fn pinned_revision_prefers_exact_commit_over_a_tag() { let sha = "0123456789abcdef0123456789abcdef01234567"; assert_eq!(PinnedRevision::from_selectors(None, None), None); - let tag = PinnedRevision::from_selectors(Some("release/v1"), None).unwrap(); - assert_eq!(tag.expected_sha(), None); - let commit = PinnedRevision::from_selectors(Some("release/v1"), Some(sha)).unwrap(); - assert_eq!(commit.expected_sha(), Some(sha)); + assert_eq!( + PinnedRevision::from_selectors(Some("release/v1"), None), + Some(PinnedRevision::Tag) + ); + assert_eq!( + PinnedRevision::from_selectors(Some("release/v1"), Some(sha)), + Some(PinnedRevision::Commit) + ); } #[test] @@ -468,25 +435,6 @@ mod tests { assert!(empty_tag.to_string().contains("non-empty tag")); } - #[test] - fn exact_checkout_verification_rejects_invalid_or_mismatched_head() { - let expected = "0123456789abcdef0123456789abcdef01234567"; - let pin = PinnedRevision::Commit(expected.to_string()); - pin.verify_head("0123456789ABCDEF0123456789ABCDEF01234567\n") - .expect("uppercase command output should normalize"); - - let invalid = pin - .verify_head("fatal: not a revision") - .expect_err("non-SHA output should fail verification"); - assert!(invalid.to_string().contains("invalid HEAD commit ID")); - assert!(!invalid.to_string().contains("fatal: not a revision")); - - let mismatched = pin - .verify_head("1123456789abcdef0123456789abcdef01234567") - .expect_err("mismatched SHA should fail verification"); - assert!(mismatched.to_string().contains("did not match")); - } - #[test] fn github_layout_maps_ssh_origin_to_repos_checkout_and_workspace_link() { let layout = github_repo_layout( diff --git a/lib/components/fabro-sandbox/src/credentials.rs b/lib/components/fabro-sandbox/src/credentials.rs new file mode 100644 index 000000000..b0647d981 --- /dev/null +++ b/lib/components/fabro-sandbox/src/credentials.rs @@ -0,0 +1,158 @@ +//! GitHub credentials for a clone-based sandbox's repository. +//! +//! Fabro decides which credential a checkout works with and when it is +//! renewed; the sandbox driver applies it. The facet's own network +//! operations, fabro's clone and pushes, take the token per call and never +//! write it into the repository. The agent's own git commands read it from +//! the credential store the driver installs beside the checkout, which the +//! workflow's refresh tick rewrites as the token is renewed. The remote URL +//! is never touched, so no secret shows in `git remote -v` or in +//! `.git/config`. The token cache itself sits below, in +//! [`InstallationTokenSource`]. + +use std::sync::Arc; +use std::time::SystemTime; + +use fabro_github::GitHubCredentials; +use fabro_github::token_source::{InstallationTokenSource, ResolvedToken}; +use sandbox_driver::{Git as _, GitCredentials, GitFacet}; + +/// The username GitHub expects with an installation token or PAT. +pub(crate) const GITHUB_TOKEN_USERNAME: &str = "x-access-token"; + +/// Build the shared installation-token source for a clone-based sandbox. +/// +/// Returns `None` when there are no managed credentials or no GitHub origin +/// to scope them to. Minted tokens carry the same `contents: write` +/// permission the clone token uses. +pub(crate) fn build_token_source( + github_app: Option<&GitHubCredentials>, + clone_origin_url: Option<&str>, +) -> crate::Result>> { + let Some(creds) = github_app else { + return Ok(None); + }; + let Some(origin_url) = clone_origin_url.filter(|url| !url.trim().is_empty()) else { + return Ok(None); + }; + let normalized = fabro_github::normalize_repo_origin_url(origin_url); + let Ok((owner, repo)) = fabro_github::parse_github_owner_repo(&normalized) else { + // Non-GitHub origins never clone in these providers, so there is no + // remote to keep credentials fresh for. + return Ok(None); + }; + InstallationTokenSource::for_repository( + creds, + owner, + repo, + serde_json::json!({ "contents": "write" }), + ) + .map(Some) + .map_err(|err| crate::Error::context_anyhow("Failed to build GitHub token source", err)) +} + +/// The GitHub credentials a run's checkout works with: a token source when +/// fabro manages them, nothing when the repository was cloned without a +/// GitHub App or the sandbox was reattached by a later process. +pub(crate) struct RepoCredentials { + source: Option>, +} + +impl RepoCredentials { + pub(crate) fn new(source: Option>) -> Self { + Self { source } + } + + /// No managed credentials: pushes and the agent's git commands use + /// whatever the checkout already has. + pub(crate) fn none() -> Self { + Self::new(None) + } + + pub(crate) fn source(&self) -> Option<&Arc> { + self.source.as_ref() + } + + pub(crate) fn managed(&self) -> bool { + self.source.is_some() + } + + /// Mint the clone token. Never a warm-cache reuse: a clone retried on + /// replication lag must hold the token minted for it. The mint seeds + /// the source, so later resolves reuse this token until it nears + /// expiry. + pub(crate) async fn mint_for_clone(&self) -> crate::Result> { + let Some(source) = &self.source else { + return Ok(None); + }; + source.mint_for_clone().await.map(Some).map_err(|err| { + crate::Error::context_anyhow("Failed to get GitHub App credentials for clone", err) + }) + } + + /// The token one operation works with, reused from the cache until it + /// nears expiry. A refresh that fails while the cached token is still + /// valid returns that token. + pub(crate) async fn resolve(&self) -> crate::Result> { + let Some(source) = &self.source else { + return Ok(None); + }; + source.resolve().await.map(Some).map_err(|err| { + crate::Error::context_anyhow("Failed to refresh GitHub App credentials", err) + }) + } + + /// Install `token` as the credentials every git command run inside the + /// sandbox picks up for the checkout at `repo_path`. The driver keeps + /// them in a credential store beside the checkout and points the + /// repository's helper configuration at it; calling again replaces + /// them in place. + pub(crate) async fn install( + git: &GitFacet<'_>, + repo_path: &str, + token: &ResolvedToken, + ) -> crate::Result<()> { + git.set_ambient_credentials(repo_path, Some(&git_credentials(token))) + .await + .map_err(|error| { + crate::Error::context("Failed to install the checkout's GitHub credentials", error) + }) + } +} + +/// The per-call form of `token` for the driver's network operations. The +/// mint time travels with a minted token so the driver's retry knows a +/// rejection may be replication lag; a static credential carries none. +pub(crate) fn git_credentials(token: &ResolvedToken) -> GitCredentials { + let credentials = GitCredentials::new(GITHUB_TOKEN_USERNAME, token.token.expose()); + match token.snapshot.minted_at() { + Some(minted_at) => credentials.minted_at(SystemTime::from(minted_at)), + None => credentials, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn unmanaged_credentials_resolve_to_nothing() { + let credentials = RepoCredentials::none(); + assert!(!credentials.managed()); + assert!(credentials.mint_for_clone().await.unwrap().is_none()); + assert!(credentials.resolve().await.unwrap().is_none()); + } + + #[tokio::test] + async fn a_pat_becomes_per_call_credentials_under_the_github_username() { + let source = InstallationTokenSource::pat("ghp_static".to_owned()); + let token = source.resolve().await.unwrap(); + let credentials = git_credentials(&token); + assert_eq!(credentials.username, GITHUB_TOKEN_USERNAME); + assert_eq!(credentials.password, "ghp_static"); + assert!( + credentials.minted_at.is_none(), + "a static credential has no mint time" + ); + } +} diff --git a/lib/components/fabro-sandbox/src/daytona.rs b/lib/components/fabro-sandbox/src/daytona.rs index 99c6354e6..952892a28 100644 --- a/lib/components/fabro-sandbox/src/daytona.rs +++ b/lib/components/fabro-sandbox/src/daytona.rs @@ -1,30 +1,28 @@ //! The `daytona` provider kind: what fabro adds to a run's spec for the //! sandbox-driver Daytona provider. //! -//! The environment's options build the spec once; Daytona's overlay creates -//! sandboxes from a snapshot (built from the environment's image or -//! Dockerfile and named by an HMAC of its inputs, or Daytona's default when -//! the environment names neither), fixes the working directory, and sets -//! the lifecycle timers. The run works in `/home/daytona/workspace`, with a -//! cloned repository checked out under `/home/daytona/repos` and linked -//! into the workspace. +//! The environment's options build the spec once; Daytona's overlay fixes +//! the working directory, names the run, sets the lifecycle timers, and +//! falls back to Daytona's default snapshot when the environment names no +//! image or Dockerfile. An image or Dockerfile goes to the driver as is: +//! the Daytona provider builds it into a snapshot named by its inputs under +//! the API key and reuses that snapshot for the same inputs. The run works +//! in `/home/daytona/workspace`, with a cloned repository checked out under +//! `/home/daytona/repos` and linked into the workspace. use std::sync::Arc; use std::time::Duration; -use async_trait::async_trait; use fabro_types::settings::server::ServerSandboxProviderSettings; use fabro_types::{RunId, SandboxProviderKind}; use sandbox_driver::{ - EventContext, HealthStatus, LifecycleTimers, Resources, SandboxProvider, SandboxSource, - SandboxSpec as DriverSpec, SnapshotId, SnapshotSource, SnapshotSpec, + HealthStatus, Resources, SandboxProvider, SandboxSource, SandboxSpec as DriverSpec, SnapshotId, }; use tokio::time; pub use crate::driver::DaytonaCredentials; use crate::driver::{ProviderConnectOptions, connect_provider}; -use crate::driver_sandbox::{CreatePlan, PreparedCreate, WorkspaceLayout}; -use crate::options::SandboxOptions; +use crate::driver_sandbox::WorkspaceLayout; pub(crate) const WORKING_DIRECTORY: &str = "/home/daytona/workspace"; pub(crate) const REPOS_ROOT: &str = "/home/daytona/repos"; @@ -32,8 +30,6 @@ const DEFAULT_SNAPSHOT: &str = "daytona-medium"; pub const DEFAULT_DAYTONA_API_URL: &str = "https://app.daytona.io/api"; /// Budget for the credential probe `fabro doctor` and the install flow run. pub const DAYTONA_CREDENTIAL_PROBE_TIMEOUT: Duration = Duration::from_secs(20); -/// Budget for a custom snapshot to reach Daytona's active state. -const DAYTONA_SNAPSHOT_ACTIVE_TIMEOUT: Duration = Duration::from_mins(30); /// Auto-stop applied when `lifecycle.auto_stop` is unset. Omitting the timer /// would inherit Daytona's server-side default of 15 idle minutes, which is /// shorter than a single long inference call and stops the sandbox mid-run; @@ -41,137 +37,15 @@ const DAYTONA_SNAPSHOT_ACTIVE_TIMEOUT: Duration = Duration::from_mins(30); /// leaked by a dead worker. An explicit zero disables auto-stop entirely. const DEFAULT_AUTO_STOP: Duration = Duration::from_hours(2); -/// Scopes a Daytona API key needs for fabro's snapshot and sandbox flow, in -/// the order the remediation text lists them. -pub const REQUIRED_DAYTONA_SCOPES: &[&str] = &[ - "write:snapshots", - "delete:snapshots", - "write:sandboxes", - "delete:sandboxes", -]; - -/// What a custom snapshot is built from: the environment's image or -/// Dockerfile and its resources in whole gigabytes, the units Daytona -/// sizes snapshots in and the values the snapshot's name is derived from. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SnapshotInputs<'a> { - pub source: SnapshotInput<'a>, - pub cpu: Option, - pub memory_gb: Option, - pub disk_gb: Option, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SnapshotInput<'a> { - /// A pullable image reference such as `ubuntu:24.04`. - Image(&'a str), - /// A Dockerfile Daytona builds into the snapshot. - Dockerfile(&'a str), -} - -/// The snapshot `options` ask for, or `None` when the environment names no -/// image or Dockerfile and the sandbox comes from Daytona's default. -pub fn snapshot_inputs(options: &SandboxOptions) -> Option> { - let source = match (&options.image, &options.dockerfile) { - (Some(image), _) => SnapshotInput::Image(image), - (None, Some(dockerfile)) => SnapshotInput::Dockerfile(dockerfile), - (None, None) => return None, - }; - Some(SnapshotInputs { - source, - cpu: options.cpu.and_then(|cpu| i32::try_from(cpu).ok()), - memory_gb: options.memory_bytes.map(bytes_to_gb), - disk_gb: options.disk_bytes.map(bytes_to_gb), - }) -} - -/// Whole decimal gigabytes, the unit Daytona sizes snapshots in. -fn bytes_to_gb(bytes: u64) -> i32 { - i32::try_from(bytes / 1_000_000_000).unwrap_or(i32::MAX) -} - -pub mod snapshot_identity { - use hmac::{Hmac, Mac}; - use serde::Serialize; - use sha2::{Digest, Sha256}; - use uuid::Uuid; - - use super::{SnapshotInput, SnapshotInputs}; - - const IDENTITY_VERSION: u8 = 1; - const PROVIDER: &str = "daytona"; - const TENANT: &str = "single-tenant"; - - type HmacSha256 = Hmac; - - /// The snapshot source as it appears in the identity manifest. Each - /// variant flattens into a single `"": ""` entry. - #[derive(Serialize)] - #[serde(rename_all = "snake_case")] - enum SourceManifest<'a> { - DockerfileSha256(String), - Image(&'a str), - } - - #[derive(Serialize)] - struct SnapshotManifest<'a> { - identity_version: u8, - provider: &'static str, - tenant: &'static str, - #[serde(flatten)] - source: SourceManifest<'a>, - cpu: Option, - memory_gb: Option, - disk_gb: Option, - /// Nothing sets an entrypoint yet. The field stays because removing - /// it would rename every existing snapshot under `IDENTITY_VERSION` 1. - entrypoint: Option<&'static str>, - } - - /// The name of the snapshot built from `inputs`: a UUIDv8 derived from an - /// HMAC of the build inputs keyed by the API key, so the same inputs reuse - /// the same snapshot and a rotated key never collides with another - /// tenant's. - pub fn snapshot_name(api_key: &str, inputs: &SnapshotInputs<'_>) -> crate::Result { - let manifest = canonical_manifest(inputs)?; - let mut mac = HmacSha256::new_from_slice(api_key.as_bytes()) - .expect("HMAC-SHA256 accepts keys of any length"); - mac.update(&manifest); - let digest = mac.finalize().into_bytes(); - let mut bytes = [0_u8; 16]; - bytes.copy_from_slice(&digest[..16]); - Ok(format!("fabro-{}", Uuid::new_v8(bytes))) - } - - fn canonical_manifest(inputs: &SnapshotInputs<'_>) -> crate::Result> { - let source = match inputs.source { - SnapshotInput::Image(image) => SourceManifest::Image(image), - SnapshotInput::Dockerfile(text) => { - SourceManifest::DockerfileSha256(hex::encode(Sha256::digest(text.as_bytes()))) - } - }; - let manifest = SnapshotManifest { - identity_version: IDENTITY_VERSION, - provider: PROVIDER, - tenant: TENANT, - source, - cpu: inputs.cpu, - memory_gb: inputs.memory_gb, - disk_gb: inputs.disk_gb, - entrypoint: None, - }; - serde_json::to_vec(&manifest).map_err(|err| { - crate::Error::context("Failed to serialize Daytona snapshot identity", err) - }) - } -} - /// Outcome of probing a Daytona credential through the provider's health -/// check. +/// check. The provider owns the list of scopes it needs and the order it +/// reports them in; fabro only renders them. #[derive(Debug)] pub struct DaytonaKeyCheck { /// Scopes the key lacks, in Daytona's wire names. - pub missing: Vec, + pub missing: Vec, + /// Every scope the provider requires, for the remediation text. + pub required: Vec, } #[derive(Debug, thiserror::Error)] @@ -211,11 +85,12 @@ impl DaytonaKeyCheck { self.missing_display() ) } -} -#[must_use] -pub fn required_perms_display() -> String { - REQUIRED_DAYTONA_SCOPES.join(", ") + /// Every scope the provider requires, comma separated, for remediation. + #[must_use] + pub fn required_display(&self) -> String { + self.required.join(", ") + } } /// Whether `credentials` reach Daytona, are accepted, and carry the scopes @@ -233,11 +108,13 @@ pub async fn check_daytona_api_key( .map_err(|error| anyhow::Error::new(error).context("Daytona health check failed"))?; match health.status { HealthStatus::Ok | HealthStatus::Unknown => Ok(DaytonaKeyCheck { - missing: Vec::new(), + missing: Vec::new(), + required: health.required_permissions, }), HealthStatus::Unauthorized if !health.missing_permissions.is_empty() => { Ok(DaytonaKeyCheck { - missing: ordered_scopes(&health.missing_permissions), + missing: health.missing_permissions, + required: health.required_permissions, }) } HealthStatus::Unauthorized => Err(anyhow::anyhow!( @@ -262,22 +139,6 @@ pub async fn check_daytona_api_key( } } -/// The scopes fabro requires, in fabro's documented order, followed by any -/// other scope the provider reported missing. -fn ordered_scopes(missing: &[String]) -> Vec { - let mut ordered: Vec = REQUIRED_DAYTONA_SCOPES - .iter() - .filter(|scope| missing.iter().any(|reported| reported == *scope)) - .map(|scope| (*scope).to_string()) - .collect(); - for scope in missing { - if !ordered.contains(scope) { - ordered.push(scope.clone()); - } - } - ordered -} - async fn connect(credentials: &DaytonaCredentials) -> anyhow::Result> { connect_provider( &SandboxProviderKind::DAYTONA, @@ -300,203 +161,61 @@ pub(crate) fn layout() -> WorkspaceLayout { } } -/// Daytona's additions to the base spec: the snapshot the sandbox is created -/// from, the fixed working directory, the run's Daytona name, and the -/// lifecycle timers. -pub(crate) fn overlay( - spec: DriverSpec, - options: &SandboxOptions, - run_id: Option<&RunId>, - snapshot: &SnapshotId, -) -> DriverSpec { +/// Daytona's additions to the environment's spec: the fixed working +/// directory, the run's Daytona name, the lifecycle timers, and Daytona's +/// default snapshot when the environment names no image or Dockerfile. An +/// image or Dockerfile stays as it is: the driver builds it into a cached +/// snapshot sized by the spec's resources. A create from the default +/// snapshot carries no resources, which Daytona refuses on a sandbox +/// created from a snapshot. +pub(crate) fn overlay(spec: DriverSpec, run_id: Option<&RunId>) -> DriverSpec { let mut spec = spec.working_directory(WORKING_DIRECTORY); - spec.source = SandboxSource::Snapshot { - id: snapshot.clone(), - }; + if !matches!( + spec.source, + SandboxSource::Image { .. } | SandboxSource::Dockerfile { .. } + ) { + spec.source = SandboxSource::Snapshot { + id: SnapshotId::try_new(DEFAULT_SNAPSHOT).expect("the default snapshot name is valid"), + }; + spec.resources = Resources::default(); + } spec.name = run_id.map(|run_id| format!("fabro-{run_id}")); - let mut timers = LifecycleTimers::default(); + let mut timers = spec.timers; // An explicit zero disables auto-stop; the driver encodes // `Duration::ZERO` as that wire value. - timers.auto_stop_after_idle = Some(options.auto_stop.unwrap_or(DEFAULT_AUTO_STOP)); + timers.auto_stop_after_idle = Some(timers.auto_stop_after_idle.unwrap_or(DEFAULT_AUTO_STOP)); // Run sandboxes are never deleted on stop: the run record may need // them again on resume, and `fabro system prune` reclaims them. timers.auto_delete_after_stop = Some(Duration::ZERO); spec.timers(timers) } -/// Ensures the snapshot `inputs` describe exists and is active, building -/// it when Daytona does not have it. Returns the snapshot to create -/// sandboxes from. -async fn ensure_snapshot( - provider: &dyn SandboxProvider, - api_key: &str, - inputs: &SnapshotInputs<'_>, - events: Option, -) -> crate::Result<(SnapshotId, String)> { - let name = snapshot_identity::snapshot_name(api_key, inputs)?; - let snapshots = provider.snapshots().ok_or_else(|| { - crate::Error::message("The Daytona provider does not expose snapshot management") - })?; - let id = snapshots - .ensure( - &snapshot_spec(&name, inputs), - DAYTONA_SNAPSHOT_ACTIVE_TIMEOUT, - events, - ) - .await - .map_err(|error| { - crate::Error::context(format!("Failed to ensure snapshot '{name}'"), error) - })?; - Ok((id, name)) -} - -fn snapshot_spec(name: &str, inputs: &SnapshotInputs<'_>) -> SnapshotSpec { - let source = match inputs.source { - SnapshotInput::Image(image) => SnapshotSource::Image { - reference: image.to_string(), - }, - SnapshotInput::Dockerfile(content) => SnapshotSource::Dockerfile { - content: content.to_string(), - }, - }; - let mut resources = Resources::default(); - resources.cpu_cores = inputs.cpu.and_then(|cpu| u32::try_from(cpu).ok()); - resources.memory_mb = inputs - .memory_gb - .and_then(|gb| u64::try_from(gb).ok()) - .map(|gb| gb * 1024); - resources.disk_mb = inputs - .disk_gb - .and_then(|gb| u64::try_from(gb).ok()) - .map(|gb| gb * 1024); - SnapshotSpec::new(source).name(name).resources(resources) -} - -/// Prepares a Daytona create: the snapshot first, then the spec naming it. -pub(crate) struct DaytonaCreatePlan { - provider: Arc, - api_key: String, - base: DriverSpec, - options: SandboxOptions, - run_id: Option, -} - -/// The create plan for a run on Daytona: `base` is the spec the -/// environment's options built, which the plan completes with the snapshot -/// once it exists. -pub(crate) fn create_plan( - provider: Arc, - api_key: String, - base: DriverSpec, - options: SandboxOptions, - run_id: Option, -) -> DaytonaCreatePlan { - DaytonaCreatePlan { - provider, - api_key, - base, - options, - run_id, - } -} - -#[async_trait] -impl CreatePlan for DaytonaCreatePlan { - async fn prepare(&self, events: Option) -> crate::Result { - let (snapshot_id, snapshot_name) = match snapshot_inputs(&self.options) { - // The driver finds, activates, builds, or waits for the snapshot - // as needed, and reports that work through `events`. - Some(inputs) => { - ensure_snapshot(self.provider.as_ref(), &self.api_key, &inputs, events).await? - } - None => ( - SnapshotId::try_new(DEFAULT_SNAPSHOT).expect("the default snapshot name is valid"), - DEFAULT_SNAPSHOT.to_string(), - ), - }; - Ok(PreparedCreate { - spec: overlay( - self.base.clone(), - &self.options, - self.run_id.as_ref(), - &snapshot_id, - ), - snapshot: Some(snapshot_name), - }) - } -} - #[cfg(test)] mod tests { - use std::collections::BTreeMap; - - use sandbox_driver::NetworkPolicy; + use sandbox_driver::{LifecycleTimers, NetworkPolicy}; use super::*; - use crate::options::base_spec; fn run_id() -> RunId { "01HY0000000000000000000000".parse().unwrap() } - fn dockerfile_inputs(dockerfile: &str) -> SnapshotInputs<'_> { - SnapshotInputs { - source: SnapshotInput::Dockerfile(dockerfile), - cpu: Some(2), - memory_gb: Some(4), - disk_gb: Some(10), - } - } - - #[test] - fn snapshot_inputs_come_from_the_image_or_dockerfile_in_whole_gigabytes() { - assert!(snapshot_inputs(&SandboxOptions::default()).is_none()); - - let options = SandboxOptions { - image: Some("ubuntu:24.04".to_string()), - cpu: Some(2), - memory_bytes: Some(4_000_000_000), - disk_bytes: Some(10_500_000_000), - ..SandboxOptions::default() - }; - assert_eq!( - snapshot_inputs(&options), - Some(SnapshotInputs { - source: SnapshotInput::Image("ubuntu:24.04"), - cpu: Some(2), - memory_gb: Some(4), - disk_gb: Some(10), - }) - ); - - let options = SandboxOptions { - dockerfile: Some("FROM ubuntu".to_string()), - ..SandboxOptions::default() - }; - assert_eq!( - snapshot_inputs(&options).map(|inputs| inputs.source), - Some(SnapshotInput::Dockerfile("FROM ubuntu")) - ); - } - #[test] fn overlay_names_the_run_and_carries_fabro_labels_and_timers() { - let options = SandboxOptions { - labels: BTreeMap::from([("team".to_string(), "platform".to_string())]), - network: NetworkPolicy::CidrAllowList { + let mut resources = Resources::default(); + resources.cpu_cores = Some(2); + let base = DriverSpec::new(SandboxSource::HostDirectory) + .label("team", "platform") + .network(NetworkPolicy::CidrAllowList { cidrs: vec!["10.0.0.0/8".to_string()], - }, - ..SandboxOptions::default() - }; - let snapshot = SnapshotId::try_new("snap-1").unwrap(); - let spec = overlay( - base_spec(&options, Some(&run_id())), - &options, - Some(&run_id()), - &snapshot, - ); + }) + .resources(resources); + let spec = overlay(base, Some(&run_id())); - assert!(matches!(&spec.source, SandboxSource::Snapshot { id } if id == &snapshot)); + assert!( + matches!(&spec.source, SandboxSource::Snapshot { id } if id.as_str() == DEFAULT_SNAPSHOT), + "a spec without an image comes from Daytona's default snapshot" + ); assert_eq!( spec.name.as_deref(), Some("fabro-01HY0000000000000000000000") @@ -519,18 +238,53 @@ mod tests { "an unset auto-stop gets fabro's explicit default, never Daytona's 15 minutes" ); assert_eq!(spec.timers.auto_delete_after_stop, Some(Duration::ZERO)); + assert_eq!( + spec.resources, + Resources::default(), + "the default snapshot carries the resources; Daytona refuses them on the sandbox" + ); assert!(!spec.ephemeral); } + #[test] + fn overlay_leaves_an_image_and_its_resources_for_the_driver_to_cache() { + let mut resources = Resources::default(); + resources.cpu_cores = Some(2); + resources.memory_mb = Some(4096); + let base = DriverSpec::new(SandboxSource::Image { + reference: "ubuntu:24.04".to_string(), + }) + .resources(resources); + let spec = overlay(base, None); + assert!( + matches!(&spec.source, SandboxSource::Image { reference } if reference == "ubuntu:24.04") + ); + assert_eq!( + spec.resources, resources, + "the resources size the cached snapshot" + ); + assert_eq!(spec.working_directory.as_deref(), Some(WORKING_DIRECTORY)); + + let dockerfile = overlay( + DriverSpec::new(SandboxSource::Dockerfile { + content: "FROM ubuntu".to_string(), + }), + None, + ); + assert!(matches!( + dockerfile.source, + SandboxSource::Dockerfile { .. } + )); + } + #[test] fn overlay_passes_explicit_auto_stop_through_and_zero_disables() { - let snapshot = SnapshotId::try_new(DEFAULT_SNAPSHOT).unwrap(); - let options = SandboxOptions { - auto_stop: Some(Duration::from_mins(45)), - network: NetworkPolicy::Block, - ..SandboxOptions::default() - }; - let explicit = overlay(base_spec(&options, None), &options, None, &snapshot); + let mut timers = LifecycleTimers::default(); + timers.auto_stop_after_idle = Some(Duration::from_mins(45)); + let base = DriverSpec::new(SandboxSource::HostDirectory) + .network(NetworkPolicy::Block) + .timers(timers); + let explicit = overlay(base, None); assert_eq!( explicit.timers.auto_stop_after_idle, Some(Duration::from_mins(45)) @@ -538,164 +292,44 @@ mod tests { assert!(matches!(explicit.network, NetworkPolicy::Block)); assert!(explicit.name.is_none()); - let options = SandboxOptions { - auto_stop: Some(Duration::ZERO), - ..SandboxOptions::default() - }; - let disabled = overlay(base_spec(&options, None), &options, None, &snapshot); + let mut timers = LifecycleTimers::default(); + timers.auto_stop_after_idle = Some(Duration::ZERO); + let disabled = overlay( + DriverSpec::new(SandboxSource::HostDirectory).timers(timers), + None, + ); assert_eq!(disabled.timers.auto_stop_after_idle, Some(Duration::ZERO)); } #[test] - fn snapshot_spec_maps_sources_and_gigabyte_resources() { - let inputs = SnapshotInputs { - source: SnapshotInput::Image("ubuntu:24.04"), - cpu: Some(2), - memory_gb: Some(4), - disk_gb: Some(10), - }; - let spec = snapshot_spec("fabro-x", &inputs); - assert_eq!(spec.name.as_deref(), Some("fabro-x")); - assert!(matches!( - &spec.source, - SnapshotSource::Image { reference } if reference == "ubuntu:24.04" - )); - assert_eq!(spec.resources.cpu_cores, Some(2)); - assert_eq!(spec.resources.memory_mb, Some(4096)); - assert_eq!(spec.resources.disk_mb, Some(10_240)); - - let dockerfile = snapshot_spec("fabro-y", &SnapshotInputs { - source: SnapshotInput::Dockerfile("FROM ubuntu"), - ..inputs - }); - assert!(matches!( - &dockerfile.source, - SnapshotSource::Dockerfile { content } if content == "FROM ubuntu" - )); - } - - #[test] - fn computed_snapshot_identity_is_deterministic_and_keyed() { - let inputs = dockerfile_inputs("FROM ubuntu:24.04\nRUN apt-get update"); - - let first = snapshot_identity::snapshot_name("dtn_secret", &inputs).unwrap(); - let second = snapshot_identity::snapshot_name("dtn_secret", &inputs).unwrap(); - let rotated_key = snapshot_identity::snapshot_name("dtn_rotated", &inputs).unwrap(); - - assert_eq!(first, second); - assert_eq!(first, "fabro-e607185f-c7ab-88c9-bf9d-d70addba9298"); - assert_ne!(first, rotated_key); - let uuid = first - .strip_prefix("fabro-") - .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) - .expect("snapshot name should be fabro-"); - assert_eq!(uuid.get_version_num(), 8); - assert_eq!(uuid.get_variant(), uuid::Variant::RFC4122); - } - - #[test] - fn computed_snapshot_identity_changes_for_generation_inputs() { - let base = dockerfile_inputs("FROM ubuntu:24.04"); - let base_name = snapshot_identity::snapshot_name("dtn_secret", &base).unwrap(); - - let cases = [ - SnapshotInputs { - source: SnapshotInput::Dockerfile("FROM ubuntu:24.04\n# roll cache"), - ..base.clone() - }, - SnapshotInputs { - cpu: Some(4), - ..base.clone() - }, - SnapshotInputs { - memory_gb: Some(8), - ..base.clone() - }, - SnapshotInputs { - disk_gb: Some(20), - ..base.clone() - }, - ]; - - for changed in cases { - let changed_name = snapshot_identity::snapshot_name("dtn_secret", &changed).unwrap(); - assert_ne!(base_name, changed_name); - } - } - - #[test] - fn computed_snapshot_identity_excludes_raw_dockerfile_and_key_material() { - let inputs = SnapshotInputs { - source: SnapshotInput::Dockerfile( - "FROM private.example.com/secret-image\nRUN echo raw-secret", - ), - cpu: None, - memory_gb: None, - disk_gb: None, - }; - - let name = snapshot_identity::snapshot_name("dtn_super_secret_key", &inputs).unwrap(); - - assert!(name.starts_with("fabro-")); - assert!(!name.contains("private.example.com")); - assert!(!name.contains("raw-secret")); - assert!(!name.contains("dtn_super_secret_key")); - } - - #[test] - fn computed_snapshot_identity_changes_for_image_reference() { - let inputs = SnapshotInputs { - source: SnapshotInput::Image("ubuntu:24.04"), - cpu: Some(2), - memory_gb: Some(4), - disk_gb: Some(10), - }; - let first = snapshot_identity::snapshot_name("dtn_secret", &inputs).unwrap(); - let changed = snapshot_identity::snapshot_name("dtn_secret", &SnapshotInputs { - source: SnapshotInput::Image("ubuntu:24.10"), - ..inputs - }) - .unwrap(); - - assert_eq!(first, "fabro-5d23a023-d7ff-8d68-b3ca-e6286f4211d9"); - assert_ne!(first, changed); - } - - #[test] - fn missing_scopes_render_in_documented_order() { + fn missing_scopes_render_as_the_provider_reports_them() { let check = DaytonaKeyCheck { - missing: ordered_scopes(&[ - "write:sandboxes".to_string(), + missing: vec!["write:snapshots".to_string(), "write:sandboxes".to_string()], + required: vec![ "write:snapshots".to_string(), - "manage:secrets".to_string(), - ]), + "delete:snapshots".to_string(), + "write:sandboxes".to_string(), + "delete:sandboxes".to_string(), + ], }; assert!(!check.ok()); - assert_eq!( - check.missing_display(), - "write:snapshots, write:sandboxes, manage:secrets" - ); + assert_eq!(check.missing_display(), "write:snapshots, write:sandboxes"); assert_eq!( check.missing_message(), - "Daytona API key is missing required scopes: write:snapshots, write:sandboxes, \ - manage:secrets. Regenerate the key with all snapshot and sandbox scopes." + "Daytona API key is missing required scopes: write:snapshots, write:sandboxes. \ + Regenerate the key with all snapshot and sandbox scopes." ); assert_eq!( - required_perms_display(), + check.required_display(), "write:snapshots, delete:snapshots, write:sandboxes, delete:sandboxes" ); } #[tokio::test] async fn credential_probe_reports_configured_timeout() { - let credentials = DaytonaCredentials { - api_key: "dtn_test".to_string(), - // A non-routable address: the probe cannot finish within the budget. - api_url: Some("http://10.255.255.1:1/api".to_string()), - organization_id: None, - target: None, - http_client: None, - }; + // A non-routable address: the probe cannot finish within the budget. + let credentials = DaytonaCredentials::new("dtn_test".to_string()) + .with_api_url(Some("http://10.255.255.1:1/api".to_string())); let err = check_daytona_api_key(&credentials, Duration::from_millis(1)) .await .expect_err("probe should time out"); @@ -728,7 +362,7 @@ mod wire_gate { use super::*; use crate::driver_sandbox::{LayoutSource, RepoWorkspace, RunSandbox}; - use crate::options::base_spec; + use crate::environment::CloneRequest; #[expect( clippy::disallowed_methods, @@ -736,15 +370,9 @@ mod wire_gate { )] fn live_credentials() -> Option { let api_key = std::env::var(EnvVars::DAYTONA_API_KEY).ok()?; - Some(DaytonaCredentials { - api_key, - api_url: std::env::var(EnvVars::DAYTONA_API_URL) - .or_else(|_| std::env::var(EnvVars::DAYTONA_SERVER_URL)) - .ok(), - organization_id: std::env::var(EnvVars::DAYTONA_ORGANIZATION_ID).ok(), - target: None, - http_client: None, - }) + Some(DaytonaCredentials::from_api_key(api_key, |name| { + std::env::var(name).ok() + })) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -765,18 +393,16 @@ mod wire_gate { let workspace = RepoWorkspace::plan( LayoutSource::Fixed(layout()), - false, - Some("https://github.com/brynary/rack-test"), - None, - None, - None, - Some(100), + &CloneRequest { + origin_url: Some("https://github.com/brynary/rack-test".to_string()), + depth: Some(100), + ..CloneRequest::default() + }, None, ) .expect("clone plan"); - let snapshot = SnapshotId::try_new(DEFAULT_SNAPSHOT).expect("snapshot id"); - let options = SandboxOptions::default(); - let spec = overlay(base_spec(&options, None), &options, None, &snapshot); + // No image: the overlay creates from Daytona's default snapshot. + let spec = overlay(DriverSpec::new(SandboxSource::HostDirectory), None); let sandbox = RunSandbox::pending(SandboxProviderKind::DAYTONA, remote, spec, workspace); sandbox .initialize() @@ -800,8 +426,8 @@ mod wire_gate { ) .await .expect("layout check"); - assert!(result.is_success(), "{result:?}"); - assert!(result.stdout.contains("true")); + assert!(result.success(), "{result:?}"); + assert!(result.stdout_lossy().contains("true")); let layout = sandbox.workspace_layout().expect("layout record"); assert_eq!( layout.primary_repo_path.as_deref(), @@ -809,6 +435,6 @@ mod wire_gate { ); }; checks.await; - sandbox.cleanup().await.expect("cleanup"); + sandbox.delete().await.expect("cleanup"); } } diff --git a/lib/components/fabro-sandbox/src/details.rs b/lib/components/fabro-sandbox/src/details.rs index cbdd9c09a..da6682b2e 100644 --- a/lib/components/fabro-sandbox/src/details.rs +++ b/lib/components/fabro-sandbox/src/details.rs @@ -1,29 +1,17 @@ -use std::collections::BTreeMap; - use anyhow::Result; -use chrono::{DateTime, Utc}; -use fabro_types::{ - BundledProvider, RunId, RunSandboxInstance, SandboxDetails, SandboxNetwork, - SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps, -}; +use fabro_types::{RunId, RunSandboxInstance, SandboxDetails}; use crate::driver::ProviderAccess; use crate::reconnect; -/// Inspect the sandbox identified by `record` and return provider-neutral -/// details for control-plane display. -/// -/// `local` always returns a minimal record describing the host; every other -/// provider is described through the sandbox driver. +/// The sandbox identified by `record`, as the run record fabro keeps and +/// the status the sandbox driver reports for it, on every provider. pub async fn sandbox_details( record: &RunSandboxInstance, access: &ProviderAccess, run_id: Option, ) -> Result { - if record.provider.bundled() == Some(BundledProvider::Local) { - return Ok(local_details(record)); - } - let sandbox = reconnect::reconnect_driver_for_run(record, access, run_id, None).await?; + let sandbox = reconnect::reconnect_for_run(record, access, run_id, None).await?; let status = sandbox.handle()?.describe().await.map_err(|err| { anyhow::anyhow!( "Failed to describe {} sandbox '{}': {err}", @@ -31,236 +19,8 @@ pub async fn sandbox_details( record.runtime.id ) })?; - Ok(details_from_status(record, &status)) -} - -fn local_details(record: &RunSandboxInstance) -> SandboxDetails { - SandboxDetails { - sandbox: record.clone(), - state: SandboxState::Running, - native_state: None, - region: None, - web_url: None, - resources: SandboxResources::default(), - network: SandboxNetwork::unknown(), - labels: BTreeMap::new(), - timestamps: SandboxTimestamps::default(), - } -} - -/// Projection of a sandbox-driver [`sandbox_driver::SandboxStatus`] into -/// fabro's inventory shape. The driver reports what a provider exposes -/// through its public facets; fields no facet carries (network policy) stay -/// unknown rather than being read from provider SDK types. -pub(crate) fn info_from_status( - kind: &fabro_types::SandboxProviderKind, - status: &sandbox_driver::SandboxStatus, -) -> fabro_types::SandboxInfo { - let fields = fields_from_status(status); - fabro_types::SandboxInfo { - provider: kind.clone(), - id: status.id.to_string(), - display_name: status.name.clone().filter(|name| !name.is_empty()), - state: fields.state, - native_state: fields.native_state, - image: status.source.clone(), - snapshot: None, - region: status.region.clone(), - web_url: status.web_url.clone(), - working_directory: None, - resources: fields.resources, - network: SandboxNetwork::unknown(), - labels: status.labels.clone(), - timestamps: fields.timestamps, - } -} - -pub(crate) fn details_from_status( - record: &RunSandboxInstance, - status: &sandbox_driver::SandboxStatus, -) -> SandboxDetails { - let fields = fields_from_status(status); - SandboxDetails { - sandbox: RunSandboxInstance { - image: (record.provider == SandboxProviderKind::DOCKER) - .then(|| status.source.clone()) - .flatten() - .or_else(|| record.image.clone()), - snapshot: (record.provider == SandboxProviderKind::DAYTONA) - .then(|| status.source.clone()) - .flatten() - .or_else(|| record.snapshot.clone()), - ..record.clone() - }, - state: fields.state, - native_state: fields.native_state, - region: status.region.clone(), - web_url: status.web_url.clone(), - resources: fields.resources, - network: SandboxNetwork::unknown(), - labels: status.labels.clone(), - timestamps: fields.timestamps, - } -} - -struct StatusFields { - state: SandboxState, - native_state: Option, - resources: SandboxResources, - timestamps: SandboxTimestamps, -} - -fn fields_from_status(status: &sandbox_driver::SandboxStatus) -> StatusFields { - StatusFields { - state: normalize_driver_state(status.state), - native_state: Some(status.provider_state.clone()).filter(|value| !value.is_empty()), - resources: status - .resources - .as_ref() - .map(|resources| SandboxResources { - cpu_cores: resources.cpu_cores.map(f64::from), - memory_bytes: resources.memory_mb.map(|mb| mb * 1024 * 1024), - disk_bytes: resources.disk_mb.map(|mb| mb * 1024 * 1024), - }) - .unwrap_or_default(), - timestamps: SandboxTimestamps { - created_at: status.created_at.map(DateTime::::from), - last_activity_at: status.updated_at.map(DateTime::::from), - }, - } -} - -pub(crate) fn normalize_driver_state(state: sandbox_driver::SandboxState) -> SandboxState { - use sandbox_driver::SandboxState as Driver; - match state { - Driver::Creating | Driver::Forking => SandboxState::Provisioning, - Driver::Starting | Driver::Resuming => SandboxState::Starting, - // A sandbox mid-snapshot keeps serving commands. - Driver::Running | Driver::Snapshotting => SandboxState::Running, - Driver::Stopping | Driver::Archiving => SandboxState::Stopping, - Driver::Stopped => SandboxState::Stopped, - Driver::Pausing | Driver::Paused => SandboxState::Paused, - Driver::Archived => SandboxState::Archived, - Driver::Restoring => SandboxState::Restoring, - Driver::Resizing => SandboxState::Resizing, - Driver::Deleting => SandboxState::Deleting, - Driver::Deleted => SandboxState::Deleted, - Driver::Error => SandboxState::Error, - _ => SandboxState::Unknown, - } -} - -#[cfg(test)] -mod tests { - use sandbox_driver::SandboxId; - - use super::*; - - #[test] - fn driver_states_map_onto_fabro_states() { - use sandbox_driver::SandboxState as Driver; - for (driver, fabro) in [ - (Driver::Creating, SandboxState::Provisioning), - (Driver::Starting, SandboxState::Starting), - (Driver::Running, SandboxState::Running), - (Driver::Snapshotting, SandboxState::Running), - (Driver::Stopping, SandboxState::Stopping), - (Driver::Stopped, SandboxState::Stopped), - (Driver::Paused, SandboxState::Paused), - (Driver::Archived, SandboxState::Archived), - (Driver::Deleting, SandboxState::Deleting), - (Driver::Deleted, SandboxState::Deleted), - (Driver::Error, SandboxState::Error), - (Driver::Unknown, SandboxState::Unknown), - ] { - assert_eq!(normalize_driver_state(driver), fabro, "{driver:?}"); - } - } - - #[test] - fn status_projection_carries_identity_source_and_labels() { - let mut status = sandbox_driver::SandboxStatus::new( - SandboxId::try_new("container-abc123").unwrap(), - sandbox_driver::SandboxState::Running, - ); - status.name = Some("fabro-run-abc".to_string()); - status.provider_state = "running".to_string(); - status.source = Some("buildpack-deps:noble".to_string()); - status - .labels - .insert("sh.fabro.managed".to_string(), "true".to_string()); - let mut resources = sandbox_driver::Resources::default(); - resources.cpu_cores = Some(2); - resources.memory_mb = Some(2048); - status.resources = Some(resources); - - let info = info_from_status(&SandboxProviderKind::DOCKER, &status); - assert_eq!(info.id, "container-abc123"); - assert_eq!(info.display_name.as_deref(), Some("fabro-run-abc")); - assert_eq!(info.state, SandboxState::Running); - assert_eq!(info.native_state.as_deref(), Some("running")); - assert_eq!(info.image.as_deref(), Some("buildpack-deps:noble")); - assert_eq!(info.resources.cpu_cores, Some(2.0)); - assert_eq!(info.resources.memory_bytes, Some(2_147_483_648)); - assert_eq!( - info.labels.get("sh.fabro.managed").map(String::as_str), - Some("true") - ); - - let record = RunSandboxInstance { - provider: SandboxProviderKind::DOCKER, - image: None, - snapshot: None, - runtime: fabro_types::RunSandboxRuntime { - id: "container-abc123".to_string(), - working_directory: "/workspace".to_string(), - repo_cloned: Some(true), - clone_origin_url: None, - clone_branch: None, - workspace_root: None, - repos_root: None, - primary_repo_path: None, - primary_repo_link: None, - }, - }; - let details = details_from_status(&record, &status); - assert_eq!( - details.sandbox.image.as_deref(), - Some("buildpack-deps:noble") - ); - assert_eq!(details.sandbox.runtime.id, "container-abc123"); - assert_eq!(details.network, SandboxNetwork::unknown()); - } - - #[test] - fn local_details_returns_running_with_no_metadata() { - let record = RunSandboxInstance { - provider: SandboxProviderKind::LOCAL, - image: None, - snapshot: None, - runtime: fabro_types::RunSandboxRuntime { - id: "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z".to_string(), - working_directory: "/Users/client/project".to_string(), - repo_cloned: None, - clone_origin_url: None, - clone_branch: None, - workspace_root: None, - repos_root: None, - primary_repo_path: None, - primary_repo_link: None, - }, - }; - let details = local_details(&record); - assert_eq!(details.sandbox.provider, SandboxProviderKind::LOCAL); - assert_eq!(details.state, SandboxState::Running); - let runtime = &details.sandbox.runtime; - assert_eq!(runtime.id, "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z"); - assert_eq!(runtime.working_directory, "/Users/client/project"); - assert!(details.region.is_none()); - assert!(details.sandbox.image.is_none()); - assert!(details.labels.is_empty()); - assert_eq!(details.resources, SandboxResources::default()); - assert_eq!(details.network, SandboxNetwork::unknown()); - assert_eq!(details.timestamps, SandboxTimestamps::default()); - } + Ok(SandboxDetails { + sandbox: record.clone(), + status, + }) } diff --git a/lib/components/fabro-sandbox/src/docker.rs b/lib/components/fabro-sandbox/src/docker.rs index 87cb66f57..138cd582c 100644 --- a/lib/components/fabro-sandbox/src/docker.rs +++ b/lib/components/fabro-sandbox/src/docker.rs @@ -8,12 +8,11 @@ //! [`REPOS_ROOT`] and is linked into the workspace, so the run works in //! `/workspace/`. -use sandbox_driver::{HealthStatus, SandboxSource, SandboxSpec as DriverSpec}; +use sandbox_driver::{HealthStatus, LifecycleTimers, SandboxSource, SandboxSpec as DriverSpec}; use sandbox_driver_docker_config::DockerProviderConfig; use crate::driver::ProviderAccess; use crate::driver_sandbox::WorkspaceLayout; -use crate::options::SandboxOptions; use crate::provider_sandbox; pub const WORKING_DIRECTORY: &str = "/workspace"; @@ -30,28 +29,28 @@ pub(crate) fn layout() -> WorkspaceLayout { } /// The image a Docker sandbox runs: the environment's, or the default. -pub(crate) fn effective_image(options: &SandboxOptions) -> String { - options - .image - .clone() - .unwrap_or_else(|| DEFAULT_IMAGE.to_string()) +pub(crate) fn effective_image(spec: &DriverSpec) -> String { + match &spec.source { + SandboxSource::Image { reference } => reference.clone(), + _ => DEFAULT_IMAGE.to_string(), + } } -/// Docker's additions to the base spec, and the image it will run. -pub(crate) fn overlay(spec: DriverSpec, options: &SandboxOptions) -> (DriverSpec, String) { - let image = effective_image(options); +/// Docker's additions to the environment's spec: the image it will run, +/// the fixed working directory, and a pull for a missing image. Docker has +/// no lifecycle timers, so the environment's auto-stop does not apply. +pub(crate) fn overlay(spec: DriverSpec) -> DriverSpec { + let image = effective_image(&spec); let mut spec = spec; - spec.source = SandboxSource::Image { - reference: image.clone(), - }; - let spec = spec.working_directory(WORKING_DIRECTORY).provider_config( + spec.source = SandboxSource::Image { reference: image }; + spec.timers = LifecycleTimers::default(); + spec.working_directory(WORKING_DIRECTORY).provider_config( DockerProviderConfig { auto_pull: true, ..DockerProviderConfig::default() } .into_value(), - ); - (spec, image) + ) } /// Whether the Docker daemon answers. Used by `fabro doctor`. @@ -76,57 +75,49 @@ pub async fn check_docker_daemon() -> crate::Result<()> { #[cfg(test)] mod tests { - use std::collections::BTreeMap; + use std::time::Duration; - use fabro_types::RunId; use sandbox_driver::NetworkPolicy; use super::*; - use crate::options::base_spec; #[test] fn overlay_fixes_the_workspace_and_pulls_the_named_image() { - let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); - let options = SandboxOptions { - image: Some("ghcr.io/acme/dev:1".to_string()), - env: BTreeMap::from([("FOO".to_string(), "bar".to_string())]), - memory_bytes: Some(4_000_000_000), - cpu: Some(2), - network: NetworkPolicy::Block, - ..SandboxOptions::default() - }; - let (spec, image) = overlay(base_spec(&options, Some(&run_id)), &options); - - assert_eq!(image, "ghcr.io/acme/dev:1"); + let mut requested = LifecycleTimers::default(); + requested.auto_stop_after_idle = Some(Duration::from_mins(45)); + let spec = overlay( + DriverSpec::new(SandboxSource::Image { + reference: "ubuntu:24.04".to_string(), + }) + .network(NetworkPolicy::Block) + .timers(requested), + ); assert!(matches!( &spec.source, - SandboxSource::Image { reference } if reference == "ghcr.io/acme/dev:1" + SandboxSource::Image { reference } if reference == "ubuntu:24.04" )); - assert_eq!( - spec.name.as_deref(), - Some("fabro-run-01HY0000000000000000000000") - ); assert_eq!(spec.working_directory.as_deref(), Some(WORKING_DIRECTORY)); - assert!( - !spec.labels.contains_key("sh.fabro.managed"), - "ownership labels come from the scope the provider is connected through" - ); - assert_eq!(spec.env.get("FOO").map(String::as_str), Some("bar")); - assert_eq!(spec.resources.cpu_cores, Some(2)); - assert_eq!(spec.resources.memory_mb, Some(3815)); assert!(matches!(spec.network, NetworkPolicy::Block)); - assert_eq!(spec.provider_config["auto_pull"], true); + assert_eq!( + spec.timers, + LifecycleTimers::default(), + "docker has no timers to honor the environment's auto-stop with" + ); + let config: DockerProviderConfig = + serde_json::from_value(spec.provider_config).expect("docker provider config"); + assert!(config.auto_pull); } #[test] fn overlay_supplies_the_default_image_when_the_environment_names_none() { - let options = SandboxOptions::default(); - let (spec, image) = overlay(base_spec(&options, None), &options); - assert_eq!(image, DEFAULT_IMAGE); + let spec = overlay(DriverSpec::new(SandboxSource::HostDirectory)); assert!(matches!( &spec.source, SandboxSource::Image { reference } if reference == DEFAULT_IMAGE )); - assert!(spec.name.is_none()); + assert_eq!( + effective_image(&DriverSpec::new(SandboxSource::HostDirectory)), + DEFAULT_IMAGE + ); } } diff --git a/lib/components/fabro-sandbox/src/driver.rs b/lib/components/fabro-sandbox/src/driver.rs index 12fb872d8..d8b738ab4 100644 --- a/lib/components/fabro-sandbox/src/driver.rs +++ b/lib/components/fabro-sandbox/src/driver.rs @@ -16,16 +16,12 @@ use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::Arc; -use async_trait::async_trait; use fabro_static::EnvVars; use fabro_types::settings::server::{ SandboxPluginSettings, ServerSandboxProviderSettings, ServerSandboxProvidersSettings, }; use fabro_types::{BundledProvider, SandboxProviderKind}; -use sandbox_driver::{ - Capabilities, EventContext, ProviderHealth, ProviderKind, Sandbox, SandboxFilter, SandboxId, - SandboxProvider, SandboxSpec, SandboxStatus, SnapshotProvider, VolumeProvider, -}; +use sandbox_driver::{ProviderKind, SandboxProvider}; use sandbox_driver_daytona::{DaytonaConfig, DaytonaProvider}; use sandbox_driver_docker::DockerProvider; use sandbox_driver_host::HostProvider; @@ -38,39 +34,74 @@ pub const PLUGIN_BINARY_PREFIX: &str = "fabro-sandbox"; /// `User-Agent` fabro presents to remote sandbox control planes. pub const USER_AGENT: &str = concat!("fabro-sandbox/", env!("CARGO_PKG_VERSION")); -/// Explicit Daytona credentials. The process environment is never consulted. +/// Explicit Daytona credentials: the SDK's configuration with the API key +/// always present and a `Debug` that never prints it. The process +/// environment is never consulted. #[derive(Clone)] -pub struct DaytonaCredentials { - pub api_key: String, - pub api_url: Option, - pub organization_id: Option, - pub target: Option, - /// Shared HTTP client; tests pass a no-proxy client here. - pub http_client: Option, -} +pub struct DaytonaCredentials(DaytonaConfig); impl DaytonaCredentials { + /// Credentials for `api_key` against Daytona's public control plane, + /// presenting fabro's `User-Agent`. + #[must_use] + pub fn new(api_key: String) -> Self { + Self(DaytonaConfig { + api_key: Some(api_key), + user_agent: Some(USER_AGENT.to_string()), + ..DaytonaConfig::default() + }) + } + /// Credentials for a vault API key, with the control-plane URL and /// organization taken from `lookup` (server configuration, or the /// process environment in a CLI worker). Nothing is read implicitly. pub fn from_api_key(api_key: String, lookup: impl Fn(&str) -> Option) -> Self { - Self { - api_key, - api_url: lookup(EnvVars::DAYTONA_API_URL) - .or_else(|| lookup(EnvVars::DAYTONA_SERVER_URL)), - organization_id: lookup(EnvVars::DAYTONA_ORGANIZATION_ID), - target: None, - http_client: None, - } + Self::new(api_key) + .with_api_url( + lookup(EnvVars::DAYTONA_API_URL).or_else(|| lookup(EnvVars::DAYTONA_SERVER_URL)), + ) + .with_organization_id(lookup(EnvVars::DAYTONA_ORGANIZATION_ID)) + } + + /// The control-plane URL; Daytona's public API when `None`. + #[must_use] + pub fn with_api_url(mut self, api_url: Option) -> Self { + self.0.api_url = api_url; + self + } + + #[must_use] + pub fn with_organization_id(mut self, organization_id: Option) -> Self { + self.0.organization_id = organization_id; + self + } + + /// A shared HTTP client; tests pass a no-proxy client here. + #[must_use] + pub fn with_http_client(mut self, http_client: Option) -> Self { + self.0.http_client = http_client; + self + } + + /// The API key, which every constructor sets. + #[must_use] + pub fn api_key(&self) -> &str { + self.0.api_key.as_deref().unwrap_or_default() + } + + /// The SDK configuration the driver's Daytona provider connects with. + #[must_use] + pub fn config(&self) -> &DaytonaConfig { + &self.0 } } impl std::fmt::Debug for DaytonaCredentials { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("DaytonaCredentials") - .field("api_url", &self.api_url) - .field("organization_id", &self.organization_id) - .field("target", &self.target) + .field("api_url", &self.0.api_url) + .field("organization_id", &self.0.organization_id) + .field("target", &self.0.target) .finish_non_exhaustive() } } @@ -155,8 +186,10 @@ pub enum ConnectError { /// Connects the provider behind `kind`. /// /// Bundled kinds return the in-process driver provider. Any other kind -/// launches the plugin named by `settings.plugin` and returns a supervised -/// handle that relaunches it after a crash for new work only. The +/// launches the plugin named by `settings.plugin` and returns the driver's +/// supervisor, which relaunches the executable after a crash for new work +/// only; handles from an earlier generation stay bound to it, and callers +/// rebuild them through `attach` with the persisted sandbox id. The /// configured kind is fabro's name for whatever the executable serves; the /// kind the plugin declares is not compared against it. Disabled entries /// are refused here so no caller has to remember the policy check. @@ -187,17 +220,8 @@ pub async fn connect_provider( .daytona .as_ref() .ok_or(ConnectError::MissingDaytonaCredentials)?; - let config = DaytonaConfig { - api_key: Some(credentials.api_key.clone()), - jwt_token: None, - organization_id: credentials.organization_id.clone(), - api_url: credentials.api_url.clone(), - target: credentials.target.clone(), - http_client: credentials.http_client.clone(), - user_agent: Some(USER_AGENT.to_string()), - }; Arc::new( - DaytonaProvider::connect_explicit(config) + DaytonaProvider::connect_explicit(credentials.config().clone()) .await .map_err(driver)?, ) @@ -207,7 +231,20 @@ pub async fn connect_provider( .plugin .as_ref() .ok_or_else(|| ConnectError::MissingPluginSettings { kind: kind.clone() })?; - Arc::new(PluginBackedProvider::launch(kind, plugin).await?) + let driver_kind = ProviderKind::try_new(kind.as_str()).map_err(|source| { + ConnectError::InvalidKind { + kind: kind.clone(), + source, + } + })?; + // The supervisor is the provider: it launches the executable now, + // so a misconfigured plugin fails at connect time, and relaunches + // it after a crash for new work only. + Arc::new( + PluginSupervisor::launch(PLUGIN_BINARY_PREFIX, plugin_config(driver_kind, plugin)) + .await + .map_err(driver)?, + ) } }; Ok(ConnectedProvider { @@ -216,57 +253,6 @@ pub async fn connect_provider( }) } -/// A plugin provider that survives its executable crashing. -/// -/// Wraps a [`PluginSupervisor`]: every call obtains the current plugin -/// generation, and a closed transport is replaced with a fresh launch before -/// the call. A failed call is never replayed, and handles obtained from an -/// earlier generation stay bound to it; callers rebuild them through -/// [`SandboxProvider::attach`] with the persisted sandbox id. -pub struct PluginBackedProvider { - kind: ProviderKind, - capabilities: Capabilities, - supervisor: PluginSupervisor, -} - -impl PluginBackedProvider { - async fn launch( - kind: &SandboxProviderKind, - settings: &SandboxPluginSettings, - ) -> Result { - let driver_kind = - ProviderKind::try_new(kind.as_str()).map_err(|source| ConnectError::InvalidKind { - kind: kind.clone(), - source, - })?; - let supervisor = PluginSupervisor::new( - PLUGIN_BINARY_PREFIX, - plugin_config(driver_kind.clone(), settings), - ); - // Launch once now so a misconfigured plugin fails at connect time and - // the declared capabilities are known for preflight. - let capabilities = supervisor - .current() - .await - .map_err(|source| ConnectError::Driver { - kind: kind.clone(), - source, - })? - .capabilities() - .clone(); - Ok(Self { - kind: driver_kind, - capabilities, - supervisor, - }) - } - - /// Asks the current plugin generation to exit and reaps it. - pub async fn shutdown(&self) -> sandbox_driver::Result<()> { - self.supervisor.shutdown().await - } -} - fn plugin_config(kind: ProviderKind, settings: &SandboxPluginSettings) -> PluginConfig { PluginConfig { kind, @@ -283,69 +269,6 @@ fn plugin_config(kind: ProviderKind, settings: &SandboxPluginSettings) -> Plugin } } -#[async_trait] -impl SandboxProvider for PluginBackedProvider { - fn kind(&self) -> &ProviderKind { - &self.kind - } - - fn capabilities(&self) -> &Capabilities { - &self.capabilities - } - - async fn create( - &self, - spec: &SandboxSpec, - events: Option, - ) -> sandbox_driver::Result> { - self.supervisor.current().await?.create(spec, events).await - } - - async fn attach( - &self, - id: &SandboxId, - events: Option, - ) -> sandbox_driver::Result> { - self.supervisor.current().await?.attach(id, events).await - } - - async fn undelete( - &self, - id: &SandboxId, - events: Option, - ) -> sandbox_driver::Result> { - self.supervisor.current().await?.undelete(id, events).await - } - - async fn delete( - &self, - id: &SandboxId, - events: Option, - ) -> sandbox_driver::Result<()> { - self.supervisor.current().await?.delete(id, events).await - } - - async fn list(&self, filter: &SandboxFilter) -> sandbox_driver::Result> { - self.supervisor.current().await?.list(filter).await - } - - async fn health(&self) -> sandbox_driver::Result { - self.supervisor.current().await?.health().await - } - - /// Snapshot and volume management cross the wire per plugin generation, - /// which these borrowing accessors cannot express. Fabro drives - /// snapshots on the bundled Daytona provider only, so a plugin reports - /// none until a generation-aware accessor exists. - fn snapshots(&self) -> Option<&dyn SnapshotProvider> { - None - } - - fn volumes(&self) -> Option<&dyn VolumeProvider> { - None - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/lib/components/fabro-sandbox/src/driver_sandbox.rs b/lib/components/fabro-sandbox/src/driver_sandbox.rs index afe16e52d..e7a5d4f12 100644 --- a/lib/components/fabro-sandbox/src/driver_sandbox.rs +++ b/lib/components/fabro-sandbox/src/driver_sandbox.rs @@ -6,76 +6,36 @@ //! through the handle itself. Nothing here knows which provider is behind //! the handle or whether it runs in-process or over the plugin wire. //! -//! What stays fabro's: the exec ladder, the credential filter on explicit -//! environment variables, and the run-facing conventions (`platform` names, -//! grep line format, walk results relative to a caller-declared base). The -//! driver reports lifecycle events itself, through the [`EventContext`] a -//! sandbox is created or attached with. +//! What stays fabro's: the exec ladder and the run-facing conventions +//! (`platform` names, grep line format, walk results relative to a +//! caller-declared base). The driver reports lifecycle events itself, +//! through the [`EventContext`] a sandbox is created or attached with. use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; -use async_trait::async_trait; use fabro_github::GitHubCredentials; -use fabro_github::token_source::InstallationTokenSource; +use fabro_github::token_source::{InstallationTokenSource, TokenSnapshot}; use fabro_types::SandboxProviderKind; use fabro_util::workspace_glob::WorkspaceGlob; use sandbox_driver::{ - Capability, DirEntry, EventContext, FileKind, GrepMatch, GrepOptions, LifecycleTimers, - PreviewUrl, PreviewUrls, PtyOptions, PtySize, Sandbox as DriverHandle, - SandboxProvider as DriverProvider, SandboxSource, SandboxSpec as DriverSpec, SandboxState, - Search as _, WaitOptions, WalkOptions, + Capability, DirEntry, EventContext, ExecControls, ExecResult, ExecSpec, ExecStreamingResult, + FileKind, GitRetryPolicy, GrepMatch, GrepOptions, PreviewUrl, PreviewUrls, PtyOptions, + PtySession, PtySize, Sandbox as DriverHandle, SandboxProvider as DriverProvider, + SandboxSpec as DriverSpec, SandboxState, Search as _, StdioProcess, WaitOptions, WalkOptions, }; -use sandbox_driver_host::HostProvider; -use tokio::fs; use tokio::sync::OnceCell; use tokio_util::sync::CancellationToken; use crate::clone::{self, GitHubClone}; use crate::clone_source::{self, CloneDecision, EmptyWorkspaceReason}; -use crate::push_credentials::{self, PushCredentialState}; -use crate::terminal::{DriverTerminalSession, TerminalSize}; -use crate::{GitRunInfo, GitSetupIntent, RefreshOutcome, RetryPlan}; - -/// A sandbox on the worker host at `working_directory`, the fabro `local` -/// kind, served by the driver's in-process Host provider. -/// -/// The directory is designated: the sandbox uses it in place and never -/// removes it. It is created when missing so a run can point at a fresh -/// scratch path. The registry lives in a per-process temporary root, so a -/// later process rebuilds the handle by calling this again with the -/// persisted working directory rather than by id. -pub async fn local_sandbox(working_directory: impl Into) -> crate::Result { - local_sandbox_with_events(working_directory, None).await -} - -/// [`local_sandbox`] whose driver lifecycle events reach `events`. -pub async fn local_sandbox_with_events( - working_directory: impl Into, - events: Option, -) -> crate::Result { - let working_directory: PathBuf = working_directory.into(); - fs::create_dir_all(&working_directory) - .await - .map_err(|error| crate::Error::context("Failed to create working directory", error))?; - let provider = HostProvider::new(); - let spec = DriverSpec::new(SandboxSource::HostDirectory) - .working_directory(working_directory.display().to_string()); - let handle = provider - .create(&spec, events) - .await - .map_err(|error| crate::Error::context("Failed to create local sandbox", error))?; - let sandbox = RunSandbox::new(SandboxProviderKind::LOCAL, handle); - sandbox.learn_platform().await?; - Ok(sandbox) -} -use crate::exec::{ExplicitEnvPolicy, SandboxExec}; -use crate::sandbox::{ - self, ExecResult, ExecStreamingRequest, ExecStreamingResult, PushError, PushReport, - SandboxFile, SandboxWorkspaceLayout, StdioProcess, -}; +use crate::credentials::{self, RepoCredentials}; +use crate::environment::CloneRequest; +use crate::exec::SandboxExec; +use crate::sandbox::{self, PushError, PushReport, SandboxFile, SandboxWorkspaceLayout}; +use crate::{GitRunInfo, GitSetupIntent}; /// Where a clone-based provider puts its files: the run works under /// `workspace_root`, and repositories check out under `repos_root`. @@ -116,12 +76,14 @@ enum WorkspacePlan { Attached, } -/// Fabro's clone-based workspace on an isolated sandbox: the layout, the -/// clone it performs, and the GitHub credentials its checkout carries. +/// The run's workspace on a sandbox: the layout, the clone fabro performs +/// into it (if any), and the GitHub credentials its checkout carries. A +/// workspace fabro did not clone into is still a checkout the run may push +/// from, with whatever credentials the checkout carries itself. pub(crate) struct RepoWorkspace { layout: OnceLock, plan: WorkspacePlan, - credentials: PushCredentialState, + credentials: RepoCredentials, repo_cloned: OnceLock, origin_url: OnceLock, /// The directory the run works in once known: the repository link for a @@ -135,31 +97,22 @@ pub(crate) struct RepoWorkspace { impl RepoWorkspace { /// Decide the clone for a new sandbox. Fails before any provider call /// when the selectors are inconsistent (a pin without a branch, a - /// non-GitHub origin without `skip_clone`). - #[expect( - clippy::too_many_arguments, - reason = "the clone selectors are validated together by decide_clone" - )] + /// non-GitHub origin without `skip`). pub(crate) fn plan( layout: LayoutSource, - skip_clone: bool, - clone_origin_url: Option<&str>, - clone_branch: Option<&str>, - clone_tag: Option<&str>, - clone_commit_sha: Option<&str>, - clone_depth: Option, + clone: &CloneRequest, github_app: Option<&GitHubCredentials>, ) -> crate::Result { let decision = clone_source::decide_clone( - skip_clone, - clone_origin_url, - clone_branch, - clone_tag, - clone_commit_sha, + clone.skip, + clone.origin_url.as_deref(), + clone.branch.as_deref(), + clone.tag.as_deref(), + clone.commit_sha.as_deref(), )?; - let credentials = PushCredentialState::new(push_credentials::build_token_source( + let credentials = RepoCredentials::new(credentials::build_token_source( github_app, - clone_origin_url, + clone.origin_url.as_deref(), )?); let plan = match decision { CloneDecision::EmptyWorkspace { reason } => WorkspacePlan::Empty(reason), @@ -173,7 +126,7 @@ impl RepoWorkspace { branch, tag, commit_sha, - depth: clone_depth, + depth: clone.depth, }), }; Ok(Self { @@ -189,7 +142,7 @@ impl RepoWorkspace { /// A workspace prepared by an earlier process, described by the run /// record. Pushes from a reattached sandbox use whatever credentials the - /// checkout's `origin` already carries. + /// checkout's credential store already carries. pub(crate) fn attached( layout: LayoutSource, repo_cloned: bool, @@ -199,7 +152,7 @@ impl RepoWorkspace { let workspace = Self { layout: layout.into_cell(), plan: WorkspacePlan::Attached, - credentials: PushCredentialState::new(None), + credentials: RepoCredentials::none(), repo_cloned: OnceLock::new(), origin_url: OnceLock::new(), execution_directory: OnceLock::new(), @@ -216,6 +169,21 @@ impl RepoWorkspace { workspace } + /// The workspace an existing handle already works in, whatever it + /// holds: nothing fabro cloned, laid out from the handle's own working + /// directory. + pub(crate) fn existing() -> Self { + Self { + layout: LayoutSource::ProviderWorkingDirectory.into_cell(), + plan: WorkspacePlan::Attached, + credentials: RepoCredentials::none(), + repo_cloned: OnceLock::new(), + origin_url: OnceLock::new(), + execution_directory: OnceLock::new(), + checkout_path: OnceLock::new(), + } + } + /// Settle a provider-dependent layout from the sandbox's working /// directory. A fixed layout is left alone. fn resolve_layout(&self, provider_working_directory: &str) -> &WorkspaceLayout { @@ -285,69 +253,38 @@ impl LayoutSource { } } -/// What a create needs once its inputs are settled. -#[derive(Clone)] -pub(crate) struct PreparedCreate { - pub(crate) spec: DriverSpec, - /// The provider snapshot the sandbox is created from, when the provider - /// has that concept; recorded on the run. - pub(crate) snapshot: Option, -} - -/// Settles a create's inputs right before the provider call. A plan may -/// build provider resources first (a Daytona snapshot); the driver reports -/// that work through `events`. -#[async_trait] -pub(crate) trait CreatePlan: Send + Sync { - async fn prepare(&self, events: Option) -> crate::Result; -} - -/// A create whose spec is known up front. -struct SpecPlan(PreparedCreate); - -#[async_trait] -impl CreatePlan for SpecPlan { - async fn prepare(&self, _events: Option) -> crate::Result { - Ok(self.0.clone()) - } -} - /// A sandbox that does not exist yet: `initialize` creates it on the -/// provider from the plan's spec. +/// provider from `spec`. struct PendingCreate { provider: Arc, - plan: Box, + spec: DriverSpec, } /// A fabro sandbox backed by a sandbox-driver handle. pub struct RunSandbox { - kind: SandboxProviderKind, + kind: SandboxProviderKind, /// Set at construction for an existing sandbox, at `initialize` for a /// pending one. - handle: OnceCell>, - pending: Option, - workspace: Option, - env_policy: ExplicitEnvPolicy, + handle: OnceCell>, + pending: Option, + workspace: RepoWorkspace, /// Where the driver reports the lifecycle of a sandbox this creates. /// Set before `initialize` on a pending sandbox; an existing handle /// already carries the context it was created or attached with. - events: Option, + events: Option, /// `(platform, os_version)` learned from the sandbox at initialize or /// start; unknown until then. - platform: OnceLock<(String, String)>, + platform: OnceLock<(String, String)>, /// The provider snapshot the sandbox was created from, when known. - snapshot: OnceLock, + snapshot: OnceLock, } impl RunSandbox { - /// Wraps a driver handle. `local` runs on the worker host, so explicit - /// environment variables pass the credential filter; every other kind - /// is isolated and takes the caller's environment as composed. + /// Wraps an existing driver handle as a sandbox of `kind`, working in + /// whatever the handle's working directory holds. #[must_use] pub fn new(kind: SandboxProviderKind, handle: Arc) -> Self { - let sandbox = Self::empty(kind); - let _ = sandbox.handle.set(handle); - sandbox + Self::attached(kind, handle, RepoWorkspace::existing()) } /// A sandbox over an existing handle whose platform is already known, @@ -372,28 +309,8 @@ impl RunSandbox { spec: DriverSpec, workspace: RepoWorkspace, ) -> Self { - Self::pending_with_plan( - kind, - provider, - Box::new(SpecPlan(PreparedCreate { - spec, - snapshot: None, - })), - workspace, - ) - } - - /// A sandbox `initialize` will create on `provider` once `plan` has - /// settled its spec, then prepare per `workspace`. - pub(crate) fn pending_with_plan( - kind: SandboxProviderKind, - provider: Arc, - plan: Box, - workspace: RepoWorkspace, - ) -> Self { - let mut sandbox = Self::empty(kind); - sandbox.pending = Some(PendingCreate { provider, plan }); - sandbox.workspace = Some(workspace); + let mut sandbox = Self::empty(kind, workspace); + sandbox.pending = Some(PendingCreate { provider, spec }); sandbox } @@ -410,23 +327,17 @@ impl RunSandbox { workspace: RepoWorkspace, ) -> Self { workspace.resolve_layout(handle.working_directory()); - let mut sandbox = Self::new(kind, handle); - sandbox.workspace = Some(workspace); + let sandbox = Self::empty(kind, workspace); + let _ = sandbox.handle.set(handle); sandbox } - fn empty(kind: SandboxProviderKind) -> Self { - let env_policy = if kind.is_local() { - ExplicitEnvPolicy::FilterSensitive - } else { - ExplicitEnvPolicy::TrustCaller - }; + fn empty(kind: SandboxProviderKind, workspace: RepoWorkspace) -> Self { Self { kind, handle: OnceCell::new(), pending: None, - workspace: None, - env_policy, + workspace, events: None, platform: OnceLock::new(), snapshot: OnceLock::new(), @@ -461,11 +372,9 @@ impl RunSandbox { /// Fabro's exec policy over the driver's exec facet, working in the /// run's directory. Absent until a pending sandbox is initialized. pub fn exec(&self) -> crate::Result> { - let mut exec = SandboxExec::new(self.handle()?.exec(), self.env_policy); - if let Some(workspace) = &self.workspace { - if let Some(dir) = workspace.execution_directory.get() { - exec = exec.with_working_dir(dir.clone()); - } + let mut exec = SandboxExec::new(self.handle()?.exec()); + if let Some(dir) = self.workspace.execution_directory.get() { + exec = exec.with_working_dir(dir.clone()); } Ok(exec) } @@ -474,19 +383,17 @@ impl RunSandbox { /// resolves relative paths against the sandbox's own working directory, /// which sits above a cloned repository's link. fn resolve(&self, path: &str) -> String { - match self - .workspace - .as_ref() - .and_then(|workspace| workspace.execution_directory.get()) - { + match self.workspace.execution_directory.get() { Some(working_directory) => sandbox::resolve_path(path, working_directory), None => path.to_string(), } } - /// The driver's git facet for this sandbox's checkout. Absent until a - /// pending sandbox is initialized, or when the provider has no git. - pub(crate) fn git(&self) -> crate::Result> { + /// The driver's git facet for this sandbox's checkout, for fabro's own + /// git operations (checkpoints, diffs, the Run Files listing). Absent + /// until a pending sandbox is initialized, or when the provider has no + /// git. Pass [`Self::working_directory`] as the repository path. + pub fn git(&self) -> crate::Result> { self.handle()?.git().ok_or_else(|| { crate::Error::message(format!( "sandbox provider `{}` does not support git", @@ -495,6 +402,20 @@ impl RunSandbox { }) } + /// The driver's services facet for this sandbox: background processes + /// that outlive their exec (the agent's MCP servers, dev servers), the + /// wait for a port to answer, and the list of listeners. Absent until a + /// pending sandbox is initialized, or when the provider has no + /// services. + pub fn services(&self) -> crate::Result> { + self.handle()?.services().ok_or_else(|| { + crate::Error::message(format!( + "sandbox provider `{}` does not support background services", + self.kind + )) + }) + } + fn search(&self) -> crate::Result> { self.handle()?.search().ok_or_else(|| { crate::Error::message(format!( @@ -512,23 +433,27 @@ impl RunSandbox { let Some(pending) = &self.pending else { return self.handle().map(|_| ()); }; - let prepared = pending.plan.prepare(self.events.clone()).await?; - if let Some(snapshot) = prepared.snapshot { - let _ = self.snapshot.set(snapshot); - } let handle = pending .provider - .create(&prepared.spec, self.events.clone()) + .create(&pending.spec, self.events.clone()) .await .map_err(|error| { crate::Error::context(format!("Failed to create {} sandbox", self.kind), error) })?; + // The provider may have created the sandbox from a snapshot it + // built or chose (Daytona caches images as snapshots); the run + // record names it. + if let Ok(status) = handle.describe().await { + if let Some(snapshot) = status.snapshot { + let _ = self.snapshot.set(snapshot); + } + } let _ = self.handle.set(handle); Ok(()) } /// Bring the sandbox to `Running` with a verified Bash, and learn its - /// platform. Shared by initialize and start. + /// platform. Shared by initialize and activate. async fn make_ready(&self) -> crate::Result<()> { sandbox_driver::activate(self.handle()?.as_ref(), &WaitOptions::default()).await?; self.learn_platform().await @@ -537,9 +462,7 @@ impl RunSandbox { /// Prepare the workspace after the sandbox runs for the first time: /// an empty root, or fabro's clone. async fn prepare_workspace(&self) -> crate::Result<()> { - let Some(workspace) = &self.workspace else { - return Ok(()); - }; + let workspace = &self.workspace; let layout = workspace .resolve_layout(self.handle()?.working_directory()) .clone(); @@ -579,7 +502,7 @@ impl RunSandbox { let handle = self.handle()?; // The clone names every directory it touches, so it runs // without fabro's working-directory override. - let exec = SandboxExec::new(handle.exec(), self.env_policy); + let exec = SandboxExec::new(handle.exec()); let outcome = clone::clone_github_repo( &self.kind, handle.as_ref(), @@ -623,7 +546,7 @@ impl RunSandbox { /// Open an interactive shell in the sandbox's working directory over the /// driver's Pty facet. - pub async fn open_terminal(&self, size: TerminalSize) -> crate::Result { + pub async fn open_terminal(&self, size: PtySize) -> crate::Result> { let handle = self.handle()?; let pty = handle.pty().ok_or_else(|| { crate::Error::message(format!( @@ -632,16 +555,11 @@ impl RunSandbox { )) })?; let mut options = PtyOptions::default(); - options.size = PtySize { - rows: size.rows, - cols: size.cols, - }; + options.size = size; options.working_dir = Some(self.working_directory().to_string()); - let session = pty - .open(&options) + pty.open(&options) .await - .map_err(|error| crate::Error::context("Failed to open sandbox terminal", error))?; - Ok(DriverTerminalSession::new(session)) + .map_err(|error| crate::Error::context("Failed to open sandbox terminal", error)) } /// Ask the sandbox for its platform once; `platform` and `os_version` @@ -669,11 +587,7 @@ impl RunSandbox { // A cloned repository is reached through a workspace link. The // driver refuses a symlinked traversal root, so walk the real // checkout; results are reported under the link. - if let Some(checkout) = self - .workspace - .as_ref() - .and_then(|workspace| workspace.checkout_path.get()) - { + if let Some(checkout) = self.workspace.checkout_path.get() { return sandbox::join_sandbox_path(checkout, relative_start); } if relative_start.is_empty() { @@ -713,23 +627,6 @@ impl RunSandbox { .map_err(|err| crate::Error::context("File is not valid UTF-8", err)) } - /// A file's text with line numbers, from `offset` for `limit` lines. - /// - /// Kept only for `fabro-agent`, which is being deleted; pebble's - /// `Environment::read_file` is the numbered read from then on. - pub async fn read_file( - &self, - path: &str, - offset: Option, - limit: Option, - ) -> crate::Result { - Ok(sandbox::format_lines_numbered( - &self.read_file_text(path).await?, - offset, - limit, - )) - } - pub async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> { self.handle()? .fs() @@ -803,22 +700,29 @@ impl RunSandbox { .await } + /// Runs `spec` under fabro's exec policy, delivering output through + /// `controls.sink` as it arrives. Build the spec with + /// [`ExecSpec::bash`]; the policy fills the stop grace, the run's + /// working directory, and the environment filter where the spec leaves + /// them open. pub async fn exec_command_streaming( &self, - request: ExecStreamingRequest<'_>, + spec: ExecSpec, + controls: ExecControls, ) -> crate::Result { - self.exec()?.run_streaming(request).await + self.exec()?.run_streaming(spec, controls).await } + /// Launches a long-lived process with bidirectional stdio. The returned + /// handle terminates the process; dropping it does not. pub async fn spawn_stdio_process( &self, command: &str, working_dir: Option<&str>, env_vars: Option<&HashMap>, - cancel_token: Option, ) -> crate::Result { self.exec()? - .spawn_stdio(command, working_dir, env_vars, cancel_token) + .spawn_stdio(command, working_dir, env_vars) .await } @@ -936,12 +840,8 @@ impl RunSandbox { } /// The provider's console page for this sandbox, when it has one. Best - /// effort: a failed describe reports no page. The local sandbox is the - /// host and has none. + /// effort: a failed describe reports no page. pub async fn console_url(&self) -> Option { - if self.kind.is_local() { - return None; - } self.handle() .ok()? .describe() @@ -950,48 +850,37 @@ impl RunSandbox { .and_then(|status| status.web_url) } - /// Idempotent access-time check: a running sandbox is left alone; a - /// stopped or paused one is brought back and its Bash verified. + /// Brings the sandbox back into use, idempotently: a running sandbox is + /// left alone and only its platform is learned when unknown; a stopped + /// or paused one is started and its Bash verified. Resume and every + /// access-time caller share this one entry point. pub async fn activate(&self) -> crate::Result<()> { let status = self.handle()?.describe().await?; if status.state == SandboxState::Running { - return Ok(()); + return self.learn_platform().await; } self.make_ready().await } - pub async fn start(&self) -> crate::Result<()> { - self.make_ready().await - } - pub async fn stop(&self) -> crate::Result<()> { self.handle()?.stop().await.map_err(crate::Error::from) } - pub async fn delete(&self) -> crate::Result<()> { - self.release().await - } - /// Releases the sandbox. For a designated host directory this frees the /// handle and leaves the directory in place; for an isolated provider it - /// removes the sandbox. - pub async fn cleanup(&self) -> crate::Result<()> { + /// removes the sandbox. A pending sandbox that was never created has + /// nothing to release. + pub async fn delete(&self) -> crate::Result<()> { self.release().await } /// The directory the run works in: the cloned repository's link for a /// clone-based workspace, the provider's working directory otherwise. pub fn working_directory(&self) -> &str { - if let Some(directory) = self - .workspace - .as_ref() - .and_then(RepoWorkspace::working_directory) - { - return directory; - } - self.handle - .get() - .map_or("", |handle| handle.working_directory()) + self.workspace + .working_directory() + .or_else(|| self.handle.get().map(|handle| handle.working_directory())) + .unwrap_or("") } pub fn runtime_directory(&self) -> Option<&str> { @@ -1013,14 +902,10 @@ impl RunSandbox { ) } - /// The provider's id for this sandbox, or empty for `local`: a local - /// sandbox is its working directory, which the run record already - /// carries, and its Host registry id does not outlive the process. - /// Empty for a pending sandbox that has not been created. + /// The provider's id for this sandbox; for `local`, the id the Host + /// provider derives from the working directory. Empty for a pending + /// sandbox that has not been created. pub fn sandbox_info(&self) -> String { - if self.kind.is_local() { - return String::new(); - } self.handle .get() .map(|handle| handle.id().to_string()) @@ -1032,23 +917,7 @@ impl RunSandbox { } pub fn workspace_layout(&self) -> Option { - self.workspace.as_ref().and_then(RepoWorkspace::record) - } - - pub async fn set_autostop_interval(&self, minutes: i32) -> crate::Result<()> { - let mut timers = LifecycleTimers::default(); - timers.auto_stop_after_idle = u64::try_from(minutes) - .ok() - .filter(|minutes| *minutes > 0) - .map(Duration::from_mins); - match self.handle()?.set_timers(&timers).await { - // A provider without timers has nothing to stop automatically. - Ok(()) | Err(sandbox_driver::Error::Unsupported { .. }) => Ok(()), - Err(error) => Err(crate::Error::context( - "Failed to set sandbox auto-stop", - error, - )), - } + self.workspace.record() } pub async fn setup_git(&self, intent: &GitSetupIntent) -> crate::Result> { @@ -1058,84 +927,64 @@ impl RunSandbox { sandbox::setup_git(self, intent).await.map(Some) } - pub fn resume_setup_commands(&self, run_branch: &str) -> Vec { - if !self.repo_cloned() { - return Vec::new(); - } - vec![format!( - "git fetch origin {} && git checkout {}", - sandbox::shell_quote(run_branch), - sandbox::shell_quote(run_branch) - )] - } - + /// Push `refspec` from the run's checkout. A checkout fabro cloned + /// pushes with the credentials it was cloned with. Any other checkout + /// pushes only when it has an origin, with whatever credentials it + /// carries itself; a workspace without one has nothing to push. pub async fn git_push_ref( &self, refspec: &str, - plan: &RetryPlan, + policy: &GitRetryPolicy, ) -> Result { - let Some(workspace) = &self.workspace else { - // A designated directory: push only when the checkout has an - // origin, with whatever credentials its URL already carries. - let has_origin = match self - .exec_command("git remote get-url origin", 10_000, None, None, None) - .await - { - Ok(result) if result.is_success() => true, - Ok(_) => false, - Err(err) => { - return Err(PushError { - report: PushReport::default(), - error: crate::Error::context("git remote get-url origin", err), - }); - } - }; - if !has_origin { - return Ok(PushReport::default()); + let workspace = &self.workspace; + if workspace.repo_cloned() { + return sandbox::git_push(self, Some(&workspace.credentials), refspec, policy).await; + } + let has_origin = match self + .exec_command("git remote get-url origin", 10_000, None, None, None) + .await + { + Ok(result) => result.success(), + Err(err) => { + return Err(PushError { + report: PushReport::default(), + error: crate::Error::context("git remote get-url origin", err), + }); } - return sandbox::git_push(self, None, refspec, plan).await; }; - if !workspace.repo_cloned() { + if !has_origin { return Ok(PushReport::default()); } - let credentials = workspace - .origin_url - .get() - .map(|origin_url| (&workspace.credentials, origin_url.as_str())); - sandbox::git_push(self, credentials, refspec, plan).await + sandbox::git_push(self, None, refspec, policy).await } pub fn origin_url(&self) -> Option<&str> { - let workspace = self.workspace.as_ref()?; - if !workspace.repo_cloned() { + if !self.workspace.repo_cloned() { return None; } - workspace.origin_url.get().map(String::as_str) + self.workspace.origin_url.get().map(String::as_str) } + /// Renew the credentials the agent's own git commands read for the + /// checkout: resolve the current token and rewrite the checkout's + /// credential store with it. Returns the token's non-secret description, + /// or `None` when this sandbox has no managed credentials or no + /// checkout to install them in. #[tracing::instrument(name = "git_op", skip_all, fields(op = "refresh-credentials"))] - pub async fn refresh_push_credentials(&self) -> crate::Result { - let Some(workspace) = &self.workspace else { - return Ok(RefreshOutcome::none()); + pub async fn refresh_ambient_credentials(&self) -> crate::Result> { + let workspace = &self.workspace; + let Some(checkout) = workspace.checkout_path.get() else { + return Ok(None); }; - if !workspace.repo_cloned() { - return Ok(RefreshOutcome::none()); - } - let Some(origin_url) = workspace.origin_url.get() else { - return Ok(RefreshOutcome::none()); + let Some(token) = workspace.credentials.resolve().await? else { + return Ok(None); }; - workspace - .credentials - .refresh(origin_url, |auth_url| { - push_credentials::set_auth_url_via_exec(self, auth_url) - }) - .await + RepoCredentials::install(&self.git()?, checkout, &token).await?; + Ok(Some(token.snapshot)) } pub fn push_token_source(&self) -> Option> { - self.workspace - .as_ref() - .and_then(|workspace| workspace.credentials.source().cloned()) + self.workspace.credentials.source().cloned() } /// The local command that opens a shell in the sandbox, from the @@ -1213,9 +1062,7 @@ impl PreviewUrls for SandboxPortRoutes { impl RunSandbox { fn repo_cloned(&self) -> bool { - self.workspace - .as_ref() - .is_some_and(RepoWorkspace::repo_cloned) + self.workspace.repo_cloned() } /// Delete the sandbox on the provider. A pending sandbox that was never @@ -1237,12 +1084,16 @@ fn elapsed_ms(started: Instant) -> u64 { mod tests { use std::sync::Mutex; - use fabro_types::CommandTermination; - use sandbox_driver::{SandboxProvider as _, SandboxSource, SandboxSpec}; + use async_trait::async_trait; + use sandbox_driver::{SandboxProvider as _, SandboxSource, SandboxSpec, Termination}; use sandbox_driver_host::HostProvider; use tokio::fs; use super::*; + use crate::driver::ProviderAccess; + use crate::exec::ExecResultExt; + use crate::provider_sandbox::local_sandbox; + use crate::sandbox_spec::SandboxSpec as RunSandboxSpec; struct Fixture { dir: tempfile::TempDir, @@ -1290,7 +1141,10 @@ mod tests { .read_file_text("nonexistent.txt") .await .unwrap_err(); - assert!(read.is_not_found(), "{read}"); + assert!( + matches!(read.driver(), Some(sandbox_driver::Error::NotFound { .. })), + "{read}" + ); } #[tokio::test] @@ -1329,15 +1183,15 @@ mod tests { ) .await .unwrap(); - assert_eq!(ok.stdout, "hello\nbash\n"); - assert!(ok.is_success()); + assert_eq!(ok.stdout_lossy(), "hello\nbash\n"); + assert!(ok.success()); let timed_out = f .sandbox .exec_command("sleep 10", 200, None, None, None) .await .unwrap(); - assert_eq!(timed_out.termination, CommandTermination::TimedOut); - assert_eq!(timed_out.exit_code, None); + assert_eq!(timed_out.termination, Termination::TimedOut); + assert_eq!(timed_out.program_exit_code(), None); } #[tokio::test] @@ -1471,14 +1325,13 @@ mod tests { async fn lifecycle_reaches_the_driver_events_and_learns_the_platform() { let dir = tempfile::tempdir().unwrap(); let recorded = Arc::new(Recorded(Mutex::new(Vec::new()))); - let sandbox = local_sandbox_with_events( - dir.path(), - Some(EventContext::new( + let sandbox = RunSandboxSpec::local(dir.path(), ProviderAccess::default()) + .build(Some(EventContext::new( Arc::clone(&recorded) as Arc - )), - ) - .await - .unwrap(); + ))) + .await + .unwrap(); + sandbox.initialize().await.unwrap(); let expected = if cfg!(target_os = "macos") { "darwin" } else { @@ -1486,19 +1339,20 @@ mod tests { }; assert_eq!(sandbox.platform(), expected); assert!(sandbox.os_version().starts_with(expected)); - assert_eq!( - sandbox.sandbox_info(), - "", - "local sandboxes are identified by directory" - ); let handle = Arc::clone(sandbox.handle().unwrap()); + assert_eq!(sandbox.sandbox_info(), handle.id().to_string()); + assert!( + sandbox.sandbox_info().starts_with("host-dir-"), + "a local sandbox is identified by its directory: {}", + sandbox.sandbox_info() + ); let isolated = RunSandbox::new(SandboxProviderKind::DOCKER, Arc::clone(&handle)); assert_eq!(isolated.sandbox_info(), handle.id().to_string()); assert_eq!(sandbox.console_url().await, None); sandbox.stop().await.unwrap(); sandbox.activate().await.unwrap(); - sandbox.cleanup().await.unwrap(); + sandbox.delete().await.unwrap(); assert!( dir.path().is_dir(), "designated directories survive cleanup" @@ -1550,7 +1404,7 @@ mod tests { Path::new(sandbox.working_directory()), workspace.canonicalize().unwrap() ); - sandbox.cleanup().await.unwrap(); + sandbox.delete().await.unwrap(); assert!(workspace.is_dir()); } diff --git a/lib/components/fabro-sandbox/src/environment.rs b/lib/components/fabro-sandbox/src/environment.rs index b1e78a4cd..76461fbce 100644 --- a/lib/components/fabro-sandbox/src/environment.rs +++ b/lib/components/fabro-sandbox/src/environment.rs @@ -1,364 +1,314 @@ -//! [`RunSandbox`] as the [`Environment`] pebble's coding agent runs in. +//! What an environment asks of a sandbox, mapped once onto the driver's spec. //! -//! Pebble's tools speak the `Environment` contract; fabro's one sandbox type -//! speaks the sandbox driver's facets. This module is the mapping between the -//! two, and nothing else: every path resolves the way fabro resolves it, every -//! command runs through [`SandboxExec`](crate::SandboxExec) with fabro's -//! environment policy, and every failure keeps its driver cause. There is no -//! adapter struct; a run sandbox *is* an environment. -//! -//! Where the two contracts differ, pebble's wins here because the model reads -//! pebble's: a glob that pebble rejects is rejected before the driver sees it, -//! a directory listing is in tree order, and a command with no retention cap -//! still drains under the driver's default buffer rather than without bound. +//! The environment names an image or Dockerfile, resources, a network +//! policy, labels, variables, and a lifecycle. Every provider starts from +//! the same driver [`SandboxSpec`] built here; a bundled provider adds only +//! what its backend needs on top (the Docker working directory and default +//! image, the Daytona snapshot and timers) in its own overlay, and the +//! ownership scope adds fabro's labels. The clone policy travels beside the +//! spec as a [`CloneRequest`]: cloning is fabro's work once the sandbox +//! exists, not the provider's. -use std::sync::Arc; +use std::collections::BTreeMap; -use async_trait::async_trait; -use fabro_types::CommandOutputStream; -use pebble_coding_agent::environment::support::{capture_stats, tree_order, validate_glob}; -use pebble_coding_agent::environment::{ - DirEntry, EnvResult, Environment, EnvironmentError, EnvironmentErrorKind, ExecOutcome, - ExecOutputSink, ExecOutputStream, ExecRequest, ExecResult, GrepOptions, +use fabro_types::RunId; +use fabro_types::settings::run::{ + DockerfileSource, EnvironmentNetworkMode, RunCloneSettings, RunEnvironmentSettings, +}; +use sandbox_driver::{ + Capabilities, LifecycleTimers, NetworkPolicy, Resources, SandboxSource, SandboxSpec, }; -use sandbox_driver::FileKind; -use crate::driver_sandbox::RunSandbox; -use crate::sandbox::{self, CommandOutputCallback, ExecStreamingRequest}; +/// What to clone into a provider sandbox, if anything. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CloneRequest { + pub origin_url: Option, + /// The branch the checkout works on. + pub branch: Option, + /// A tag to pin the checkout to; the branch still names the checkout. + pub tag: Option, + /// An exact commit to pin the checkout to, authoritative over `tag`. + pub commit_sha: Option, + /// Maximum Git history depth fetched; `None` fetches full history. + pub depth: Option, + /// Create an empty workspace instead of cloning, even when an origin + /// is present. + pub skip: bool, +} -#[async_trait] -impl Environment for RunSandbox { - fn working_directory(&self) -> &str { - Self::working_directory(self) - } - - fn platform(&self) -> &str { - Self::platform(self) - } - - fn os_version(&self) -> String { - Self::os_version(self) - } - - async fn read_file_bytes(&self, path: &str) -> EnvResult> { - Self::read_file_bytes(self, path) - .await - .map_err(|error| environment_error(&format!("Failed to read {path}"), error)) - } - - async fn write_file(&self, path: &str, content: &str) -> EnvResult<()> { - Self::write_file(self, path, content) - .await - .map_err(|error| environment_error(&format!("Failed to write {path}"), error)) - } - - async fn rename_file(&self, source: &str, destination: &str) -> EnvResult<()> { - let resolved_source = self.resolve_for_environment(source); - let resolved_destination = self.resolve_for_environment(destination); - if !Self::file_exists(self, source) - .await - .map_err(|error| environment_error(&format!("Failed to stat {source}"), error))? - { - return Err(EnvironmentError::new( - EnvironmentErrorKind::NotFound, - format!("Failed to move {source}: file does not exist"), - )); +impl CloneRequest { + /// No clone: the run starts in an empty workspace. + #[must_use] + pub fn none() -> Self { + Self { + skip: true, + ..Self::default() } - // The same path spelled twice is a move to itself, which must leave - // the file where it is. Aliases the sandbox's own filesystem would - // resolve (a symlinked parent, a hard link) are not checked: fabro has - // no remote `realpath`, and a driver `mv a a` is a no-op anyway. - if normalize(&resolved_source) == normalize(&resolved_destination) { - return Ok(()); + } + + /// The environment's clone policy: whether to clone and how deep. The + /// origin and the selectors come from the run's target. + #[must_use] + pub fn from_settings(clone: &RunCloneSettings) -> Self { + Self { + depth: clone + .depth_limit() + .and_then(|depth| u32::try_from(depth).ok()), + skip: !clone.enabled, + ..Self::default() } - let handle = self - .handle() - .map_err(|error| environment_error("Sandbox is not initialized", error))?; - // The destination's parent is created first, and a parent that is a - // file fails here, before anything has moved, so the source stays - // intact as the contract requires. - if let Some(parent) = parent_directory(&resolved_destination) { - handle.fs().create_dir(parent).await.map_err(|error| { - environment_error( - &format!("Failed to create the parent directory of {destination}"), - crate::Error::from(error), - ) - })?; - } - handle - .fs() - .rename(&resolved_source, &resolved_destination) - .await - .map_err(|error| { - environment_error( - &format!("Failed to move {source} to {destination}"), - crate::Error::from(error), - ) - }) - } - - async fn delete_file(&self, path: &str) -> EnvResult<()> { - // The driver's delete is idempotent; pebble's is a `remove_file`, which - // reports a path that is not there. - if !Self::file_exists(self, path) - .await - .map_err(|error| environment_error(&format!("Failed to stat {path}"), error))? - { - return Err(EnvironmentError::new( - EnvironmentErrorKind::NotFound, - format!("Failed to delete {path}: file does not exist"), - )); - } - Self::delete_file(self, path) - .await - .map_err(|error| environment_error(&format!("Failed to delete {path}"), error)) - } - - async fn file_exists(&self, path: &str) -> EnvResult { - Self::file_exists(self, path) - .await - .map_err(|error| environment_error(&format!("Failed to stat {path}"), error)) - } - - async fn list_directory(&self, path: &str, depth: Option) -> EnvResult> { - let mut entries: Vec = Self::list_directory(self, path, depth) - .await - .map_err(|error| environment_error(&format!("Failed to list {path}"), error))? - .into_iter() - .map(|entry| DirEntry { - is_dir: entry.kind == FileKind::Directory, - size: (entry.kind == FileKind::File) - .then_some(entry.size) - .flatten(), - name: entry.path, - }) - .collect(); - // The driver lists in flat lexicographic order of the whole relative - // path, where `foo-bar` sorts between `foo` and `foo/x`. Pebble lists - // in tree order, and says how. - tree_order(&mut entries); - Ok(entries) - } - - async fn grep( - &self, - pattern: &str, - path: &str, - options: &GrepOptions, - ) -> EnvResult> { - let mut driver_options = sandbox_driver::GrepOptions::default(); - driver_options.case_insensitive = options.case_insensitive; - driver_options.max_matches = options.max_results; - driver_options.include = options.glob_filter.clone(); - let matches = Self::grep(self, pattern, path, &driver_options) - .await - .map_err(|error| environment_error("Failed to search file contents", error))?; - Ok(matches - .into_iter() - .map(|found| format!("{}:{}:{}", found.path, found.line_number, found.line)) - .collect()) - } - - async fn glob(&self, pattern: &str, path: Option<&str>) -> EnvResult> { - // Validated by pebble's own grammar before the driver sees the - // pattern, so the reason reaches the model in pebble's words and the - // patterns pebble rejects are rejected even where fabro's glob would - // accept them. - validate_glob(pattern)?; - Self::glob(self, pattern, path) - .await - .map_err(|error| environment_error("Failed to match files", error)) - } - - async fn exec(&self, request: ExecRequest<'_>) -> EnvResult { - let ExecRequest { - command, - timeout_ms, - working_dir, - env_vars, - cancel_token, - output_bytes_cap, - output_sink, - } = request; - let streaming = self - .exec_command_streaming(ExecStreamingRequest { - timeout_ms, - working_dir, - env_vars, - cancel_token, - stdin: None, - output_callback: output_sink.map(adapt_output_sink), - // `None` asks pebble for no cap at all. The driver always - // retains under a buffer, so a command with no cap drains under - // the driver's default rather than without bound; the capture - // counts still say what was dropped. - stream_output_bytes_cap: output_bytes_cap, - ..ExecStreamingRequest::new(command) - }) - .await - .map_err(|error| { - let kind = if error.is_transport() { - EnvironmentErrorKind::Io - } else { - EnvironmentErrorKind::Spawn - }; - EnvironmentError::with_source(kind, "Failed to run the command", error) - })?; - Ok(ExecOutcome { - result: ExecResult { - stdout: streaming.result.stdout, - stderr: streaming.result.stderr, - exit_code: streaming.result.exit_code, - termination: streaming.result.termination, - duration_ms: streaming.result.duration_ms, - }, - streams_separated: streaming.streams_separated, - stdout_capture: capture_stats( - streaming.stdout_capture.observed_bytes, - output_bytes_cap, - ), - stderr_capture: capture_stats( - streaming.stderr_capture.observed_bytes, - output_bytes_cap, - ), - }) } } -impl RunSandbox { - /// A caller path as the driver will see it: fabro's working directory - /// applied where fabro applies it, and nothing more. - fn resolve_for_environment(&self, path: &str) -> String { - sandbox::resolve_path(path, Self::working_directory(self)) - } -} - -/// Pebble's glob grammar, beyond what fabro's glob already rejects. +/// The driver spec every provider starts from: the environment's source +/// (an image, a Dockerfile, or a managed directory when it names neither), +/// its labels, variables, resources, network policy, and auto-stop. `env` +/// is the environment's variables, resolved by the caller: the worker +/// resolves secrets through the vault, while preflight carries them in +/// source form. /// -/// A path with its redundant separators and `.` segments removed, for -/// deciding whether two spellings name the same file. -fn normalize(path: &str) -> String { - let absolute = path.starts_with('/'); - let joined = path - .split('/') - .filter(|segment| !segment.is_empty() && *segment != ".") - .collect::>() - .join("/"); - if absolute { - format!("/{joined}") - } else { - joined - } -} - -/// The directory a path is in, when the path names one. -fn parent_directory(path: &str) -> Option<&str> { - let trimmed = path.trim_end_matches('/'); - let (parent, _) = trimmed.rsplit_once('/')?; - if parent.is_empty() { - return Some("/"); - } - Some(parent) -} - -/// Feeds the driver's asynchronous chunk callback into pebble's synchronous -/// sink. -fn adapt_output_sink(sink: ExecOutputSink) -> CommandOutputCallback { - Arc::new(move |stream, chunk: Vec| { - let stream = match stream { - CommandOutputStream::Stdout => ExecOutputStream::Stdout, - CommandOutputStream::Stderr => ExecOutputStream::Stderr, - }; - sink(stream, &chunk); - Box::pin(async { Ok(()) }) - }) -} - -/// A sandbox failure as pebble classifies it, keeping the driver cause. -fn environment_error(message: &str, error: crate::Error) -> EnvironmentError { - let kind = if error.is_not_found() { - EnvironmentErrorKind::NotFound - } else if error.is_unsupported() { - EnvironmentErrorKind::Unsupported - } else { - EnvironmentErrorKind::Io +/// A Dockerfile given as a path must have been resolved to inline content +/// earlier; none of the providers can read a path. +pub fn sandbox_spec_for_environment( + settings: &RunEnvironmentSettings, + env: BTreeMap, +) -> crate::Result { + // fabro-config rejects environments that set both image.docker and + // image.dockerfile. If both still arrive here, the image wins. + let source = match (&settings.image.docker, &settings.image.dockerfile) { + (Some(reference), _) => SandboxSource::Image { + reference: reference.clone(), + }, + (None, Some(DockerfileSource::Inline(content))) => SandboxSource::Dockerfile { + content: content.clone(), + }, + (None, Some(DockerfileSource::Path { path })) => { + return Err(crate::Error::message(format!( + "environment `{}` names a Dockerfile path ({path}) that should have been \ + resolved to inline content before sandbox creation", + settings.id + ))); + } + // A provider without images (a host-style plugin) manages a + // workspace directory of its own. + (None, None) => SandboxSource::HostDirectory, }; - EnvironmentError::with_source(kind, message, error) + let network = match settings.network.mode { + EnvironmentNetworkMode::Block => NetworkPolicy::Block, + EnvironmentNetworkMode::AllowAll => NetworkPolicy::AllowAll, + EnvironmentNetworkMode::CidrAllowList => NetworkPolicy::CidrAllowList { + cidrs: settings.network.allow.clone(), + }, + }; + let mut spec = SandboxSpec::new(source).network(network); + // The environment's labels; fabro's ownership labels are stamped by the + // ownership scope the provider is connected through. + for (key, value) in &settings.labels { + spec = spec.label(key, value); + } + for (key, value) in env { + spec = spec.env_var(key, value); + } + let mut resources = Resources::default(); + resources.cpu_cores = settings + .resources + .cpu + .and_then(|cpu| u32::try_from(cpu).ok()); + resources.memory_mb = settings + .resources + .memory + .map(|size| mebibytes(size.as_bytes())); + resources.disk_mb = settings + .resources + .disk + .map(|size| mebibytes(size.as_bytes())); + let mut timers = LifecycleTimers::default(); + timers.auto_stop_after_idle = settings + .lifecycle + .auto_stop + .map(|duration| duration.as_std()); + Ok(spec.resources(resources).timers(timers)) +} + +/// Whole mebibytes, rounded up: the unit the driver sizes resources in. +fn mebibytes(bytes: u64) -> u64 { + bytes.div_ceil(1024 * 1024) +} + +/// The provider-side name of a run's sandbox. +pub(crate) fn run_name(run_id: &RunId) -> String { + format!("fabro-run-{run_id}") +} + +/// The environment's default `allow_all` means "unrestricted", which a +/// provider without network controls already is; asking such a provider +/// for it explicitly would be rejected. An explicit restriction is still +/// requested, and refused by the provider when it cannot honor it. +pub(crate) fn supported_network( + requested: NetworkPolicy, + capabilities: &Capabilities, +) -> NetworkPolicy { + match requested { + NetworkPolicy::AllowAll if !capabilities.network.allow_all => { + NetworkPolicy::ProviderDefault + } + other => other, + } +} + +/// The environment's auto-stop is a request a backend without timers +/// cannot take; such a provider gets no timers rather than a rejected spec. +pub(crate) fn supported_timers( + requested: LifecycleTimers, + capabilities: &Capabilities, +) -> LifecycleTimers { + if capabilities.lifecycle.timers { + requested + } else { + LifecycleTimers::default() + } } #[cfg(test)] mod tests { - use pebble_coding_agent::test_support::EnvironmentContract; + use std::collections::HashMap; + use std::time::Duration; + + use fabro_types::SandboxProviderKind; + use fabro_types::settings::run::{ + EnvironmentImageSettings, EnvironmentLifecycleSettings, EnvironmentNetworkSettings, + EnvironmentResourcesSettings, + }; + use fabro_types::settings::{Duration as SettingsDuration, Size}; use super::*; - use crate::local_sandbox; - /// The run sandbox over the driver's Host provider, in a directory that - /// goes away with the test. - async fn host_environment() -> (tempfile::TempDir, RunSandbox) { - let directory = tempfile::tempdir().expect("a temporary directory"); - let sandbox = local_sandbox(directory.path().to_path_buf()) - .await - .expect("a local sandbox"); - (directory, sandbox) - } - - #[tokio::test] - async fn host_files_satisfy_pebbles_environment_contract() { - let (_directory, sandbox) = host_environment().await; - EnvironmentContract::new(&sandbox, "contract") - .verify_files() - .await - .expect("file contract"); - } - - #[tokio::test] - async fn host_search_satisfies_pebbles_environment_contract() { - let (_directory, sandbox) = host_environment().await; - EnvironmentContract::new(&sandbox, "contract") - .verify_search() - .await - .expect("search contract"); - } - - #[tokio::test] - async fn host_commands_satisfy_pebbles_environment_contract() { - let (_directory, sandbox) = host_environment().await; - EnvironmentContract::new(&sandbox, "contract") - .verify_commands() - .await - .expect("command contract"); - } - - #[tokio::test] - async fn a_directory_listing_is_in_tree_order() { - let (directory, sandbox) = host_environment().await; - for name in ["foo/x.txt", "foo-bar/y.txt", "foo.txt"] { - Environment::write_file(&sandbox, name, "content") - .await - .expect("fixture"); + fn environment(kind: &str) -> RunEnvironmentSettings { + RunEnvironmentSettings { + id: kind.to_string(), + provider: SandboxProviderKind::try_new(kind).unwrap(), + cwd: None, + image: EnvironmentImageSettings::default(), + resources: EnvironmentResourcesSettings::default(), + network: EnvironmentNetworkSettings::default(), + lifecycle: EnvironmentLifecycleSettings::default(), + labels: HashMap::from([("team".to_string(), "platform".to_string())]), + env: HashMap::new(), } - let names: Vec = Environment::list_directory(&sandbox, ".", Some(2)) - .await - .expect("listing") - .into_iter() - .map(|entry| entry.name) - .collect(); - assert_eq!(names, [ - "foo", - "foo/x.txt", - "foo-bar", - "foo-bar/y.txt", - "foo.txt" - ]); - drop(directory); } #[test] - fn a_path_spelled_two_ways_is_one_path() { - assert_eq!(normalize("/work//a/./b.txt"), "/work/a/b.txt"); - assert_eq!(parent_directory("/work/a/b.txt"), Some("/work/a")); - assert_eq!(parent_directory("/b.txt"), Some("/")); - assert_eq!(parent_directory("b.txt"), None); + fn an_environment_without_an_image_asks_for_a_managed_directory() { + let spec = sandbox_spec_for_environment( + &environment("host"), + BTreeMap::from([("FOO".to_string(), "bar".to_string())]), + ) + .unwrap(); + assert!(matches!(spec.source, SandboxSource::HostDirectory)); + assert!(spec.working_directory.is_none()); + assert!( + spec.name.is_none(), + "the run names the sandbox, not the environment" + ); + assert_eq!(spec.env.get("FOO").map(String::as_str), Some("bar")); + assert_eq!( + spec.labels.get("team").map(String::as_str), + Some("platform") + ); + assert!( + !spec.labels.contains_key("sh.fabro.managed"), + "ownership labels come from the scope, not the environment" + ); + assert!(matches!(spec.network, NetworkPolicy::AllowAll)); + assert_eq!(spec.resources, Resources::default()); + assert_eq!(spec.timers, LifecycleTimers::default()); + } + + #[test] + fn an_environment_with_an_image_maps_resources_network_and_lifecycle() { + let mut settings = environment("e2b"); + settings.image.docker = Some("ubuntu:24.04".to_string()); + settings.resources.cpu = Some(2); + settings.resources.memory = Some(Size::from_bytes(4_000_000_000)); + settings.network.mode = EnvironmentNetworkMode::Block; + settings.lifecycle.auto_stop = Some(SettingsDuration::from_std(Duration::from_mins(45))); + + let spec = sandbox_spec_for_environment(&settings, BTreeMap::new()).unwrap(); + assert!(matches!( + &spec.source, + SandboxSource::Image { reference } if reference == "ubuntu:24.04" + )); + assert_eq!(spec.resources.cpu_cores, Some(2)); + assert_eq!(spec.resources.memory_mb, Some(3815)); + assert!(matches!(spec.network, NetworkPolicy::Block)); + assert_eq!( + spec.timers.auto_stop_after_idle, + Some(Duration::from_mins(45)) + ); + } + + #[test] + fn the_clone_request_carries_the_environments_policy() { + let clone = CloneRequest::from_settings(&RunCloneSettings::default()); + assert_eq!(clone.depth, Some(100)); + assert!(!clone.skip); + + let clone = CloneRequest::from_settings(&RunCloneSettings { + enabled: false, + depth: 0, + }); + assert_eq!(clone.depth, None); + assert!(clone.skip); + assert!(CloneRequest::none().skip); + } + + #[test] + fn an_inline_dockerfile_becomes_the_source_and_a_path_is_rejected() { + let mut settings = environment("daytona"); + settings.image.dockerfile = Some(DockerfileSource::Inline("FROM ubuntu".to_string())); + let spec = sandbox_spec_for_environment(&settings, BTreeMap::new()).unwrap(); + assert!(matches!( + spec.source, + SandboxSource::Dockerfile { content } if content == "FROM ubuntu" + )); + + settings.image.dockerfile = Some(DockerfileSource::Path { + path: "Dockerfile".to_string(), + }); + let error = sandbox_spec_for_environment(&settings, BTreeMap::new()).unwrap_err(); + assert!(error.to_string().contains("Dockerfile path"), "{error}"); + } + + #[test] + fn allow_all_falls_back_to_the_provider_default_without_network_control() { + let none = Capabilities::minimal(sandbox_driver::Isolation::None); + assert!(matches!( + supported_network(NetworkPolicy::AllowAll, &none), + NetworkPolicy::ProviderDefault + )); + assert!(matches!( + supported_network(NetworkPolicy::Block, &none), + NetworkPolicy::Block + )); + let mut full = Capabilities::minimal(sandbox_driver::Isolation::Container); + full.network.allow_all = true; + assert!(matches!( + supported_network(NetworkPolicy::AllowAll, &full), + NetworkPolicy::AllowAll + )); + } + + #[test] + fn timers_are_dropped_for_a_provider_without_them() { + let mut requested = LifecycleTimers::default(); + requested.auto_stop_after_idle = Some(Duration::from_mins(45)); + let none = Capabilities::minimal(sandbox_driver::Isolation::None); + assert_eq!( + supported_timers(requested, &none), + LifecycleTimers::default() + ); + let mut with_timers = Capabilities::minimal(sandbox_driver::Isolation::Container); + with_timers.lifecycle.timers = true; + assert_eq!(supported_timers(requested, &with_timers), requested); } } diff --git a/lib/components/fabro-sandbox/src/error.rs b/lib/components/fabro-sandbox/src/error.rs index 57c132f23..eabd62f32 100644 --- a/lib/components/fabro-sandbox/src/error.rs +++ b/lib/components/fabro-sandbox/src/error.rs @@ -2,7 +2,6 @@ use std::fmt::Write as _; use fabro_util::error::{collect_causes, render_with_causes}; -use crate::ExecResult; use crate::sandbox::{DEFAULT_EXEC_OUTPUT_TAIL_BYTES, redacted_output_tail}; #[derive(Debug, thiserror::Error)] @@ -26,21 +25,10 @@ pub enum Error { /// A sandbox-driver failure: provider, transport, or an operation whose /// outcome is unknown. The driver's own variants stay reachable through - /// [`Error::driver`] so callers can act on `NotFound`, `Unsupported`, - /// `Transport`, and `Incomplete` without string matching. + /// [`Error::driver`] so callers can act on `Exec`, `Git`, and `NotFound` + /// without string matching. #[error(transparent)] Driver(Box), - - #[error( - "{label} failed (exit {exit}, termination={termination}, duration_ms={duration_ms}) - hint: {hint}", - exit = format_exit_code(result.exit_code), - termination = result.termination, - duration_ms = result.duration_ms, - hint = classify_exec_failure(&result.stderr) - .or_else(|| classify_exec_failure(&result.stdout)) - .unwrap_or("unclassified") - )] - Exec { label: String, result: ExecResult }, } impl Error { @@ -65,13 +53,6 @@ impl Error { } } - pub fn exec(label: impl Into, result: ExecResult) -> Self { - Self::Exec { - label: label.into(), - result, - } - } - pub fn default_redacted_output_tail(&self) -> Option { default_redacted_output_tail(self) } @@ -80,10 +61,6 @@ impl Error { collect_causes(self) } - pub fn driver_error(source: sandbox_driver::Error) -> Self { - Self::Driver(Box::new(source)) - } - /// The underlying sandbox-driver error, when this error carries one /// anywhere in its chain. pub fn driver(&self) -> Option<&sandbox_driver::Error> { @@ -100,36 +77,6 @@ impl Error { None } - /// The facts established when a driver operation ended without a - /// complete outcome. A caller that sees `Some` must not replay the - /// operation: its effects may already have happened. - pub fn incomplete_operation(&self) -> Option<&sandbox_driver::IncompleteOperation> { - match self.driver()? { - sandbox_driver::Error::Incomplete(incomplete) => Some(incomplete), - _ => None, - } - } - - /// True when communication with an out-of-process provider failed. The - /// operation may or may not have run; fabro rebuilds handles through - /// `attach` rather than retrying blind. - pub fn is_transport(&self) -> bool { - matches!(self.driver(), Some(sandbox_driver::Error::Transport(_))) - } - - /// True when the driver reported the resource missing. - pub fn is_not_found(&self) -> bool { - matches!(self.driver(), Some(sandbox_driver::Error::NotFound { .. })) - } - - /// True when the provider does not support the requested capability. - pub fn is_unsupported(&self) -> bool { - matches!( - self.driver(), - Some(sandbox_driver::Error::Unsupported { .. }) - ) - } - pub fn display_with_causes(&self) -> String { render_with_causes(&self.to_string(), &self.causes()) } @@ -137,60 +84,10 @@ impl Error { impl From for Error { fn from(value: sandbox_driver::Error) -> Self { - Self::driver_error(value) + Self::Driver(Box::new(value)) } } -impl From for Error { - fn from(value: String) -> Self { - Self::Message(value) - } -} - -impl From<&str> for Error { - fn from(value: &str) -> Self { - Self::Message(value.to_string()) - } -} - -pub(crate) fn classify_exec_failure(stderr: &str) -> Option<&'static str> { - let lower = stderr.to_ascii_lowercase(); - if lower.contains("could not read username") || lower.contains("terminal prompts disabled") { - Some( - "no credentials in origin URL - check that the sandbox forwarded \ - GITHUB_APP_PRIVATE_KEY (or GITHUB_TOKEN) and that refresh_push_credentials succeeded", - ) - } else if lower.contains("permission to") && lower.contains("denied") { - Some( - "github denied the push - installation token lacks contents:write \ - on this repo, or a branch protection / push ruleset is rejecting the ref", - ) - } else if lower.contains("protected branch") - || lower.contains("ruleset") - || lower.contains("rejected") - { - Some("github rejected the ref - likely a branch protection rule or push ruleset") - } else if lower.contains("authentication failed") || lower.contains("invalid username") { - Some("github authentication failed - installation token may be expired or wrong scope") - } else if lower.contains("could not resolve host") || lower.contains("network is unreachable") { - Some("network failure inside sandbox - check DNS / egress from the run container") - } else if lower.contains("repository not found") { - Some("github 404 - repository is unavailable to the current credentials") - } else if lower.contains("no such remote") && lower.contains("origin") { - Some("origin remote missing - push credentials could not be installed") - } else if lower.contains("not a git repository") - || lower.contains("does not appear to be a git repository") - { - Some("git repository unavailable in sandbox working directory") - } else { - None - } -} - -fn format_exit_code(exit_code: Option) -> String { - exit_code.map_or_else(|| "none".to_string(), |code| code.to_string()) -} - pub type Result = std::result::Result; pub fn default_redacted_output_tail( @@ -198,14 +95,10 @@ pub fn default_redacted_output_tail( ) -> Option { let mut current = Some(err); while let Some(err) = current { - match err.downcast_ref::() { - Some(Error::Exec { result, .. }) => return result.default_redacted_output_tail(), - Some(Error::Driver(driver)) => { - if let Some(tail) = driver_output_tail(driver) { - return Some(tail); - } + if let Some(Error::Driver(driver)) = err.downcast_ref::() { + if let Some(tail) = driver_output_tail(driver) { + return Some(tail); } - _ => {} } if let Some(driver) = err.downcast_ref::() { if let Some(tail) = driver_output_tail(driver) { @@ -262,64 +155,69 @@ fn append_tail_for_log(rendered: &mut String, stream: &str, tail: Option<&str>, #[cfg(test)] mod tests { - use fabro_types::CommandTermination; + use std::time::Duration; + + use sandbox_driver::{ExecResult, Termination}; use super::*; + use crate::exec::ExecResultExt; + + const SECRET: &str = "ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"; + + fn failed_push(stdout: &str, stderr: &str) -> Error { + let mut result = + ExecResult::new(Termination::Exited, Some(128), Duration::from_millis(210)); + result.stdout = stdout.as_bytes().to_vec(); + result.stderr = stderr.as_bytes().to_vec(); + result.into_exec_error("git push origin refs/heads/run") + } + + fn leaky_stderr() -> String { + format!( + "fatal: unable to access 'https://x-access-token:{SECRET}@github.com/owner/repo/':\n\ + remote: Permission to owner/repo.git denied\n\ + identity ~/.ssh/id_rsa_work" + ) + } #[test] fn exec_display_is_log_safe() { - let stderr = "fatal: unable to access \ - 'https://x-access-token:ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA@github.com/owner/repo/':\n\ - remote: Permission to owner/repo.git denied\n\ - identity ~/.ssh/id_rsa_work"; - let error = Error::exec("git push origin refs/heads/run", crate::ExecResult { - stdout: String::new(), - stderr: stderr.to_string(), - exit_code: Some(128), - termination: CommandTermination::Exited, - duration_ms: 210, - }); + let error = failed_push("", &leaky_stderr()); let rendered = error.to_string(); assert_exec_rendering_is_safe(&rendered); assert!(rendered.contains("git push origin refs/heads/run")); - assert!(rendered.contains("exit 128")); - assert!(rendered.contains("termination=exited")); - assert!(rendered.contains("duration_ms=210")); - assert!(rendered.contains("hint:")); + assert!(rendered.contains("128")); + assert!(rendered.contains("210 ms")); } #[test] fn display_with_causes_does_not_reintroduce_raw_exec_output() { - let stderr = "fatal: unable to access \ - 'https://x-access-token:ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA@github.com/owner/repo/':\n\ - remote: Permission to owner/repo.git denied\n\ - identity ~/.ssh/id_rsa_work"; - let exec_error = Error::exec("git push origin refs/heads/run", crate::ExecResult { - stdout: "stdout secret ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA".to_string(), - stderr: stderr.to_string(), - exit_code: Some(128), - termination: CommandTermination::Exited, - duration_ms: 210, - }); + let exec_error = failed_push(&format!("stdout secret {SECRET}"), &leaky_stderr()); let error = Error::context("metadata push failed", exec_error); let rendered = error.display_with_causes(); assert_exec_rendering_is_safe(&rendered); assert!(rendered.contains("metadata push failed")); assert!(rendered.contains("git push origin refs/heads/run")); - assert!(rendered.contains("hint:")); + } + + #[test] + fn the_driver_error_is_reachable_through_the_context_chain() { + let error = Error::context("metadata push failed", failed_push("", "boom")); + + let Some(sandbox_driver::Error::Exec(failure)) = error.driver() else { + panic!("expected an exec failure, got {error:?}"); + }; + assert_eq!(failure.label(), "git push origin refs/heads/run"); + assert_eq!(failure.exit_code(), Some(128)); + assert_eq!(failure.termination(), Termination::Exited); + assert!(Error::message("plain").driver().is_none()); } #[test] fn display_for_log_walks_context_chain_and_emits_tail() { - let exec_error = Error::exec("git push origin refs/heads/run", crate::ExecResult { - stdout: "last stdout line".to_string(), - stderr: "last stderr line".to_string(), - exit_code: Some(128), - termination: CommandTermination::Exited, - duration_ms: 210, - }); + let exec_error = failed_push("last stdout line", "last stderr line"); let error = Error::context("metadata push failed", exec_error); let rendered = display_for_log(&error); @@ -334,18 +232,15 @@ mod tests { #[test] fn display_for_log_redacts_secrets() { - let error = Error::exec("git push origin refs/heads/run", crate::ExecResult { - stdout: "stdout secret ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA".to_string(), - stderr: "stderr secret ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA".to_string(), - exit_code: Some(128), - termination: CommandTermination::Exited, - duration_ms: 210, - }); + let error = failed_push( + &format!("stdout secret {SECRET}"), + &format!("stderr secret {SECRET}"), + ); let rendered = display_for_log(&error); assert!( - !rendered.contains("ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"), + !rendered.contains(SECRET), "log rendering leaked raw secret: {rendered}" ); assert!(rendered.contains("REDACTED")); @@ -367,7 +262,7 @@ mod tests { "fatal:", "remote:", "x-access-token", - "ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA", + SECRET, "~/.ssh", "id_rsa_work", ] { @@ -380,14 +275,7 @@ mod tests { #[test] fn exec_error_exposes_default_redacted_output_tail() { - let stderr = "stderr secret ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"; - let error = Error::exec("git push origin refs/heads/run", crate::ExecResult { - stdout: "last stdout line".to_string(), - stderr: stderr.to_string(), - exit_code: Some(128), - termination: CommandTermination::Exited, - duration_ms: 210, - }); + let error = failed_push("last stdout line", &format!("stderr secret {SECRET}")); let tail = error.default_redacted_output_tail().expect("tail present"); assert_eq!(tail.stdout.as_deref(), Some("last stdout line")); @@ -401,13 +289,7 @@ mod tests { #[test] fn free_tail_helper_walks_context_chain() { - let exec_error = Error::exec("git push origin refs/heads/run", crate::ExecResult { - stdout: "last stdout line".to_string(), - stderr: "last stderr line".to_string(), - exit_code: Some(128), - termination: CommandTermination::Exited, - duration_ms: 210, - }); + let exec_error = failed_push("last stdout line", "last stderr line"); let error = Error::context("metadata push failed", exec_error); let tail = default_redacted_output_tail(&error).expect("tail present"); @@ -415,42 +297,4 @@ mod tests { assert_eq!(tail.stdout.as_deref(), Some("last stdout line")); assert_eq!(tail.stderr.as_deref(), Some("last stderr line")); } - - #[test] - fn classify_exec_failure_documents_known_branches() { - let cases = [ - ( - "fatal: could not read Username for 'https://github.com'", - "no credentials in origin URL", - ), - ( - "remote: Permission to owner/repo.git denied to fabro-app[bot].", - "github denied the push", - ), - ( - "remote: error: GH013: Repository rule violations found due to ruleset", - "github rejected the ref", - ), - ( - "fatal: Authentication failed for 'https://github.com/owner/repo'", - "github authentication failed", - ), - ( - "fatal: could not resolve host: github.com", - "network failure", - ), - ("remote: Repository not found.", "github 404"), - ("error: No such remote 'origin'", "origin remote missing"), - ("fatal: not a git repository", "git repository unavailable"), - ]; - - for (stderr, expected) in cases { - let hint = classify_exec_failure(stderr).expect(stderr); - assert!( - hint.contains(expected), - "expected {hint:?} to contain {expected:?}" - ); - } - assert_eq!(classify_exec_failure("weird new git error"), None); - } } diff --git a/lib/components/fabro-sandbox/src/exec.rs b/lib/components/fabro-sandbox/src/exec.rs index 73f068d47..bf5fd42d3 100644 --- a/lib/components/fabro-sandbox/src/exec.rs +++ b/lib/components/fabro-sandbox/src/exec.rs @@ -1,41 +1,44 @@ //! Fabro's command execution policy over the sandbox-driver [`Exec`] facet. //! -//! A command runs as Bash source under `bash -c` with `BASH_ENV` blanked, -//! and ends in one of three ways: +//! The vocabulary is the driver's own: an [`ExecSpec`] and [`ExecControls`] +//! go in, an [`ExecResult`] or [`ExecStreamingResult`] comes out. This +//! module adds fabro's policy on the way in and fabro's reading of a result +//! on the way out. +//! +//! A command runs as Bash source under `bash -c` with `BASH_ENV` blanked by +//! the driver whatever the caller passed, and ends in one of three ways: //! //! - **timeout**: the spec's timeout fires and the provider runs the stop //! ladder fabro asks for — `TERM`, then `KILL` after -//! [`SandboxExec::stop_grace`]. The result reports -//! [`CommandTermination::TimedOut`]. +//! [`SandboxExec::stop_grace`]. The result reports [`Termination::TimedOut`]. //! - **cancellation**: the caller's [`CancellationToken`] is the `term` stop; //! the provider escalates to `KILL` after the same grace. The result reports -//! [`CommandTermination::Cancelled`]. +//! [`Termination::Cancelled`]. //! - **exit**: the process ended on its own. //! -//! Output is drained regardless of the retention cap, redacted only when a -//! tail is rendered for events or logs, and delivered live through the -//! caller's callback. Explicit environment variables pass through a -//! fail-closed secret filter under [`ExplicitEnvPolicy::FilterSensitive`], -//! matching what the Host provider already does for inherited variables. +//! Output is drained regardless of the retention cap and delivered live +//! through the caller's [`sandbox_driver::OutputSink`]. Fabro reads command +//! output as text, so the policy asks the driver for +//! [`OutputSanitization::StripAll`]: terminal escape sequences and stray +//! control characters never reach a result, a sink chunk, or a tail. Secret +//! redaction stays fabro's job and happens only when a tail is rendered for +//! events or logs ([`ExecResultExt`]). The explicit environment reaches the +//! provider as the caller composed it: the driver filters credential-shaped +//! names out of the *inherited* host environment itself and treats the +//! spec's own variables as the deliberate channel for secrets, so fabro adds +//! no filter of its own. use std::collections::HashMap; -use std::sync::Arc; use std::time::Duration; -use async_trait::async_trait; -use fabro_static::EnvVars; -use fabro_types::{CommandOutputStream, CommandTermination}; +use fabro_types::{CommandTermination, ExecOutputTail}; use sandbox_driver::{ - BASH_ENV_VAR, CaptureStats, Exec, ExecControls, ExecSpec, OutputStream, SpawnSpec, - StdioProcessHandle as DriverStdioProcessHandle, Termination, TransportError, + Exec, ExecControls, ExecFailure, ExecResult, ExecSpec, ExecStreamingResult, OutputSanitization, + SpawnSpec, StdioProcess, Termination, }; use tokio_util::sync::CancellationToken; -use crate::sandbox::{ - CommandOutputCallback, ExecResult, ExecStreamingRequest, ExecStreamingResult, - OutputCaptureStats, StderrCollector, StdioProcess, StdioProcessControl, StdioProcessHandle, - StdioProcessTermination, -}; +use crate::sandbox::{DEFAULT_EXEC_OUTPUT_TAIL_BYTES, redacted_output_tail}; /// Time between `TERM` and `KILL` when fabro stops a command. pub const DEFAULT_STOP_GRACE: Duration = Duration::from_secs(2); @@ -44,50 +47,9 @@ pub const DEFAULT_STOP_GRACE: Duration = Duration::from_secs(2); /// renders, bounded so a runaway command cannot exhaust memory. pub const DEFAULT_RETAINED_OUTPUT_BYTES: usize = sandbox_driver::DEFAULT_BUFFER_BYTES; -/// How explicit per-command environment variables are treated. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ExplicitEnvPolicy { - /// Drop variables whose names look like credentials unless safelisted. - /// Used where the command runs on the worker host and the caller's env - /// may carry worker secrets. - FilterSensitive, - /// Pass every variable through. Used for isolated providers, where the - /// caller composed the environment deliberately. - TrustCaller, -} - -/// Variables that look like credentials but are needed by ordinary tools. -const ENV_SAFELIST: &[&str] = &[ - EnvVars::PATH, - EnvVars::HOME, - EnvVars::USER, - EnvVars::SHELL, - EnvVars::LANG, - EnvVars::TERM, - EnvVars::TMPDIR, - EnvVars::GOPATH, - EnvVars::CARGO_HOME, - EnvVars::NVM_DIR, -]; - -/// Whether an environment variable name looks like a credential. -#[must_use] -pub fn is_sensitive_env_var(key: &str) -> bool { - if ENV_SAFELIST.contains(&key) { - return false; - } - let lower = key.to_lowercase(); - lower.ends_with("_api_key") - || lower.ends_with("_secret") - || lower.ends_with("_token") - || lower.ends_with("_password") - || lower.ends_with("_credential") -} - /// Fabro's exec policy bound to one driver [`Exec`] facet. pub struct SandboxExec<'a> { exec: &'a dyn Exec, - env_policy: ExplicitEnvPolicy, stop_grace: Duration, /// Where a command runs when the caller names no directory. `None` /// leaves the choice to the provider's own working directory. @@ -96,10 +58,9 @@ pub struct SandboxExec<'a> { impl<'a> SandboxExec<'a> { #[must_use] - pub fn new(exec: &'a dyn Exec, env_policy: ExplicitEnvPolicy) -> Self { + pub fn new(exec: &'a dyn Exec) -> Self { Self { exec, - env_policy, stop_grace: DEFAULT_STOP_GRACE, working_dir: None, } @@ -131,7 +92,8 @@ impl<'a> SandboxExec<'a> { /// /// Equivalent to `bash -c ` with a clean, non-login shell: no /// `errexit`, no `pipefail`, `BASH_ENV` blanked. A caller that wants - /// different semantics writes them into the command. + /// different semantics writes them into the command. `None` for + /// `timeout` runs without a deadline. pub async fn run( &self, command: &str, @@ -140,217 +102,180 @@ impl<'a> SandboxExec<'a> { env_vars: Option<&HashMap>, cancel_token: Option, ) -> crate::Result { - let streaming = self - .run_streaming(ExecStreamingRequest { - timeout_ms: timeout - .map(|timeout| u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX)), - working_dir, - env_vars, - cancel_token, - ..ExecStreamingRequest::new(command) - }) - .await?; - Ok(streaming.result) - } - - /// Runs Bash source, delivering output through `request.output_callback` - /// as it arrives. Same interpreter contract as [`Self::run`]. - pub async fn run_streaming( - &self, - request: ExecStreamingRequest<'_>, - ) -> crate::Result { - let ExecStreamingRequest { - command, - timeout_ms, - working_dir, - env_vars, - cancel_token, - stdin, - output_callback, - stream_output_bytes_cap, - } = request; - - let mut spec = ExecSpec::bash(command) - .no_timeout() - .stop_grace(self.stop_grace); - if let Some(timeout_ms) = timeout_ms { - spec = spec.timeout(Duration::from_millis(timeout_ms)); + let mut spec = ExecSpec::bash(command).no_timeout(); + if let Some(timeout) = timeout { + spec = spec.timeout(timeout); } - if let Some(dir) = working_dir.or(self.working_dir.as_deref()) { + if let Some(dir) = working_dir { spec = spec.working_dir(dir); } - for (key, value) in self.explicit_env(env_vars) { + for (key, value) in env_vars.into_iter().flatten() { spec = spec.env_var(key, value); } - if let Some(bytes) = stdin { - spec = spec.stdin(bytes); - } - - // The caller's cancellation is the `term` stop; the provider runs - // the grace and the `kill` itself. let controls = ExecControls { - term: cancel_token, - kill: None, - stdin: None, - sink: output_callback.map(adapt_output_callback), - retained_output_limit: Some( - stream_output_bytes_cap.unwrap_or(DEFAULT_RETAINED_OUTPUT_BYTES), - ), + term: cancel_token, + ..ExecControls::default() }; + Ok(self.run_streaming(spec, controls).await?.result) + } - let streaming = self.exec.run_streaming(&spec, controls).await?; - - let termination = map_termination(streaming.result.termination); - let duration_ms = duration_ms(streaming.result.duration); - Ok(ExecStreamingResult { - result: ExecResult { - stdout: String::from_utf8_lossy(&streaming.result.stdout).into_owned(), - stderr: String::from_utf8_lossy(&streaming.result.stderr).into_owned(), - exit_code: exit_code_for(termination, streaming.result.exit_code), - termination, - duration_ms, - }, - streams_separated: streaming.streams_separated, - live_streaming: streaming.live_streaming, - stdout_capture: capture_stats(streaming.stdout_capture), - stderr_capture: capture_stats(streaming.stderr_capture), - }) + /// Runs `spec` under fabro's policy, delivering output through + /// `controls.sink` as it arrives. + /// + /// The policy fills what the spec leaves open: the stop grace, the + /// working directory, and the text output policy. The spec's environment + /// goes to the provider as the caller composed it. The caller's + /// `controls.term` is the `term` stop; the provider runs the grace and + /// the `kill` itself. Output beyond `controls.retained_output_limit` + /// (fabro's default when unset) is drained and counted, not kept. + pub async fn run_streaming( + &self, + spec: ExecSpec, + mut controls: ExecControls, + ) -> crate::Result { + let spec = self.apply_policy(spec); + if controls.retained_output_limit.is_none() { + controls.retained_output_limit = Some(DEFAULT_RETAINED_OUTPUT_BYTES); + } + Ok(self.exec.run_streaming(&spec, controls).await?) } /// Launches a long-lived process with bidirectional stdio. /// /// `command` is evaluated under the same non-login Bash contract before - /// the shell replaces itself with the requested process. Cancelling - /// `cancel_token` terminates the process. + /// the shell replaces itself with the requested process. The returned + /// handle terminates the process; dropping it does not. pub async fn spawn_stdio( &self, command: &str, working_dir: Option<&str>, env_vars: Option<&HashMap>, - cancel_token: Option, ) -> crate::Result { let mut spec = SpawnSpec::bash(format!("exec {command}")); if let Some(dir) = working_dir.or(self.working_dir.as_deref()) { spec = spec.working_dir(dir); } - for (key, value) in self.explicit_env(env_vars) { + for (key, value) in env_vars.into_iter().flatten() { spec = spec.env_var(key, value); } - let process = self.exec.spawn_stdio(&spec).await?; - let handle = StdioProcessHandle::new(DriverStdioControl { - handle: Arc::from(process.handle), - }); - if let Some(token) = cancel_token { - let handle = handle.clone(); - tokio::spawn(async move { - token.cancelled().await; - if let Err(error) = handle.terminate().await { - tracing::warn!(error = %error, "failed to terminate stdio process on cancel"); - } - }); - } - Ok(StdioProcess { - stdin: process.stdin, - stdout: process.stdout, - stderr: StderrCollector::from_driver_tail(process.stderr_tail), - handle, - }) + Ok(self.exec.spawn_stdio(&spec).await?) } - /// The explicit environment after policy: `BASH_ENV` never passes, - /// because the Bash helper blanks it and a caller value would override - /// that; credential-shaped names pass only under `TrustCaller`. - fn explicit_env(&self, env_vars: Option<&HashMap>) -> Vec<(String, String)> { - let mut entries: Vec<(String, String)> = env_vars - .into_iter() - .flatten() - .filter(|(key, _)| key.as_str() != BASH_ENV_VAR) - .filter(|(key, _)| { - self.env_policy == ExplicitEnvPolicy::TrustCaller || !is_sensitive_env_var(key) - }) - .map(|(key, value)| (key.clone(), value.clone())) - .collect(); - entries.sort(); - entries + /// Fills what a spec leaves open. The output policy has no "unset" + /// state: the driver's default is raw, and fabro reads command output + /// as text, so a spec still at that default gets + /// [`OutputSanitization::StripAll`]; a caller that chose another policy + /// keeps it. Long-lived stdio processes ([`Self::spawn_stdio`]) and PTY + /// sessions stay raw, as the driver requires. + fn apply_policy(&self, mut spec: ExecSpec) -> ExecSpec { + if spec.stop_grace.is_none() { + spec.stop_grace = Some(self.stop_grace); + } + if spec.working_dir.is_none() { + spec.working_dir.clone_from(&self.working_dir); + } + if spec.output_sanitization == OutputSanitization::default() { + spec.output_sanitization = OutputSanitization::StripAll; + } + spec } } -/// The driver says how the command ended; fabro's vocabulary has two stops. -/// A timeout is the provider's deadline (the ladder ran for it); a +/// The driver says how the command ended; fabro's event vocabulary has two +/// stops. A timeout is the provider's deadline (the ladder ran for it); a /// cancelled or killed command was stopped by the caller's token, by a /// foreign `kill`, or by a provider-side abort — it did not finish and no -/// deadline passed. -fn map_termination(termination: Termination) -> CommandTermination { +/// deadline passed. `Exited`, or a provider that could not tell, is a +/// completed process; nothing asserts success here. +#[must_use] +pub fn command_termination(termination: Termination) -> CommandTermination { match termination { Termination::TimedOut => CommandTermination::TimedOut, Termination::Cancelled | Termination::Killed => CommandTermination::Cancelled, - // `Exited`, or a provider that could not tell how the command ended. - // Nothing asserts success here: `exit_code` is whatever was observed - // and `is_success` still requires `Some(0)`. _ => CommandTermination::Exited, } } /// An exit code is only the command's own when it exited on its own. A /// stopped command may still report the shell's `128 + signal` (143 for a -/// trapped `TERM`), which callers must not mistake for a program result. -fn exit_code_for(termination: CommandTermination, exit_code: Option) -> Option { - // `CommandTermination` is non-exhaustive: only a command that exited on - // its own owns its exit code. - matches!(termination, CommandTermination::Exited) - .then_some(exit_code) - .flatten() -} - -fn capture_stats(stats: CaptureStats) -> OutputCaptureStats { - OutputCaptureStats { - observed_bytes: stats.observed_bytes, - retained_bytes: stats.retained_bytes, - omitted_bytes: stats.omitted_bytes, +/// trapped `TERM`), which events must not present as a program result. +#[must_use] +pub fn program_exit_code(termination: Termination, exit_code: Option) -> Option { + // `CommandTermination` is pebble's and non-exhaustive: only a command + // that exited on its own owns its exit code. + match command_termination(termination) { + CommandTermination::Exited => exit_code, + _ => None, } } -fn adapt_output_callback(callback: CommandOutputCallback) -> sandbox_driver::OutputSink { - Arc::new(move |stream, chunk| { - let stream = match stream { - OutputStream::Stdout => CommandOutputStream::Stdout, - OutputStream::Stderr => CommandOutputStream::Stderr, - }; - let callback = Arc::clone(&callback); - Box::pin(async move { - callback(stream, chunk).await.map_err(|error| { - sandbox_driver::Error::Transport(TransportError::with_source( - "command output callback failed", - error, - )) - }) - }) - }) +/// Fabro's reading of a driver [`ExecResult`]: the event-facing numbers, +/// the redacted output tail, and the failure a non-zero exit is. +pub trait ExecResultExt { + /// The provider's measured run time in whole milliseconds. + fn duration_ms(&self) -> u64; + + /// The exit code when the command ended on its own; see + /// [`program_exit_code`]. + fn program_exit_code(&self) -> Option; + + /// Redacted tails of both streams, each bounded to + /// `max_bytes_per_stream`. `None` when both streams are empty. Terminal + /// control sequences were already stripped by the driver under + /// [`SandboxExec`]'s output policy. + fn redacted_output_tail(&self, max_bytes_per_stream: usize) -> Option; + + /// [`Self::redacted_output_tail`] at fabro's event budget. + fn default_redacted_output_tail(&self) -> Option; + + /// The failure this result is, reported under `label`. The raw output + /// stays behind the driver's [`ExecFailure`] accessors; `Display` + /// carries only the label and the classified metadata. + fn into_exec_error(self, label: impl Into) -> crate::Error; + + /// `Ok(self)` for a clean exit, the failure under `label` otherwise. + fn into_result(self, label: impl Into) -> crate::Result; } -/// The provider's measured run time in whole milliseconds. -fn duration_ms(duration: Duration) -> u64 { - u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) -} - -struct DriverStdioControl { - handle: Arc, -} - -#[async_trait] -impl StdioProcessControl for DriverStdioControl { - async fn terminate(&self) -> crate::Result<()> { - self.handle.terminate().await; - Ok(()) +impl ExecResultExt for ExecResult { + fn duration_ms(&self) -> u64 { + u64::try_from(self.duration.as_millis()).unwrap_or(u64::MAX) } - async fn wait(&self) -> crate::Result { - let (termination, exit_code) = self.handle.wait().await; - let termination = map_termination(termination); - Ok(StdioProcessTermination { - termination, - exit_code: exit_code_for(termination, exit_code), - }) + fn program_exit_code(&self) -> Option { + program_exit_code(self.termination, self.exit_code) + } + + fn redacted_output_tail(&self, max_bytes_per_stream: usize) -> Option { + redacted_output_tail( + &self.stdout_lossy(), + &self.stderr_lossy(), + max_bytes_per_stream, + ) + } + + fn default_redacted_output_tail(&self) -> Option { + self.redacted_output_tail(DEFAULT_EXEC_OUTPUT_TAIL_BYTES) + } + + fn into_exec_error(self, label: impl Into) -> crate::Error { + let failure = ExecFailure::new( + label, + self.termination, + self.exit_code, + self.stdout, + self.stderr, + ) + .with_duration(self.duration); + crate::Error::from(sandbox_driver::Error::from(failure)) + } + + fn into_result(self, label: impl Into) -> crate::Result { + if self.success() { + Ok(self) + } else { + Err(self.into_exec_error(label)) + } } } @@ -359,7 +284,10 @@ mod tests { use std::sync::{Arc, Mutex}; use std::time::Instant; - use sandbox_driver::{SandboxProvider as _, SandboxSource, SandboxSpec}; + use sandbox_driver::{ + BASH_ENV_VAR, OutputSink, OutputStream, SandboxProvider as _, SandboxSource, SandboxSpec, + TransportError, + }; use sandbox_driver_host::HostProvider; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::{fs, time}; @@ -391,30 +319,44 @@ mod tests { } } - fn exec(&self, policy: ExplicitEnvPolicy) -> SandboxExec<'_> { + fn exec(&self) -> SandboxExec<'_> { let _ = &self.provider; - SandboxExec::new(self.sandbox.exec(), policy) + SandboxExec::new(self.sandbox.exec()) } } async fn run(fixture: &HostFixture, command: &str) -> ExecResult { fixture - .exec(ExplicitEnvPolicy::FilterSensitive) + .exec() .run(command, Some(Duration::from_secs(10)), None, None, None) .await .unwrap() } + fn exec_result( + stdout: &str, + stderr: &str, + exit_code: Option, + termination: Termination, + duration_ms: u64, + ) -> ExecResult { + let mut result = + ExecResult::new(termination, exit_code, Duration::from_millis(duration_ms)); + result.stdout = stdout.as_bytes().to_vec(); + result.stderr = stderr.as_bytes().to_vec(); + result + } + #[tokio::test] async fn runs_bash_source_and_reports_exit_code_and_streams() { let fixture = HostFixture::new().await; let result = run(&fixture, "echo out; echo err >&2; exit 3").await; - assert_eq!(result.stdout, "out\n"); - assert_eq!(result.stderr, "err\n"); + assert_eq!(result.stdout_lossy(), "out\n"); + assert_eq!(result.stderr_lossy(), "err\n"); assert_eq!(result.exit_code, Some(3)); - assert_eq!(result.termination, CommandTermination::Exited); - assert!(!result.is_success()); - assert!(run(&fixture, "true").await.is_success()); + assert_eq!(result.termination, Termination::Exited); + assert!(!result.success()); + assert!(run(&fixture, "true").await.success()); } #[tokio::test] @@ -426,7 +368,7 @@ mod tests { set -o | grep -E '^(errexit|pipefail)' | awk '{print $2}' | sort -u", ) .await; - assert_eq!(result.stdout, "nonlogin\noff\n", "{result:?}"); + assert_eq!(result.stdout_lossy(), "nonlogin\noff\n", "{result:?}"); } #[tokio::test] @@ -438,7 +380,7 @@ mod tests { .unwrap(); let env = HashMap::from([(BASH_ENV_VAR.to_string(), startup.display().to_string())]); let result = fixture - .exec(ExplicitEnvPolicy::TrustCaller) + .exec() .run( "echo body", Some(Duration::from_secs(10)), @@ -448,47 +390,24 @@ mod tests { ) .await .unwrap(); - assert_eq!(result.stdout, "body\n"); + assert_eq!(result.stdout_lossy(), "body\n"); } #[tokio::test] - async fn filter_sensitive_drops_credential_shaped_explicit_variables() { + async fn explicit_variables_reach_the_command_as_composed() { let fixture = HostFixture::new().await; let env = HashMap::from([ - ("FABRO_WORKER_TOKEN".to_string(), "leaked".to_string()), + ("FABRO_WORKER_TOKEN".to_string(), "deliberate".to_string()), ("MY_VAR".to_string(), "ok".to_string()), ]); - let filtered = fixture - .exec(ExplicitEnvPolicy::FilterSensitive) + let stdout = fixture + .exec() .run("env", Some(Duration::from_secs(10)), None, Some(&env), None) .await - .unwrap(); - assert!(!filtered.stdout.contains("FABRO_WORKER_TOKEN=leaked")); - assert!(filtered.stdout.contains("MY_VAR=ok")); - - let trusted = fixture - .exec(ExplicitEnvPolicy::TrustCaller) - .run("env", Some(Duration::from_secs(10)), None, Some(&env), None) - .await - .unwrap(); - assert!(trusted.stdout.contains("FABRO_WORKER_TOKEN=leaked")); - } - - #[test] - fn sensitive_name_classification_matches_the_worker_policy() { - for key in [ - "OPENAI_API_KEY", - "DB_PASSWORD", - "AWS_SECRET", - "AUTH_TOKEN", - "MY_CREDENTIAL", - "FABRO_WORKER_TOKEN", - ] { - assert!(is_sensitive_env_var(key), "{key}"); - } - for key in ["PATH", "HOME", "MY_VAR", "GITHUB_ACTOR"] { - assert!(!is_sensitive_env_var(key), "{key}"); - } + .unwrap() + .stdout_lossy(); + assert!(stdout.contains("FABRO_WORKER_TOKEN=deliberate"), "{stdout}"); + assert!(stdout.contains("MY_VAR=ok"), "{stdout}"); } #[tokio::test] @@ -496,7 +415,7 @@ mod tests { let fixture = HostFixture::new().await; let started = Instant::now(); let result = fixture - .exec(ExplicitEnvPolicy::FilterSensitive) + .exec() .run( "sleep 10", Some(Duration::from_millis(200)), @@ -506,8 +425,8 @@ mod tests { ) .await .unwrap(); - assert_eq!(result.termination, CommandTermination::TimedOut); - assert_eq!(result.exit_code, None); + assert_eq!(result.termination, Termination::TimedOut); + assert_eq!(result.program_exit_code(), None); assert!( started.elapsed() < Duration::from_secs(5), "sleep honours TERM, so KILL should not have been needed" @@ -519,7 +438,7 @@ mod tests { let fixture = HostFixture::new().await; let started = Instant::now(); let result = fixture - .exec(ExplicitEnvPolicy::FilterSensitive) + .exec() .with_stop_grace(Duration::from_millis(300)) .run( "trap '' TERM; sleep 10", @@ -530,7 +449,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(result.termination, CommandTermination::TimedOut); + assert_eq!(result.termination, Termination::TimedOut); let elapsed = started.elapsed(); assert!(elapsed >= Duration::from_millis(400), "{elapsed:?}"); assert!(elapsed < Duration::from_secs(5), "{elapsed:?}"); @@ -546,7 +465,7 @@ mod tests { cancel.cancel(); }); let result = fixture - .exec(ExplicitEnvPolicy::FilterSensitive) + .exec() .run( "sleep 10", Some(Duration::from_secs(30)), @@ -556,8 +475,8 @@ mod tests { ) .await .unwrap(); - assert_eq!(result.termination, CommandTermination::Cancelled); - assert_eq!(result.exit_code, None); + assert_eq!(result.termination, Termination::Cancelled); + assert_eq!(result.program_exit_code(), None); } #[tokio::test] @@ -565,33 +484,36 @@ mod tests { let fixture = HostFixture::new().await; let seen = Arc::new(Mutex::new(Vec::::new())); let sink_seen = Arc::clone(&seen); - let callback: CommandOutputCallback = Arc::new(move |stream, chunk| { + let sink: OutputSink = Arc::new(move |stream, chunk| { let seen = Arc::clone(&sink_seen); Box::pin(async move { - assert_eq!(stream, CommandOutputStream::Stdout); + assert_eq!(stream, OutputStream::Stdout); seen.lock().unwrap().extend_from_slice(&chunk); Ok(()) }) }); let streaming = fixture - .exec(ExplicitEnvPolicy::FilterSensitive) - .run_streaming(ExecStreamingRequest { - timeout_ms: Some(10_000), - output_callback: Some(callback), - stream_output_bytes_cap: Some(64), - ..ExecStreamingRequest::new("for i in $(seq 1 200); do echo line-$i; done") - }) + .exec() + .run_streaming( + ExecSpec::bash("for i in $(seq 1 200); do echo line-$i; done") + .timeout(Duration::from_secs(10)), + ExecControls { + sink: Some(sink), + retained_output_limit: Some(64), + ..ExecControls::default() + }, + ) .await .unwrap(); - assert!(streaming.result.is_success()); + assert!(streaming.result.success()); assert!(streaming.live_streaming); assert!(streaming.streams_separated); let delivered = seen.lock().unwrap().len(); assert_eq!(streaming.stdout_capture.observed_bytes, delivered); assert!(streaming.stdout_capture.omitted_bytes > 0); assert!(streaming.result.stdout.len() <= 64); - assert!(streaming.result.stdout.starts_with("line-1\n")); - assert!(streaming.result.stdout.ends_with("line-200\n")); + assert!(streaming.result.stdout.starts_with(b"line-1\n")); + assert!(streaming.result.stdout.ends_with(b"line-200\n")); } #[tokio::test] @@ -599,35 +521,43 @@ mod tests { let fixture = HostFixture::new().await; let stdin = b"first line\n$(touch must-not-run)\nlast line".to_vec(); let streaming = fixture - .exec(ExplicitEnvPolicy::FilterSensitive) - .run_streaming(ExecStreamingRequest { - timeout_ms: Some(10_000), - stdin: Some(stdin.clone()), - ..ExecStreamingRequest::new("cat; test -e must-not-run && echo RAN") - }) + .exec() + .run_streaming( + ExecSpec::bash("cat; test -e must-not-run && echo RAN") + .timeout(Duration::from_secs(10)) + .stdin(stdin.clone()), + ExecControls::default(), + ) .await .unwrap(); - assert_eq!(streaming.result.stdout.as_bytes(), stdin.as_slice()); + assert_eq!(streaming.result.stdout, stdin); } #[tokio::test] - async fn a_failing_output_callback_stops_the_command_with_an_error() { + async fn a_failing_output_sink_stops_the_command_with_an_error() { let fixture = HostFixture::new().await; - let callback: CommandOutputCallback = - Arc::new(|_, _| Box::pin(async { Err(crate::Error::message("consumer gave up")) })); - let error = fixture - .exec(ExplicitEnvPolicy::FilterSensitive) - .run_streaming(ExecStreamingRequest { - timeout_ms: Some(10_000), - output_callback: Some(callback), - ..ExecStreamingRequest::new("echo hello; sleep 5") + let sink: OutputSink = Arc::new(|_, _| { + Box::pin(async { + Err(sandbox_driver::Error::Transport(TransportError::new( + "consumer gave up", + ))) }) + }); + let error = fixture + .exec() + .run_streaming( + ExecSpec::bash("echo hello; sleep 5").timeout(Duration::from_secs(10)), + ExecControls { + sink: Some(sink), + ..ExecControls::default() + }, + ) .await .map(|streaming| streaming.result.termination); // The driver either surfaces the sink failure or reports the command // cancelled by it; both keep the consumer's error visible. match error { - Ok(termination) => assert_eq!(termination, CommandTermination::Cancelled), + Ok(termination) => assert_eq!(termination, Termination::Cancelled), Err(error) => assert!(error.to_string().contains("consumer gave up"), "{error}"), } } @@ -635,11 +565,7 @@ mod tests { #[tokio::test] async fn stdio_process_round_trips_lines_and_reports_exit() { let fixture = HostFixture::new().await; - let process = fixture - .exec(ExplicitEnvPolicy::FilterSensitive) - .spawn_stdio("cat", None, None, None) - .await - .unwrap(); + let process = fixture.exec().spawn_stdio("cat", None, None).await.unwrap(); let mut stdin = process.stdin; let mut stdout = BufReader::new(process.stdout); stdin.write_all(b"ping\n").await.unwrap(); @@ -647,52 +573,166 @@ mod tests { stdout.read_line(&mut line).await.unwrap(); assert_eq!(line, "ping\n"); drop(stdin); - let termination = process.handle.wait().await.unwrap(); - assert_eq!(termination.termination, CommandTermination::Exited); - assert_eq!(termination.exit_code, Some(0)); + let (termination, exit_code) = process.handle.wait().await; + assert_eq!(termination, Termination::Exited); + assert_eq!(exit_code, Some(0)); } #[tokio::test] - async fn stdio_process_terminates_on_cancel_and_keeps_a_stderr_tail() { + async fn stdio_process_terminates_on_request_and_keeps_a_stderr_tail() { let fixture = HostFixture::new().await; - let token = CancellationToken::new(); let process = fixture - .exec(ExplicitEnvPolicy::FilterSensitive) - .spawn_stdio( - "sh -c 'echo diag >&2; sleep 30'", - None, - None, - Some(token.clone()), - ) + .exec() + .spawn_stdio("sh -c 'echo diag >&2; sleep 30'", None, None) .await .unwrap(); time::sleep(Duration::from_millis(200)).await; - token.cancel(); - let termination = time::timeout(Duration::from_secs(5), process.handle.wait()) + process.handle.terminate().await; + let (termination, _) = time::timeout(Duration::from_secs(5), process.handle.wait()) .await - .expect("cancel terminates the process") - .unwrap(); - assert_ne!(termination.termination, CommandTermination::Exited); - assert_eq!(process.stderr.tail_string().await, "diag\n"); + .expect("terminate ends the process"); + assert_ne!(termination, Termination::Exited); + assert_eq!(process.stderr_tail.to_string_lossy(), "diag\n"); } #[test] fn termination_mapping_reads_the_drivers_verdict() { assert_eq!( - map_termination(Termination::TimedOut), + command_termination(Termination::TimedOut), CommandTermination::TimedOut ); assert_eq!( - map_termination(Termination::Cancelled), + command_termination(Termination::Cancelled), CommandTermination::Cancelled ); assert_eq!( - map_termination(Termination::Killed), + command_termination(Termination::Killed), CommandTermination::Cancelled ); assert_eq!( - map_termination(Termination::Exited), + command_termination(Termination::Exited), CommandTermination::Exited ); } + + #[test] + fn program_exit_code_is_the_commands_own_only_when_it_exited() { + assert_eq!(program_exit_code(Termination::Exited, Some(3)), Some(3)); + assert_eq!(program_exit_code(Termination::TimedOut, Some(143)), None); + assert_eq!(program_exit_code(Termination::Cancelled, Some(143)), None); + assert_eq!(program_exit_code(Termination::Killed, Some(137)), None); + } + + #[test] + fn into_result_reports_a_failure_under_its_label() { + let result = exec_result( + "out", + "fatal: could not read Username", + Some(128), + Termination::Exited, + 42, + ); + let error = result.into_result("git push").unwrap_err(); + let Some(sandbox_driver::Error::Exec(failure)) = error.driver() else { + panic!("expected an exec failure, got {error:?}"); + }; + assert_eq!(failure.label(), "git push"); + assert_eq!(failure.exit_code(), Some(128)); + assert_eq!(failure.duration(), Some(Duration::from_millis(42))); + assert!( + !error.to_string().contains("could not read Username"), + "raw output leaked into Display: {error}" + ); + + let ok = exec_result("out", "", Some(0), Termination::Exited, 1); + assert!(ok.into_result("true").is_ok()); + } + + #[test] + fn output_tail_redacts_before_truncating() { + let secret = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"; + let result = exec_result( + &format!("{} {secret} done", "context ".repeat(20)), + "", + Some(1), + Termination::Exited, + 1, + ); + + let tail = result + .redacted_output_tail(32) + .expect("redacted output tail"); + let stdout = tail.stdout.expect("stdout tail"); + assert!(stdout.contains("REDACTED"), "{stdout}"); + assert!(!stdout.contains("F0gH3jE6pA"), "{stdout}"); + assert!(tail.stdout_truncated); + } + + #[tokio::test] + async fn command_output_arrives_stripped_of_terminal_control_sequences() { + let fixture = HostFixture::new().await; + let result = run( + &fixture, + "printf '\\033[31mred\\033[0m \\033]0;window-title\\007shown \\033(Bset \\033Mtwo-byte \ + \\bbackspace'", + ) + .await; + assert!(result.success(), "{result:?}"); + assert_eq!(result.stdout_lossy(), "red shown set two-byte backspace"); + + let tail = result + .redacted_output_tail(1024) + .expect("redacted output tail"); + assert_eq!( + tail.stdout.as_deref(), + Some("red shown set two-byte backspace") + ); + } + + #[tokio::test] + async fn policy_strips_output_unless_the_caller_chose_another_policy() { + let fixture = HostFixture::new().await; + let exec = fixture.exec(); + assert_eq!( + exec.apply_policy(ExecSpec::bash("true")) + .output_sanitization, + OutputSanitization::StripAll + ); + assert_eq!( + exec.apply_policy( + ExecSpec::bash("true").output_sanitization(OutputSanitization::StripAnsi) + ) + .output_sanitization, + OutputSanitization::StripAnsi + ); + } + + #[test] + fn default_output_tail_serialized_budget_stays_below_40_kib() { + let result = exec_result( + &"o".repeat(DEFAULT_EXEC_OUTPUT_TAIL_BYTES + 128), + &"e".repeat(DEFAULT_EXEC_OUTPUT_TAIL_BYTES + 128), + Some(1), + Termination::Exited, + 1, + ); + + let tail = result.default_redacted_output_tail().expect("tail present"); + assert_eq!( + tail.stdout.as_deref().map(str::len), + Some(DEFAULT_EXEC_OUTPUT_TAIL_BYTES) + ); + assert_eq!( + tail.stderr.as_deref().map(str::len), + Some(DEFAULT_EXEC_OUTPUT_TAIL_BYTES) + ); + assert!(tail.stdout_truncated); + assert!(tail.stderr_truncated); + let serialized = serde_json::to_vec(&tail).expect("serialize tail"); + assert!( + serialized.len() < 40 * 1024, + "tail JSON was {} bytes", + serialized.len() + ); + } } diff --git a/lib/components/fabro-sandbox/src/git_policy.rs b/lib/components/fabro-sandbox/src/git_policy.rs new file mode 100644 index 000000000..51bf6a802 --- /dev/null +++ b/lib/components/fabro-sandbox/src/git_policy.rs @@ -0,0 +1,278 @@ +//! Fabro's retry budgets for git operations against GitHub. +//! +//! The driver owns the retry loop and the decision +//! ([`sandbox_driver::retry_git`]): a remote that cannot be reached is retried, +//! a rejected credential is retried only while the token is fresh enough to +//! still be replicating to GitHub's git endpoints, a static credential fails +//! fast, and a command whose outcome is unknown is never replayed. Fabro keeps +//! what is policy: how many attempts each operation gets, how long the +//! operation may take, and when the credential it pushes with was minted. +//! +//! Retries reuse the same token on purpose. Replication of a given token +//! only makes progress, so each attempt strictly improves the odds, while +//! re-minting would restart the replication clock. + +use std::future::Future; +use std::sync::{Mutex, PoisonError}; +use std::time::{Duration, SystemTime}; + +use fabro_github::token_source::TokenSnapshot; +pub use fabro_types::run_event::GitPushRetryReason as GitRetryReason; +use sandbox_driver::{GitBackoff, GitCredentials, GitFailure, GitFailureKind, GitRetryPolicy}; + +use crate::credentials::GITHUB_TOKEN_USERNAME; + +/// Backoff between attempts: 3s, then 9s. +/// +/// GitHub's guidance for token replication is to wait a few seconds and +/// retry with the same token. Sub-second delays land inside the same +/// replication window and spend an attempt for nothing. +fn replication_backoff() -> GitBackoff { + GitBackoff::new(Duration::from_secs(3), 3.0, Duration::from_secs(10)) +} + +/// The clone policy: 3 attempts at replication pacing, inside whatever is +/// left of the whole-clone budget. +pub(crate) fn clone_policy(remaining: Duration) -> GitRetryPolicy { + GitRetryPolicy::new(3, replication_backoff()).max_elapsed(remaining) +} + +/// Host-side repository probes use the clone's attempt count and pacing, +/// with no deadline of their own. +#[must_use] +pub fn repository_probe_policy() -> GitRetryPolicy { + GitRetryPolicy::new(3, replication_backoff()) +} + +/// Checkpoint pushes stay cheap: the next checkpoint re-pushes the same +/// branch anyway. Worst case about 90 seconds of wall clock. +#[must_use] +pub fn checkpoint_push_policy() -> GitRetryPolicy { + GitRetryPolicy::new(3, replication_backoff()) + .max_elapsed(Duration::from_secs(90)) + .per_attempt_timeout(Duration::from_mins(1)) +} + +/// The terminal publish push guards the whole run's value, so it gets a +/// real budget: 5 attempts with growing backoff (about 3s, 10s, 33s, 60s), +/// bounded at 4 minutes of wall clock. The bound must stay under the token +/// source's `REFRESH_MARGIN` (see the margin-invariant test) so a pinned +/// token always outlives the operation. +#[must_use] +pub fn publish_push_policy() -> GitRetryPolicy { + GitRetryPolicy::new( + 5, + GitBackoff::new(Duration::from_secs(3), 10.0 / 3.0, Duration::from_mins(1)), + ) + .max_elapsed(Duration::from_mins(4)) + .per_attempt_timeout(Duration::from_mins(1)) +} + +/// The reason fabro records for a driver retry reason. A reason this build +/// does not know still retried the attempt, so it is recorded under the +/// broader class. +pub(crate) fn recorded_reason(reason: sandbox_driver::GitRetryReason) -> GitRetryReason { + match reason { + sandbox_driver::GitRetryReason::TokenReplication => GitRetryReason::TokenReplication, + _ => GitRetryReason::TransientInfra, + } +} + +/// Credentials carrying only the token's mint time, which is all the +/// driver's decision reads for git that ran outside a sandbox. The token +/// itself never leaves its snapshot. +fn credential_age(snapshot: Option<&TokenSnapshot>) -> Option { + let snapshot = snapshot?; + let credentials = GitCredentials::new(GITHUB_TOKEN_USERNAME, ""); + Some(match snapshot.minted_at() { + Some(minted_at) => credentials.minted_at(SystemTime::from(minted_at)), + None => credentials, + }) +} + +/// The driver's failure for a rendered git message, so git that ran +/// outside a sandbox (the host-side repository probe, the metadata push) +/// is classified the same way as git the driver ran. +fn classified_failure(operation: &str, message: &str) -> sandbox_driver::Error { + sandbox_driver::Error::Git(GitFailure::classified( + operation, + GitFailureKind::from_message(message), + None, + )) +} + +/// Whether a rendered git failure `message` is worth retrying with the +/// token behind `snapshot`: `None` means the failure is permanent for +/// these credentials or unrecognized. +#[must_use] +pub fn transient_git_failure( + message: &str, + snapshot: Option<&TokenSnapshot>, +) -> Option { + let credentials = credential_age(snapshot); + sandbox_driver::retry_reason(&classified_failure("git", message), credentials.as_ref()) + .map(recorded_reason) +} + +/// Runs a host-side git operation that reports failures as rendered +/// messages under `policy`, retrying while the driver's decision says the +/// message is transient for the token behind `snapshot`. The final failure +/// comes back as the operation's own message. +pub async fn retry_git_messages( + policy: &GitRetryPolicy, + snapshot: Option<&TokenSnapshot>, + operation: &str, + mut run: F, +) -> Result<(), String> +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let credentials = credential_age(snapshot); + // The operation's own message is kept beside the classified failure the + // driver decides on, so the caller reads the message it knows. + let last_message = Mutex::new(None); + let result = sandbox_driver::retry_git( + policy, + credentials.as_ref(), + operation, + |_attempt, _timeout| { + let attempt = run(); + let last_message = &last_message; + async move { + attempt.await.map_err(|message| { + let error = classified_failure(operation, &message); + *last_message.lock().unwrap_or_else(PoisonError::into_inner) = Some(message); + error + }) + } + }, + ) + .await; + match result { + Ok(_) => Ok(()), + Err(failure) => Err(last_message + .into_inner() + .unwrap_or_else(PoisonError::into_inner) + .unwrap_or_else(|| failure.error.to_string())), + } +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + use fabro_github::token_source::{REFRESH_MARGIN, TokenProvenance}; + + use super::*; + + fn snapshot(age: Duration) -> TokenSnapshot { + let now = Utc::now(); + TokenSnapshot { + generation: 1, + provenance: TokenProvenance::Minted { + minted_at: now - chrono::Duration::from_std(age).unwrap(), + expires_at: now + chrono::Duration::hours(1), + }, + } + } + + fn static_snapshot() -> TokenSnapshot { + TokenSnapshot { + generation: 0, + provenance: TokenProvenance::Static, + } + } + + #[test] + fn not_found_follows_the_credential_age() { + let message = "repository not found: Repository not found."; + assert_eq!( + transient_git_failure(message, Some(&snapshot(Duration::from_secs(5)))), + Some(GitRetryReason::TokenReplication) + ); + assert_eq!( + transient_git_failure(message, Some(&snapshot(Duration::from_mins(2)))), + Some(GitRetryReason::TransientInfra) + ); + assert_eq!( + transient_git_failure(message, Some(&static_snapshot())), + None + ); + assert_eq!(transient_git_failure(message, None), None); + } + + #[test] + fn infrastructure_failures_retry_without_credentials() { + assert_eq!( + transient_git_failure("fatal: unable to access: Could not resolve host", None), + Some(GitRetryReason::TransientInfra) + ); + assert_eq!( + transient_git_failure("fatal: something else entirely", None), + None + ); + } + + /// `REFRESH_MARGIN` must exceed every push policy's `max_elapsed`: a + /// push resolves its token once, and the token the source returns has + /// at least the margin of validity left, so the pinned token outlives + /// the operation. + #[test] + fn refresh_margin_exceeds_every_push_policy_elapsed_bound() { + for policy in [checkpoint_push_policy(), publish_push_policy()] { + let max_elapsed = policy.max_elapsed.expect("push policies are bounded"); + assert!( + REFRESH_MARGIN > max_elapsed, + "margin invariant violated: {max_elapsed:?}" + ); + } + } + + #[test] + fn publish_backoff_grows_toward_a_one_minute_cap() { + let backoff = publish_push_policy().backoff; + assert_eq!(backoff.delay_after(1), Duration::from_secs(3)); + assert_eq!(backoff.delay_after(2), Duration::from_secs(10)); + assert_eq!(backoff.delay_after(4), Duration::from_mins(1)); + assert_eq!( + repository_probe_policy().backoff.delay_after(2), + Duration::from_secs(9) + ); + } + + #[tokio::test(start_paused = true)] + async fn host_side_retries_keep_the_operations_own_message() { + let calls = Mutex::new(0_u32); + let result = retry_git_messages( + &repository_probe_policy(), + Some(&snapshot(Duration::from_secs(1))), + "repository probe", + || { + let attempt = { + let mut calls = calls.lock().unwrap(); + *calls += 1; + *calls + }; + async move { + if attempt < 3 { + Err(format!("remote: Repository not found. (attempt {attempt})")) + } else { + Ok(()) + } + } + }, + ) + .await; + assert_eq!(result, Ok(())); + assert_eq!(*calls.lock().unwrap(), 3); + + let permanent = retry_git_messages( + &repository_probe_policy(), + Some(&static_snapshot()), + "repository probe", + || async { Err("remote: Repository not found.".to_owned()) }, + ) + .await; + assert_eq!(permanent, Err("remote: Repository not found.".to_owned())); + } +} diff --git a/lib/components/fabro-sandbox/src/git_retry.rs b/lib/components/fabro-sandbox/src/git_retry.rs deleted file mode 100644 index 9d4b78084..000000000 --- a/lib/components/fabro-sandbox/src/git_retry.rs +++ /dev/null @@ -1,719 +0,0 @@ -//! Retry for git operations against GitHub from clone-based sandboxes. -//! -//! Clone-based providers can mint a GitHub App installation token and use it -//! immediately. GitHub can reject that first operation before the token is -//! available to the git endpoint. On a private repository, the rejection can -//! arrive as `Repository not found.` or an authentication failure. -//! -//! Only a token minted recently makes those messages safe to retry. Static -//! PATs and pre-minted installation tokens fail fast; a mature App token can -//! still hit a service-side blip that presents the same surface, so it -//! retries as transient infrastructure. -//! -//! Retries reuse the same token on purpose. Replication of a given token only -//! makes progress, so each attempt strictly improves the odds, while -//! re-minting would restart the replication clock. -//! -//! The driver classifies what a failure was ([`GitFailureKind`]); this module -//! decides what the class means for the credentials in hand. - -use std::future::Future; -use std::time::Duration; - -use chrono::Utc; -use fabro_github::token_source::TokenSnapshot; -#[cfg(test)] -use fabro_github::token_source::{REFRESH_MARGIN, TokenProvenance}; -use fabro_types::SandboxProviderKind; -pub use fabro_types::run_event::GitPushRetryReason as GitRetryReason; -use fabro_util::backoff::BackoffPolicy; -use sandbox_driver::GitFailureKind; -use tokio::time; - -/// How long after its mint a token is presumed to still be replicating to -/// GitHub's git endpoints. Matches the observed scale of the lag (seconds, -/// occasionally tens of seconds). -pub(crate) const REPLICATION_HORIZON: Duration = Duration::from_mins(1); - -/// What a git failure message tells us about retry safety. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum GitMessageClass { - Retry(GitRetryReason), - Permanent, - Unknown, -} - -impl GitMessageClass { - pub(crate) fn retry_reason(self) -> Option { - match self { - Self::Retry(reason) => Some(reason), - Self::Permanent | Self::Unknown => None, - } - } -} - -/// What the operation's credentials say about retrying auth-shaped failures. -/// -/// Derived from the [`TokenSnapshot`] of the token embedded for the attempt, -/// so classification reads provenance as data instead of threading booleans -/// through call stacks. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CredentialContext { - /// An installation token younger than [`REPLICATION_HORIZON`] — a 404 or - /// auth failure is likely replication lag; retry with the same token. - FreshApp, - /// An installation token older than the horizon. A 404 with it is - /// indistinguishable from a service-side blip at this layer, so it stays - /// transient rather than proving access loss. - MatureApp, - /// A PAT or pre-minted token — it cannot become valid by waiting. - Static, - /// No credentials at all. - None, -} - -impl CredentialContext { - #[must_use] - pub fn from_snapshot(snapshot: Option<&TokenSnapshot>) -> Self { - match snapshot { - None => Self::None, - Some(snapshot) => match snapshot.age_at(Utc::now()) { - None => Self::Static, - Some(age) if age < REPLICATION_HORIZON => Self::FreshApp, - Some(_) => Self::MatureApp, - }, - } - } -} - -/// What a classified git failure means for retrying with these credentials. -/// -/// The driver reads the failure; fabro decides. A remote that could not -/// be reached is retried whatever the credential. A rejected credential -/// is retried only while a just-minted App token may still be replicating -/// (`FreshApp`), retried as a service blip for a mature App token, and -/// fails fast for a static credential or none, because waiting cannot make -/// those valid. Every other class is permanent. -pub(crate) fn decide(kind: GitFailureKind, cred: CredentialContext) -> GitMessageClass { - match kind { - GitFailureKind::RemoteUnavailable => GitMessageClass::Retry(GitRetryReason::TransientInfra), - GitFailureKind::AuthRejected => match cred { - CredentialContext::FreshApp => GitMessageClass::Retry(GitRetryReason::TokenReplication), - CredentialContext::MatureApp => GitMessageClass::Retry(GitRetryReason::TransientInfra), - CredentialContext::Static | CredentialContext::None => GitMessageClass::Permanent, - }, - GitFailureKind::AccessDenied - | GitFailureKind::RefNotFound - | GitFailureKind::TargetExists => GitMessageClass::Permanent, - _ => GitMessageClass::Unknown, - } -} - -/// Classify a failed git operation by its rendered message. For git that -/// ran outside a sandbox — the host-side repository probe and metadata -/// push — where the driver never saw the failure. -pub(crate) fn classify_message(message: &str, cred: CredentialContext) -> GitMessageClass { - decide(GitFailureKind::from_message(message), cred) -} - -/// Classify a rendered git failure message, returning the retry reason when -/// the failure is transient for these credentials. `None` means the failure -/// is permanent or unrecognized. -#[must_use] -pub fn classify_failure(message: &str, cred: CredentialContext) -> Option { - classify_message(message, cred).retry_reason() -} - -/// Classify a sandbox-driver git failure. -/// -/// The driver classifies every git failure it produces; fabro only decides -/// what the class means for these credentials. An operation whose outcome -/// is unknown (a transport break, a timeout, an incomplete operation) is -/// never retried: replaying it could overlap a clone that is still running. -#[must_use] -pub(crate) fn classify_driver_failure( - error: &sandbox_driver::Error, - cred: CredentialContext, -) -> Option { - match error { - sandbox_driver::Error::Git(failure) => decide(failure.kind(), cred).retry_reason(), - sandbox_driver::Error::RateLimited { .. } | sandbox_driver::Error::Overloaded { .. } => { - Some(GitRetryReason::TransientInfra) - } - _ => None, - } -} - -/// Backoff between attempts: 3s, then 9s. -/// -/// GitHub's guidance for token replication is to wait a few seconds and retry -/// with the same token. Sub-second delays land inside the same replication -/// window and spend an attempt for nothing. -fn replication_backoff() -> BackoffPolicy { - BackoffPolicy { - initial_delay: Duration::from_secs(3), - factor: 3.0, - max_delay: Duration::from_secs(10), - jitter: false, - } -} - -/// Attempt and time bounds for one retried git operation. -/// -/// All bounds are optional so existing behaviors are expressible unchanged. -/// The effective deadline is the minimum of the bounds that are present -/// (`start + max_elapsed`, `outer_deadline`); each attempt runs with -/// `min(per_attempt_timeout, remaining)` over the caps that are present, and -/// no attempt or backoff starts past the effective deadline. -#[derive(Debug, Clone)] -pub struct RetryPlan { - /// Total attempts, including the first. - pub max_attempts: u32, - pub backoff: BackoffPolicy, - /// Wall clock for this whole operation. - pub max_elapsed: Option, - /// Cap for any single attempt. - pub per_attempt_timeout: Option, - /// Caller-supplied absolute bound. - pub outer_deadline: Option, -} - -impl RetryPlan { - /// Host-side repository probes use the same attempt count and pacing as - /// clone operations against a freshly minted token. - #[must_use] - pub fn repository_probe() -> Self { - Self::clone_default(None) - } - - /// The clone policy both providers already trust: 3 attempts, 3s/9s - /// backoff, no plan-level bounds. Docker supplies its existing absolute - /// five-minute deadline through `outer_deadline`; Daytona supplies none. - #[must_use] - pub fn clone_default(outer_deadline: Option) -> Self { - Self { - max_attempts: 3, - backoff: replication_backoff(), - max_elapsed: None, - per_attempt_timeout: None, - outer_deadline, - } - } - - /// Checkpoint pushes stay cheap: the next checkpoint re-pushes the same - /// branch anyway. Worst case ~90 seconds of wall clock. - #[must_use] - pub fn checkpoint_push() -> Self { - Self { - max_attempts: 3, - backoff: replication_backoff(), - max_elapsed: Some(Duration::from_secs(90)), - per_attempt_timeout: Some(Duration::from_mins(1)), - outer_deadline: None, - } - } - - /// The terminal publish push guards the whole run's value, so it gets a - /// real budget: 5 attempts with growing backoff (~3s/10s/33s/60s), - /// bounded at 4 minutes of wall clock. The 4-minute bound must stay - /// under the token source's `REFRESH_MARGIN` (see the margin-invariant - /// test) so a pinned token always outlives the operation. - #[must_use] - pub fn publish_push() -> Self { - Self { - max_attempts: 5, - backoff: BackoffPolicy { - initial_delay: Duration::from_secs(3), - factor: 10.0 / 3.0, - max_delay: Duration::from_mins(1), - jitter: false, - }, - max_elapsed: Some(Duration::from_mins(4)), - per_attempt_timeout: Some(Duration::from_mins(1)), - outer_deadline: None, - } - } - - /// The absolute deadline this operation must finish by, if any bound is - /// present. - pub(crate) fn effective_deadline(&self, start: time::Instant) -> Option { - let elapsed_deadline = self.max_elapsed.map(|max| start + max); - match (elapsed_deadline, self.outer_deadline) { - (Some(a), Some(b)) => Some(a.min(b)), - (Some(a), None) => Some(a), - (None, Some(b)) => Some(b), - (None, None) => None, - } - } - - /// Time cap for an attempt starting now: the per-attempt cap bounded by - /// the time remaining before the effective deadline. - pub(crate) fn attempt_timeout(&self, deadline: Option) -> Option { - let remaining = deadline.map(|d| d.saturating_duration_since(time::Instant::now())); - match (self.per_attempt_timeout, remaining) { - (Some(cap), Some(remaining)) => Some(cap.min(remaining)), - (Some(cap), None) => Some(cap), - (None, remaining) => remaining, - } - } - - pub(crate) fn retry_delay( - &self, - attempt_number: u32, - deadline: Option, - ) -> Option { - let delay = self.backoff.delay_for_attempt(attempt_number); - if deadline.is_some_and(|deadline| { - delay >= deadline.saturating_duration_since(time::Instant::now()) - }) { - None - } else { - Some(delay) - } - } -} - -/// Run a git operation, repeating it while the failure looks transient. -/// -/// `attempt` receives the 1-based attempt number. `classify` decides whether -/// an error is worth repeating; `None` returns it to the caller untouched. -/// A retry starts only when its backoff fits before the plan's effective -/// deadline. The final error is returned as-is. -pub async fn retry_git_operation( - provider: SandboxProviderKind, - op: &str, - plan: &RetryPlan, - mut attempt: Attempt, - classify: Classify, -) -> Result -where - Attempt: FnMut(u32) -> Fut, - Fut: Future>, - Classify: Fn(&E) -> Option, -{ - let deadline = plan.effective_deadline(time::Instant::now()); - - for attempt_number in 1..plan.max_attempts.max(1) { - match attempt(attempt_number).await { - Ok(value) => return Ok(value), - Err(err) => { - let Some(reason) = classify(&err) else { - return Err(err); - }; - let Some(delay) = plan.retry_delay(attempt_number, deadline) else { - return Err(err); - }; - // The failure text can carry git stderr, so log the category - // rather than the message. The caller still reports the full - // error if the attempts run out. - tracing::warn!( - provider = %provider, - op, - attempt = attempt_number, - max_attempts = plan.max_attempts, - reason = %reason, - delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX), - "Git operation failed, retrying" - ); - time::sleep(delay).await; - } - } - } - - attempt(plan.max_attempts.max(1)).await -} - -#[cfg(test)] -mod tests { - use std::sync::Mutex; - - use super::*; - - /// Records the attempt numbers a closure was called with. - #[derive(Default)] - struct Attempts(Mutex>); - - impl Attempts { - fn record(&self, attempt: u32) { - self.0.lock().expect("attempt log mutex").push(attempt); - } - - fn recorded(&self) -> Vec { - self.0.lock().expect("attempt log mutex").clone() - } - } - - /// A classifier that treats every failure as worth repeating. - const ALWAYS_RETRY: fn(&String) -> Option = - |_| Some(GitRetryReason::TokenReplication); - - fn fresh_snapshot(age: Duration, ttl: Duration) -> TokenSnapshot { - let now = Utc::now(); - TokenSnapshot { - generation: 1, - provenance: TokenProvenance::Minted { - minted_at: now - chrono::Duration::from_std(age).unwrap(), - expires_at: now + chrono::Duration::from_std(ttl).unwrap(), - }, - } - } - - #[test] - fn credential_context_reads_token_age_from_provenance() { - assert_eq!( - CredentialContext::from_snapshot(None), - CredentialContext::None - ); - assert_eq!( - CredentialContext::from_snapshot(Some(&TokenSnapshot { - generation: 0, - provenance: TokenProvenance::Static, - })), - CredentialContext::Static - ); - assert_eq!( - CredentialContext::from_snapshot(Some(&fresh_snapshot( - Duration::from_secs(5), - Duration::from_hours(1) - ))), - CredentialContext::FreshApp - ); - assert_eq!( - CredentialContext::from_snapshot(Some(&fresh_snapshot( - Duration::from_mins(2), - Duration::from_hours(1) - ))), - CredentialContext::MatureApp - ); - } - - #[test] - fn private_repo_not_found_with_a_fresh_token_is_a_replication_lag() { - assert_eq!( - classify_message( - "repository not found: Repository not found.", - CredentialContext::FreshApp - ), - GitMessageClass::Retry(GitRetryReason::TokenReplication) - ); - } - - #[test] - fn not_found_with_a_mature_token_is_transient_not_permanent() { - // A service-side blip is indistinguishable from access loss at this - // layer, so a mature-App 404 stays retryable. - assert_eq!( - classify_message( - "repository not found: Repository not found.", - CredentialContext::MatureApp - ), - GitMessageClass::Retry(GitRetryReason::TransientInfra) - ); - } - - #[test] - fn not_found_with_static_or_no_credentials_is_permanent() { - for cred in [CredentialContext::Static, CredentialContext::None] { - assert_eq!( - classify_message("repository not found: Repository not found.", cred), - GitMessageClass::Permanent, - "{cred:?} cannot become valid by waiting" - ); - } - } - - #[test] - fn auth_failure_classification_follows_the_credential_context() { - let message = "fatal: Authentication failed for 'https://github.com/owner/repo'"; - assert_eq!( - classify_message(message, CredentialContext::FreshApp), - GitMessageClass::Retry(GitRetryReason::TokenReplication) - ); - assert_eq!( - classify_message(message, CredentialContext::MatureApp), - GitMessageClass::Retry(GitRetryReason::TransientInfra) - ); - assert_eq!( - classify_message(message, CredentialContext::Static), - GitMessageClass::Permanent - ); - } - - #[test] - fn infra_failures_retry_without_credentials() { - for message in [ - "fatal: unable to access: Could not resolve host: github.com", - "error: RPC failed; curl 56 recv failure", - "fatal: early EOF", - "Operation timed out", - ] { - assert_eq!( - classify_message(message, CredentialContext::None), - GitMessageClass::Retry(GitRetryReason::TransientInfra), - "expected {message:?} to be transient" - ); - } - } - - #[test] - fn genuine_failures_are_not_retried() { - for message in [ - "fatal: could not read Username for 'https://github.com'", - "remote: Permission to owner/repo.git denied", - "fatal: destination path 'repo' already exists", - ] { - assert_eq!( - classify_message(message, CredentialContext::FreshApp), - GitMessageClass::Permanent, - "expected {message:?} to fail fast" - ); - } - } - - #[test] - fn unrecognized_failures_remain_unknown() { - assert_eq!( - classify_message( - "git operation stopped for an unexpected reason", - CredentialContext::FreshApp - ), - GitMessageClass::Unknown - ); - } - - #[test] - fn backoff_waits_seconds_not_milliseconds() { - let plan = RetryPlan::clone_default(None); - assert_eq!(plan.backoff.delay_for_attempt(1), Duration::from_secs(3)); - assert_eq!(plan.backoff.delay_for_attempt(2), Duration::from_secs(9)); - } - - #[test] - fn publish_backoff_grows_toward_a_one_minute_cap() { - let plan = RetryPlan::publish_push(); - assert_eq!(plan.backoff.delay_for_attempt(1), Duration::from_secs(3)); - assert_eq!(plan.backoff.delay_for_attempt(2), Duration::from_secs(10)); - assert!(plan.backoff.delay_for_attempt(3) < Duration::from_secs(35)); - assert_eq!(plan.backoff.delay_for_attempt(4), Duration::from_mins(1)); - } - - /// `REFRESH_MARGIN` must exceed every push plan's `max_elapsed`: a push - /// pins the token of its single successful resolve, and any token the - /// source returns has at least the margin of validity left, so the pinned - /// token must outlive the whole operation. - #[test] - fn refresh_margin_exceeds_every_push_plan_elapsed_bound() { - for plan in [RetryPlan::checkpoint_push(), RetryPlan::publish_push()] { - let max_elapsed = plan.max_elapsed.expect("push plans bound elapsed time"); - assert!( - REFRESH_MARGIN > max_elapsed, - "margin invariant violated: {max_elapsed:?}" - ); - } - } - - #[test] - fn effective_deadline_takes_the_minimum_of_present_bounds() { - let start = time::Instant::now(); - let outer = start + Duration::from_secs(30); - - let unbounded = RetryPlan::clone_default(None); - assert_eq!(unbounded.effective_deadline(start), None); - - let outer_only = RetryPlan::clone_default(Some(outer)); - assert_eq!(outer_only.effective_deadline(start), Some(outer)); - - let mut both = RetryPlan::checkpoint_push(); - both.outer_deadline = Some(outer); - assert_eq!(both.effective_deadline(start), Some(outer)); - - both.outer_deadline = Some(start + Duration::from_mins(10)); - assert_eq!( - both.effective_deadline(start), - Some(start + Duration::from_secs(90)) - ); - } - - #[tokio::test(start_paused = true)] - async fn attempt_timeout_is_capped_by_the_remaining_deadline() { - let plan = RetryPlan::checkpoint_push(); - let deadline = Some(time::Instant::now() + Duration::from_secs(20)); - assert_eq!( - plan.attempt_timeout(deadline), - Some(Duration::from_secs(20)) - ); - assert_eq!(plan.attempt_timeout(None), Some(Duration::from_mins(1))); - - let unbounded = RetryPlan::clone_default(None); - assert_eq!(unbounded.attempt_timeout(None), None); - } - - #[tokio::test(start_paused = true)] - async fn first_success_runs_one_attempt() { - let attempts = Attempts::default(); - - let result = retry_git_operation( - SandboxProviderKind::DOCKER, - "clone", - &RetryPlan::clone_default(None), - |attempt| { - attempts.record(attempt); - async move { Ok::<_, String>(attempt) } - }, - ALWAYS_RETRY, - ) - .await; - - assert_eq!(result, Ok(1)); - assert_eq!(attempts.recorded(), vec![1]); - } - - #[tokio::test(start_paused = true)] - async fn retries_until_a_later_attempt_succeeds() { - let attempts = Attempts::default(); - - let result = retry_git_operation( - SandboxProviderKind::DOCKER, - "clone", - &RetryPlan::clone_default(None), - |attempt| { - attempts.record(attempt); - async move { - if attempt < 3 { - Err("Repository not found.".to_string()) - } else { - Ok(attempt) - } - } - }, - ALWAYS_RETRY, - ) - .await; - - assert_eq!(result, Ok(3)); - assert_eq!(attempts.recorded(), vec![1, 2, 3]); - } - - #[tokio::test(start_paused = true)] - async fn exhausted_attempts_return_the_final_error() { - let attempts = Attempts::default(); - - let result = retry_git_operation( - SandboxProviderKind::DOCKER, - "clone", - &RetryPlan::clone_default(None), - |attempt| { - attempts.record(attempt); - async move { Err::<(), _>(format!("Repository not found. (attempt {attempt})")) } - }, - ALWAYS_RETRY, - ) - .await; - - assert_eq!( - result, - Err("Repository not found. (attempt 3)".to_string()), - "the caller should see the last failure, not the first" - ); - assert_eq!(attempts.recorded(), vec![1, 2, 3]); - } - - #[tokio::test(start_paused = true)] - async fn unretryable_failure_stops_immediately() { - let attempts = Attempts::default(); - - let result = retry_git_operation( - SandboxProviderKind::DOCKER, - "clone", - &RetryPlan::clone_default(None), - |attempt| { - attempts.record(attempt); - async move { Err::<(), _>("permission denied".to_string()) } - }, - |_: &String| None, - ) - .await; - - assert_eq!(result, Err("permission denied".to_string())); - assert_eq!( - attempts.recorded(), - vec![1], - "a deterministic failure should not wait out the backoff" - ); - } - - /// Docker clone parity: the caller's absolute deadline stops retries when - /// the backoff no longer fits before it. - #[tokio::test(start_paused = true)] - async fn outer_deadline_stops_retry_when_backoff_does_not_fit() { - let attempts = Attempts::default(); - let deadline = time::Instant::now() + Duration::from_secs(2); - - let result = retry_git_operation( - SandboxProviderKind::DOCKER, - "clone", - &RetryPlan::clone_default(Some(deadline)), - |attempt| { - attempts.record(attempt); - async move { Err::<(), _>("temporary failure".to_string()) } - }, - ALWAYS_RETRY, - ) - .await; - - assert_eq!(result, Err("temporary failure".to_string())); - assert_eq!(attempts.recorded(), vec![1]); - assert_eq!(time::Instant::now() + Duration::from_secs(2), deadline); - } - - /// Daytona clone parity: with no bounds at all, attempts are limited only - /// by `max_attempts` and backoff. - #[tokio::test(start_paused = true)] - async fn unbounded_plan_runs_all_attempts() { - let attempts = Attempts::default(); - - let result = retry_git_operation( - SandboxProviderKind::DAYTONA, - "clone", - &RetryPlan::clone_default(None), - |attempt| { - attempts.record(attempt); - async move { Err::<(), _>("temporary failure".to_string()) } - }, - |_: &String| Some(GitRetryReason::TransientInfra), - ) - .await; - - assert!(result.is_err()); - assert_eq!(attempts.recorded(), vec![1, 2, 3]); - } - - #[tokio::test(start_paused = true)] - async fn max_elapsed_stops_retry_when_backoff_does_not_fit() { - let attempts = Attempts::default(); - let plan = RetryPlan { - max_attempts: 5, - backoff: replication_backoff(), - max_elapsed: Some(Duration::from_secs(4)), - per_attempt_timeout: None, - outer_deadline: None, - }; - - let result = retry_git_operation( - SandboxProviderKind::DOCKER, - "push", - &plan, - |attempt| { - attempts.record(attempt); - async move { Err::<(), _>("temporary failure".to_string()) } - }, - ALWAYS_RETRY, - ) - .await; - - assert!(result.is_err()); - // Attempt 1 fails instantly, 3s backoff fits inside 4s, attempt 2 - // fails, and the 9s backoff no longer fits. - assert_eq!(attempts.recorded(), vec![1, 2]); - } -} diff --git a/lib/components/fabro-sandbox/src/lib.rs b/lib/components/fabro-sandbox/src/lib.rs index 7dc02155a..1a4253db7 100644 --- a/lib/components/fabro-sandbox/src/lib.rs +++ b/lib/components/fabro-sandbox/src/lib.rs @@ -1,30 +1,27 @@ +pub mod environment; pub mod error; -pub mod options; pub mod provider; pub mod sandbox; pub mod sandbox_spec; mod clone_source; -mod git_retry; +mod git_policy; mod managed_labels; -mod push_credentials; - -pub mod redact; +mod credentials; pub mod details; pub mod driver; pub mod driver_sandbox; -pub mod environment; pub mod exec; +mod pebble_environment; pub mod reconnect; - -pub mod terminal; +mod redact; mod clone; pub mod docker; @@ -38,40 +35,37 @@ pub mod test_support; pub use details::sandbox_details; pub use docker::check_docker_daemon; pub use driver::{DaytonaCredentials, ProviderAccess}; -pub use driver_sandbox::{RunSandbox, local_sandbox}; +pub use driver_sandbox::RunSandbox; +pub use environment::{CloneRequest, sandbox_spec_for_environment}; pub use error::{Error, Result, default_redacted_output_tail, display_for_log}; -pub use exec::{ExplicitEnvPolicy, SandboxExec, is_sensitive_env_var}; +pub use exec::{ + DEFAULT_RETAINED_OUTPUT_BYTES, DEFAULT_STOP_GRACE, ExecResultExt, SandboxExec, + command_termination, program_exit_code, +}; pub use fabro_github::token_source::{ InstallationTokenSource, ResolvedToken, TokenProvenance, TokenSnapshot, }; pub use fabro_types::{RunSandboxInstance, SandboxProviderKind}; -pub use git_retry::{ - CredentialContext, GitRetryReason, RetryPlan, classify_failure, retry_git_operation, -}; -pub use options::{ - SandboxOptions, local_working_directory_from_environment, options_from_environment, - unresolved_env, -}; -pub use provider::driver::DriverInventoryProvider; -pub use provider::{ - LocalSandboxProvider, SandboxLookupError, SandboxProvider, SandboxProviderRegistry, -}; -pub use provider_sandbox::{attach_provider_sandbox, provider_sandbox}; -pub use push_credentials::RefreshErrorKind; -pub use reconnect::{ - reconnect, reconnect_driver_for_run, reconnect_for_run, reconnect_for_run_with_events, +pub use git_policy::{ + GitRetryReason, checkpoint_push_policy, publish_push_policy, repository_probe_policy, + retry_git_messages, transient_git_failure, }; +pub use provider::{SandboxInventory, SandboxLookupError}; +pub use provider_sandbox::{attach_provider_sandbox, local_sandbox, provider_sandbox}; +pub use reconnect::{open_terminal_for_run, reconnect_for_run}; pub use redact::SecretRedactor; pub use sandbox::{ - CommandOutputCallback, DEFAULT_EXEC_OUTPUT_TAIL_BYTES, ExecResult, ExecStreamingRequest, - ExecStreamingResult, GitRunInfo, GitSetupIntent, OutputCaptureStats, PushAttempt, PushError, - PushReport, RefreshOutcome, RemoteCredentialAction, SandboxFile, SandboxWorkspaceLayout, - StderrCollector, StdioProcess, StdioProcessHandle, StdioProcessTermination, - format_lines_numbered, redacted_output_tail, setup_git, shell_quote, + DEFAULT_EXEC_OUTPUT_TAIL_BYTES, GitRunInfo, GitSetupIntent, PushAttempt, PushError, PushReport, + SandboxFile, SandboxWorkspaceLayout, redacted_output_tail, setup_git, }; -/// Driver types a run sandbox's file and search operations speak, and the -/// network policy a [`SandboxOptions`] asks for, re-exported so consumers -/// need no direct driver dependency. -pub use sandbox_driver::{DirEntry, FileKind, GrepMatch, GrepOptions, NetworkPolicy, WalkOptions}; -pub use sandbox_spec::{ProviderSandboxSpec, SandboxSpec}; -pub use terminal::{DriverTerminalSession, TerminalSession, TerminalSize, open_terminal_for_run}; +/// Driver types a run sandbox speaks: what a command is and how it ended, +/// what the file and search operations return, and what an environment +/// asks of a sandbox. Re-exported so consumers need no direct driver +/// dependency. +pub use sandbox_driver::{ + CaptureStats, DirEntry, ExecControls, ExecFailure, ExecResult, ExecSpec, ExecStreamingResult, + FileKind, GitRetryPolicy, GrepMatch, GrepOptions, LifecycleTimers, NetworkPolicy, OutputSink, + OutputStream, PtySession, PtySize, Resources, SandboxSource, SandboxSpec as DriverSpec, + StderrTail, StdioProcess, StdioProcessHandle, Termination, TransportError, WalkOptions, +}; +pub use sandbox_spec::SandboxSpec; diff --git a/lib/components/fabro-sandbox/src/options.rs b/lib/components/fabro-sandbox/src/options.rs deleted file mode 100644 index f192e534a..000000000 --- a/lib/components/fabro-sandbox/src/options.rs +++ /dev/null @@ -1,391 +0,0 @@ -//! What an environment asks of a sandbox, mapped once for every provider. -//! -//! The environment names an image or Dockerfile, resources, a network -//! policy, labels, variables, a lifecycle, and a clone policy. Every -//! provider consumes the same [`SandboxOptions`]: the driver spec is built -//! from them in one place, and a bundled provider adds only what its -//! backend needs on top (the Docker working directory, the Daytona -//! snapshot and timers) in its own overlay. - -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use fabro_types::RunId; -use fabro_types::settings::run::{ - DockerfileSource, EnvironmentNetworkMode, RunCloneSettings, RunEnvironmentSettings, -}; -use sandbox_driver::{ - Capabilities, NetworkPolicy, Resources, SandboxSource, SandboxSpec as DriverSpec, -}; - -/// What an environment asks of a sandbox, provider-neutral. -#[derive(Clone, Debug, Default)] -pub struct SandboxOptions { - /// Image reference, when the environment names one. - pub image: Option, - /// Inline Dockerfile, when the environment names one instead of an - /// image. - pub dockerfile: Option, - /// Environment variables for the sandbox, resolved. - pub env: BTreeMap, - pub network: NetworkPolicy, - pub cpu: Option, - pub memory_bytes: Option, - pub disk_bytes: Option, - /// Labels from the environment; fabro's managed labels are added. - pub labels: BTreeMap, - /// Idle time before the provider stops the sandbox, when the - /// environment sets one. - pub auto_stop: Option, - /// Maximum Git history depth fetched during clone; `None` fetches full - /// history. - pub clone_depth: Option, - /// Create an empty workspace instead of cloning even when an origin - /// exists. - pub skip_clone: bool, -} - -impl SandboxOptions { - /// Memory in whole mebibytes, rounded up. - pub fn memory_mb(&self) -> Option { - self.memory_bytes.map(|bytes| bytes.div_ceil(1024 * 1024)) - } - - /// Disk in whole mebibytes, rounded up. - pub fn disk_mb(&self) -> Option { - self.disk_bytes.map(|bytes| bytes.div_ceil(1024 * 1024)) - } -} - -/// Maps resolved environment settings onto sandbox options. `env` is the -/// environment's variables, resolved by the caller: the worker resolves -/// secrets through the vault, while preflight carries them in source form. -/// -/// A Dockerfile given as a path must have been resolved to inline content -/// earlier; none of the providers can read a path. -pub fn options_from_environment( - settings: &RunEnvironmentSettings, - clone: &RunCloneSettings, - env: BTreeMap, -) -> crate::Result { - // fabro-config rejects environments that set both image.docker and - // image.dockerfile. If both still arrive here, the image wins. - let dockerfile = match (&settings.image.docker, &settings.image.dockerfile) { - (Some(_), _) | (None, None) => None, - (None, Some(DockerfileSource::Inline(content))) => Some(content.clone()), - (None, Some(DockerfileSource::Path { path })) => { - return Err(crate::Error::message(format!( - "environment `{}` names a Dockerfile path ({path}) that should have been \ - resolved to inline content before sandbox creation", - settings.id - ))); - } - }; - Ok(SandboxOptions { - image: settings.image.docker.clone(), - dockerfile, - env, - network: match settings.network.mode { - EnvironmentNetworkMode::Block => NetworkPolicy::Block, - EnvironmentNetworkMode::AllowAll => NetworkPolicy::AllowAll, - EnvironmentNetworkMode::CidrAllowList => NetworkPolicy::CidrAllowList { - cidrs: settings.network.allow.clone(), - }, - }, - cpu: settings - .resources - .cpu - .and_then(|cpu| u32::try_from(cpu).ok()), - memory_bytes: settings.resources.memory.map(|size| size.as_bytes()), - disk_bytes: settings.resources.disk.map(|size| size.as_bytes()), - labels: settings - .labels - .iter() - .map(|(key, value)| (key.clone(), value.clone())) - .collect(), - auto_stop: settings - .lifecycle - .auto_stop - .map(|duration| duration.as_std()), - clone_depth: clone - .depth_limit() - .and_then(|depth| u32::try_from(depth).ok()), - skip_clone: !clone.enabled, - }) -} - -/// The environment's variables in source form, for a path with no vault -/// (server preflight): a `{{ secrets.* }}` value keeps its token, and -/// nothing else is left to resolve because `{{ vars.* }}` is substituted at -/// run creation. -pub fn unresolved_env(settings: &RunEnvironmentSettings) -> BTreeMap { - #[expect( - clippy::disallowed_methods, - reason = "preflight has no vault, so an unresolved secret token is carried in source form" - )] - settings - .env - .iter() - .map(|(key, value)| (key.clone(), value.as_source())) - .collect() -} - -pub fn local_working_directory_from_environment( - settings: &RunEnvironmentSettings, - source_directory: Option<&Path>, -) -> crate::Result { - if let Some(cwd) = settings.cwd.as_deref() { - return Ok(PathBuf::from(cwd)); - } - - let Some(source_directory) = source_directory else { - return Err(crate::Error::message( - "local environment requires a server-side working directory; configure `environment.cwd = \"/absolute/path\"` on the selected local environment", - )); - }; - - if source_directory.is_dir() { - return Ok(source_directory.to_path_buf()); - } - - Err(crate::Error::message(format!( - "local environment source_directory does not exist or is not a directory on this server: {}. Configure `environment.cwd = \"/absolute/path\"` on the selected local environment for remote client/server deployments.", - source_directory.display() - ))) -} - -/// The driver spec every provider starts from: the environment's source -/// (an image, a Dockerfile, or a managed directory when it names -/// neither), the run's name, the environment's labels, variables, -/// resources, and network policy. A bundled provider's overlay adjusts -/// what its backend needs, and the ownership scope adds fabro's labels. -pub(crate) fn base_spec(options: &SandboxOptions, run_id: Option<&RunId>) -> DriverSpec { - let source = match (&options.image, &options.dockerfile) { - (Some(reference), _) => SandboxSource::Image { - reference: reference.clone(), - }, - (None, Some(content)) => SandboxSource::Dockerfile { - content: content.clone(), - }, - // A provider without images (a host-style plugin) manages a - // workspace directory of its own. - (None, None) => SandboxSource::HostDirectory, - }; - let mut spec = DriverSpec::new(source).network(options.network.clone()); - if let Some(run_id) = run_id { - spec = spec.name(run_name(run_id)); - } - // The environment's labels; fabro's ownership labels are stamped by the - // ownership scope the provider is connected through. - for (key, value) in &options.labels { - spec = spec.label(key, value); - } - for (key, value) in &options.env { - spec = spec.env_var(key, value); - } - let mut resources = Resources::default(); - resources.cpu_cores = options.cpu; - resources.memory_mb = options.memory_mb(); - resources.disk_mb = options.disk_mb(); - spec.resources(resources) -} - -/// The provider-side name of a run's sandbox. -pub(crate) fn run_name(run_id: &RunId) -> String { - format!("fabro-run-{run_id}") -} - -/// The environment's default `allow_all` means "unrestricted", which a -/// provider without network controls already is; asking such a provider -/// for it explicitly would be rejected. An explicit restriction is still -/// requested, and refused by the provider when it cannot honor it. -pub(crate) fn supported_network( - requested: NetworkPolicy, - capabilities: &Capabilities, -) -> NetworkPolicy { - match requested { - NetworkPolicy::AllowAll if !capabilities.network.allow_all => { - NetworkPolicy::ProviderDefault - } - other => other, - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use fabro_types::SandboxProviderKind; - use fabro_types::settings::run::{ - EnvironmentImageSettings, EnvironmentLifecycleSettings, EnvironmentNetworkSettings, - EnvironmentResourcesSettings, - }; - use fabro_types::settings::{Duration as SettingsDuration, Size}; - - use super::*; - - fn environment(kind: &str) -> RunEnvironmentSettings { - RunEnvironmentSettings { - id: kind.to_string(), - provider: SandboxProviderKind::try_new(kind).unwrap(), - cwd: None, - image: EnvironmentImageSettings::default(), - resources: EnvironmentResourcesSettings::default(), - network: EnvironmentNetworkSettings::default(), - lifecycle: EnvironmentLifecycleSettings::default(), - labels: HashMap::from([("team".to_string(), "platform".to_string())]), - env: HashMap::new(), - } - } - - fn run_id() -> RunId { - "01HY0000000000000000000000".parse().unwrap() - } - - #[test] - fn options_without_an_image_ask_for_a_managed_directory() { - let options = options_from_environment( - &environment("host"), - &RunCloneSettings::default(), - BTreeMap::from([("FOO".to_string(), "bar".to_string())]), - ) - .unwrap(); - assert!(options.image.is_none()); - assert!(options.dockerfile.is_none()); - assert_eq!(options.clone_depth, Some(100)); - assert!(!options.skip_clone); - assert!(options.auto_stop.is_none()); - - let spec = base_spec(&options, Some(&run_id())); - assert!(matches!(spec.source, SandboxSource::HostDirectory)); - assert!(spec.working_directory.is_none()); - assert_eq!( - spec.name.as_deref(), - Some("fabro-run-01HY0000000000000000000000") - ); - assert_eq!(spec.env.get("FOO").map(String::as_str), Some("bar")); - assert_eq!( - spec.labels.get("team").map(String::as_str), - Some("platform") - ); - assert!( - !spec.labels.contains_key("sh.fabro.managed"), - "ownership labels come from the scope, not the environment" - ); - assert!(matches!(spec.network, NetworkPolicy::AllowAll)); - } - - #[test] - fn options_with_an_image_map_resources_network_and_lifecycle() { - let mut settings = environment("e2b"); - settings.image.docker = Some("ubuntu:24.04".to_string()); - settings.resources.cpu = Some(2); - settings.resources.memory = Some(Size::from_bytes(4_000_000_000)); - settings.network.mode = EnvironmentNetworkMode::Block; - settings.lifecycle.auto_stop = Some(SettingsDuration::from_std(Duration::from_mins(45))); - let clone = RunCloneSettings { - enabled: false, - depth: 0, - }; - let options = options_from_environment(&settings, &clone, BTreeMap::new()).unwrap(); - assert_eq!(options.image.as_deref(), Some("ubuntu:24.04")); - assert!(options.skip_clone); - assert_eq!(options.clone_depth, None); - assert_eq!(options.memory_bytes, Some(4_000_000_000)); - assert_eq!(options.memory_mb(), Some(3815)); - assert_eq!(options.auto_stop, Some(Duration::from_mins(45))); - - let spec = base_spec(&options, None); - assert!(matches!( - &spec.source, - SandboxSource::Image { reference } if reference == "ubuntu:24.04" - )); - assert_eq!(spec.resources.cpu_cores, Some(2)); - assert_eq!(spec.resources.memory_mb, Some(3815)); - assert!(matches!(spec.network, NetworkPolicy::Block)); - assert!(spec.name.is_none()); - } - - #[test] - fn an_inline_dockerfile_becomes_the_source_and_a_path_is_rejected() { - let mut settings = environment("daytona"); - settings.image.dockerfile = Some(DockerfileSource::Inline("FROM ubuntu".to_string())); - let options = - options_from_environment(&settings, &RunCloneSettings::default(), BTreeMap::new()) - .unwrap(); - assert_eq!(options.dockerfile.as_deref(), Some("FROM ubuntu")); - assert!(matches!( - base_spec(&options, None).source, - SandboxSource::Dockerfile { content } if content == "FROM ubuntu" - )); - - settings.image.dockerfile = Some(DockerfileSource::Path { - path: "Dockerfile".to_string(), - }); - let error = - options_from_environment(&settings, &RunCloneSettings::default(), BTreeMap::new()) - .unwrap_err(); - assert!(error.to_string().contains("Dockerfile path"), "{error}"); - } - - #[test] - fn allow_all_falls_back_to_the_provider_default_without_network_control() { - let none = Capabilities::minimal(sandbox_driver::Isolation::None); - assert!(matches!( - supported_network(NetworkPolicy::AllowAll, &none), - NetworkPolicy::ProviderDefault - )); - assert!(matches!( - supported_network(NetworkPolicy::Block, &none), - NetworkPolicy::Block - )); - let mut full = Capabilities::minimal(sandbox_driver::Isolation::Container); - full.network.allow_all = true; - assert!(matches!( - supported_network(NetworkPolicy::AllowAll, &full), - NetworkPolicy::AllowAll - )); - } - - #[test] - fn local_working_directory_prefers_environment_cwd() { - let mut settings = environment("local"); - settings.cwd = Some("/srv/fabro/workspaces/team-a".to_string()); - let missing_source = Path::new("/path/that/should/not/exist"); - - let resolved = local_working_directory_from_environment(&settings, Some(missing_source)) - .expect("configured cwd should be accepted"); - - assert_eq!(resolved, PathBuf::from("/srv/fabro/workspaces/team-a")); - assert!(!missing_source.exists()); - } - - #[test] - fn local_working_directory_uses_existing_source_directory_without_cwd() { - let settings = environment("local"); - let dir = tempfile::tempdir().unwrap(); - - let resolved = local_working_directory_from_environment(&settings, Some(dir.path())) - .expect("existing source directory should be accepted"); - - assert_eq!(resolved, dir.path()); - } - - #[test] - fn local_working_directory_rejects_missing_source_directory_without_cwd() { - let settings = environment("local"); - let dir = tempfile::tempdir().unwrap(); - let missing = dir.path().join("client-only"); - - let err = local_working_directory_from_environment(&settings, Some(&missing)) - .expect_err("missing source directory without cwd should fail"); - - let message = err.to_string(); - assert!( - message.contains("environment.cwd") && message.contains("does not exist"), - "unexpected error: {message}" - ); - assert!(!missing.exists()); - } -} diff --git a/lib/components/fabro-sandbox/src/pebble_environment.rs b/lib/components/fabro-sandbox/src/pebble_environment.rs new file mode 100644 index 000000000..4bb0af0f4 --- /dev/null +++ b/lib/components/fabro-sandbox/src/pebble_environment.rs @@ -0,0 +1,373 @@ +//! [`RunSandbox`] as the [`Environment`] pebble's coding agent runs in. +//! +//! Pebble's tools speak the `Environment` contract; fabro's one sandbox type +//! speaks the sandbox driver's facets. This module is the mapping between the +//! two, and nothing else: every path resolves the way fabro resolves it, every +//! command runs through [`SandboxExec`](crate::SandboxExec) with fabro's +//! exec policy, and every failure keeps its driver cause. There is no adapter +//! struct; a run sandbox *is* an environment. +//! +//! Where the two contracts differ, pebble's wins here because the model reads +//! pebble's: a glob that pebble rejects is rejected before the driver sees it, +//! a directory listing is in tree order, and a command with no retention cap +//! still drains under the driver's default buffer rather than without bound. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use pebble_coding_agent::environment::support::{capture_stats, tree_order, validate_glob}; +use pebble_coding_agent::environment::{ + DirEntry, EnvResult, Environment, EnvironmentError, EnvironmentErrorKind, ExecOutcome, + ExecOutputSink, ExecOutputStream, ExecRequest, ExecResult, GrepOptions, +}; +use sandbox_driver::{ExecControls, ExecSpec, FileKind, OutputSink, OutputStream}; + +use crate::driver_sandbox::RunSandbox; +use crate::exec::{ExecResultExt as _, command_termination, program_exit_code}; +use crate::sandbox; + +#[async_trait] +impl Environment for RunSandbox { + fn working_directory(&self) -> &str { + Self::working_directory(self) + } + + fn platform(&self) -> &str { + Self::platform(self) + } + + fn os_version(&self) -> String { + Self::os_version(self) + } + + async fn read_file_bytes(&self, path: &str) -> EnvResult> { + Self::read_file_bytes(self, path) + .await + .map_err(|error| environment_error(&format!("Failed to read {path}"), error)) + } + + async fn write_file(&self, path: &str, content: &str) -> EnvResult<()> { + Self::write_file(self, path, content) + .await + .map_err(|error| environment_error(&format!("Failed to write {path}"), error)) + } + + async fn rename_file(&self, source: &str, destination: &str) -> EnvResult<()> { + let resolved_source = self.resolve_for_environment(source); + let resolved_destination = self.resolve_for_environment(destination); + if !Self::file_exists(self, source) + .await + .map_err(|error| environment_error(&format!("Failed to stat {source}"), error))? + { + return Err(EnvironmentError::new( + EnvironmentErrorKind::NotFound, + format!("Failed to move {source}: file does not exist"), + )); + } + // The same path spelled twice is a move to itself, which must leave + // the file where it is. Aliases the sandbox's own filesystem would + // resolve (a symlinked parent, a hard link) are not checked: fabro has + // no remote `realpath`, and a driver `mv a a` is a no-op anyway. + if normalize(&resolved_source) == normalize(&resolved_destination) { + return Ok(()); + } + let handle = self + .handle() + .map_err(|error| environment_error("Sandbox is not initialized", error))?; + // The destination's parent is created first, and a parent that is a + // file fails here, before anything has moved, so the source stays + // intact as the contract requires. + if let Some(parent) = parent_directory(&resolved_destination) { + handle.fs().create_dir(parent).await.map_err(|error| { + environment_error( + &format!("Failed to create the parent directory of {destination}"), + crate::Error::from(error), + ) + })?; + } + handle + .fs() + .rename(&resolved_source, &resolved_destination) + .await + .map_err(|error| { + environment_error( + &format!("Failed to move {source} to {destination}"), + crate::Error::from(error), + ) + }) + } + + async fn delete_file(&self, path: &str) -> EnvResult<()> { + // The driver's delete is idempotent; pebble's is a `remove_file`, which + // reports a path that is not there. + if !Self::file_exists(self, path) + .await + .map_err(|error| environment_error(&format!("Failed to stat {path}"), error))? + { + return Err(EnvironmentError::new( + EnvironmentErrorKind::NotFound, + format!("Failed to delete {path}: file does not exist"), + )); + } + Self::delete_file(self, path) + .await + .map_err(|error| environment_error(&format!("Failed to delete {path}"), error)) + } + + async fn file_exists(&self, path: &str) -> EnvResult { + Self::file_exists(self, path) + .await + .map_err(|error| environment_error(&format!("Failed to stat {path}"), error)) + } + + async fn list_directory(&self, path: &str, depth: Option) -> EnvResult> { + let mut entries: Vec = Self::list_directory(self, path, depth) + .await + .map_err(|error| environment_error(&format!("Failed to list {path}"), error))? + .into_iter() + .map(|entry| DirEntry { + is_dir: entry.kind == FileKind::Directory, + size: (entry.kind == FileKind::File) + .then_some(entry.size) + .flatten(), + name: entry.path, + }) + .collect(); + // The driver lists in flat lexicographic order of the whole relative + // path, where `foo-bar` sorts between `foo` and `foo/x`. Pebble lists + // in tree order, and says how. + tree_order(&mut entries); + Ok(entries) + } + + async fn grep( + &self, + pattern: &str, + path: &str, + options: &GrepOptions, + ) -> EnvResult> { + let mut driver_options = sandbox_driver::GrepOptions::default(); + driver_options.case_insensitive = options.case_insensitive; + driver_options.max_matches = options.max_results; + driver_options.include = options.glob_filter.clone(); + let matches = Self::grep(self, pattern, path, &driver_options) + .await + .map_err(|error| environment_error("Failed to search file contents", error))?; + Ok(matches + .into_iter() + .map(|found| format!("{}:{}:{}", found.path, found.line_number, found.line)) + .collect()) + } + + async fn glob(&self, pattern: &str, path: Option<&str>) -> EnvResult> { + // Validated by pebble's own grammar before the driver sees the + // pattern, so the reason reaches the model in pebble's words and the + // patterns pebble rejects are rejected even where fabro's glob would + // accept them. + validate_glob(pattern)?; + Self::glob(self, pattern, path) + .await + .map_err(|error| environment_error("Failed to match files", error)) + } + + async fn exec(&self, request: ExecRequest<'_>) -> EnvResult { + let ExecRequest { + command, + timeout_ms, + working_dir, + env_vars, + cancel_token, + output_bytes_cap, + output_sink, + } = request; + let mut spec = ExecSpec::bash(command).no_timeout(); + if let Some(timeout_ms) = timeout_ms { + spec = spec.timeout(Duration::from_millis(timeout_ms)); + } + if let Some(dir) = working_dir { + spec = spec.working_dir(dir); + } + for (key, value) in env_vars.into_iter().flatten() { + spec = spec.env_var(key, value); + } + let controls = ExecControls { + term: cancel_token, + sink: output_sink.map(adapt_output_sink), + // `None` asks pebble for no cap at all. Fabro's exec policy fills + // its default buffer when the cap is unset, so a command with no + // cap drains under that default rather than without bound; the + // capture counts still say what was dropped. + retained_output_limit: output_bytes_cap, + ..ExecControls::default() + }; + let streaming = self + .exec_command_streaming(spec, controls) + .await + .map_err(|error| { + let kind = match error.driver() { + Some(sandbox_driver::Error::Transport(_)) => EnvironmentErrorKind::Io, + Some(sandbox_driver::Error::Unsupported { .. }) => { + EnvironmentErrorKind::Unsupported + } + _ => EnvironmentErrorKind::Spawn, + }; + EnvironmentError::with_source(kind, "Failed to run the command", error) + })?; + let result = streaming.result; + Ok(ExecOutcome { + result: ExecResult { + stdout: result.stdout_lossy(), + stderr: result.stderr_lossy(), + exit_code: program_exit_code(result.termination, result.exit_code), + termination: command_termination(result.termination), + duration_ms: result.duration_ms(), + }, + streams_separated: streaming.streams_separated, + stdout_capture: capture_stats( + streaming.stdout_capture.observed_bytes, + output_bytes_cap, + ), + stderr_capture: capture_stats( + streaming.stderr_capture.observed_bytes, + output_bytes_cap, + ), + }) + } +} + +impl RunSandbox { + /// A caller path as the driver will see it: fabro's working directory + /// applied where fabro applies it, and nothing more. + fn resolve_for_environment(&self, path: &str) -> String { + sandbox::resolve_path(path, Self::working_directory(self)) + } +} + +/// Pebble's glob grammar, beyond what fabro's glob already rejects. +/// +/// A path with its redundant separators and `.` segments removed, for +/// deciding whether two spellings name the same file. +fn normalize(path: &str) -> String { + let absolute = path.starts_with('/'); + let joined = path + .split('/') + .filter(|segment| !segment.is_empty() && *segment != ".") + .collect::>() + .join("/"); + if absolute { + format!("/{joined}") + } else { + joined + } +} + +/// The directory a path is in, when the path names one. +fn parent_directory(path: &str) -> Option<&str> { + let trimmed = path.trim_end_matches('/'); + let (parent, _) = trimmed.rsplit_once('/')?; + if parent.is_empty() { + return Some("/"); + } + Some(parent) +} + +/// Feeds the driver's asynchronous chunk callback into pebble's synchronous +/// sink. +fn adapt_output_sink(sink: ExecOutputSink) -> OutputSink { + Arc::new(move |stream, chunk: Vec| { + let stream = match stream { + OutputStream::Stdout => ExecOutputStream::Stdout, + OutputStream::Stderr => ExecOutputStream::Stderr, + }; + sink(stream, &chunk); + Box::pin(async { Ok(()) }) + }) +} + +/// A sandbox failure as pebble classifies it, keeping the driver cause. +fn environment_error(message: &str, error: crate::Error) -> EnvironmentError { + let kind = match error.driver() { + Some(sandbox_driver::Error::NotFound { .. }) => EnvironmentErrorKind::NotFound, + Some(sandbox_driver::Error::Unsupported { .. }) => EnvironmentErrorKind::Unsupported, + _ => EnvironmentErrorKind::Io, + }; + EnvironmentError::with_source(kind, message, error) +} + +#[cfg(test)] +mod tests { + use pebble_coding_agent::test_support::EnvironmentContract; + + use super::*; + use crate::local_sandbox; + + /// The run sandbox over the driver's Host provider, in a directory that + /// goes away with the test. + async fn host_environment() -> (tempfile::TempDir, RunSandbox) { + let directory = tempfile::tempdir().expect("a temporary directory"); + let sandbox = local_sandbox(directory.path().to_path_buf()) + .await + .expect("a local sandbox"); + (directory, sandbox) + } + + #[tokio::test] + async fn host_files_satisfy_pebbles_environment_contract() { + let (_directory, sandbox) = host_environment().await; + EnvironmentContract::new(&sandbox, "contract") + .verify_files() + .await + .expect("file contract"); + } + + #[tokio::test] + async fn host_search_satisfies_pebbles_environment_contract() { + let (_directory, sandbox) = host_environment().await; + EnvironmentContract::new(&sandbox, "contract") + .verify_search() + .await + .expect("search contract"); + } + + #[tokio::test] + async fn host_commands_satisfy_pebbles_environment_contract() { + let (_directory, sandbox) = host_environment().await; + EnvironmentContract::new(&sandbox, "contract") + .verify_commands() + .await + .expect("command contract"); + } + + #[tokio::test] + async fn a_directory_listing_is_in_tree_order() { + let (directory, sandbox) = host_environment().await; + for name in ["foo/x.txt", "foo-bar/y.txt", "foo.txt"] { + Environment::write_file(&sandbox, name, "content") + .await + .expect("fixture"); + } + let names: Vec = Environment::list_directory(&sandbox, ".", Some(2)) + .await + .expect("listing") + .into_iter() + .map(|entry| entry.name) + .collect(); + assert_eq!(names, [ + "foo", + "foo/x.txt", + "foo-bar", + "foo-bar/y.txt", + "foo.txt" + ]); + drop(directory); + } + + #[test] + fn a_path_spelled_two_ways_is_one_path() { + assert_eq!(normalize("/work//a/./b.txt"), "/work/a/b.txt"); + assert_eq!(parent_directory("/work/a/b.txt"), Some("/work/a")); + assert_eq!(parent_directory("/b.txt"), Some("/")); + assert_eq!(parent_directory("b.txt"), None); + } +} diff --git a/lib/components/fabro-sandbox/src/provider.rs b/lib/components/fabro-sandbox/src/provider.rs index d32e5b4f1..9ad32d28e 100644 --- a/lib/components/fabro-sandbox/src/provider.rs +++ b/lib/components/fabro-sandbox/src/provider.rs @@ -1,47 +1,116 @@ -pub mod driver; +//! Fabro's inventory of the sandboxes it manages, across the providers a +//! server has configured. +//! +//! Every entry is a sandbox-driver provider narrowed by fabro's ownership +//! labels, so a listing shows only the sandboxes fabro created and an +//! attach to anything else is refused. A provider connects on first use: +//! the inventory is assembled synchronously at startup, and a provider that +//! is down surfaces as a lookup error rather than a startup failure. The +//! `local` kind has an entry too, so a caller can ask whether the kind is +//! ready, but its sandboxes are directories the run record names and there +//! is nothing to list. use std::sync::Arc; -use async_trait::async_trait; +use fabro_types::settings::server::ServerSandboxProviderSettings; use fabro_types::{ SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxProviderKind, SandboxProviderLookupError, }; use fabro_util::error::collect_chain; use futures::future::join_all; +use sandbox_driver::{ + Error as DriverError, OwnedProvider, SandboxFilter, SandboxId, + SandboxProvider as DriverProvider, SandboxState, +}; +use tokio::sync::OnceCell; -#[async_trait] -pub trait SandboxProvider: Send + Sync { - fn kind(&self) -> SandboxProviderKind; - - async fn list(&self) -> crate::Result>; - async fn get(&self, id: &str) -> crate::Result>; - async fn delete(&self, id: &str) -> crate::Result<()>; -} +use crate::driver::{ConnectedProvider, ProviderConnectOptions, connect_provider}; +use crate::managed_labels; +/// The sandboxes fabro manages, by provider. #[derive(Clone, Default)] -pub struct SandboxProviderRegistry { - providers: Vec>, +pub struct SandboxInventory { + entries: Vec>, } -impl SandboxProviderRegistry { - pub fn new(providers: Vec>) -> Self { - Self { providers } - } +struct InventoryEntry { + kind: SandboxProviderKind, + connection: Connection, +} +enum Connection { + /// Sandboxes on this host are directories the run record names; + /// there is nothing to list. + HostDirectories, + Connected(Arc), + /// Connected through [`connect_provider`] on first use. + Lazy(Box), +} + +struct LazyConnection { + settings: ServerSandboxProviderSettings, + options: ProviderConnectOptions, + provider: OnceCell>, +} + +impl SandboxInventory { + #[must_use] pub fn empty() -> Self { Self::default() } - pub fn providers(&self) -> &[Arc] { - &self.providers + /// A kind whose sandboxes are directories on this host: ready to run, + /// nothing to list. + #[must_use] + pub fn with_host_directories(self, kind: SandboxProviderKind) -> Self { + self.with_entry(kind, Connection::HostDirectories) + } + + /// A provider already connected, tagged with the kind fabro persists + /// for it. + #[must_use] + pub fn with_connected(self, connected: ConnectedProvider) -> Self { + self.with_entry( + connected.kind, + Connection::Connected(owned(connected.provider)), + ) + } + + /// A provider connected through [`connect_provider`] on first use. + #[must_use] + pub fn with_lazy( + self, + kind: SandboxProviderKind, + settings: ServerSandboxProviderSettings, + options: ProviderConnectOptions, + ) -> Self { + self.with_entry( + kind, + Connection::Lazy(Box::new(LazyConnection { + settings, + options, + provider: OnceCell::new(), + })), + ) + } + + fn with_entry(mut self, kind: SandboxProviderKind, connection: Connection) -> Self { + self.entries + .push(Arc::new(InventoryEntry { kind, connection })); + self + } + + /// The provider kinds this inventory covers. + pub fn kinds(&self) -> impl Iterator { + self.entries.iter().map(|entry| &entry.kind) } pub async fn list_managed(&self) -> SandboxListResponse { let results = join_all( - self.providers + self.entries .iter() - .map(|provider| async move { (provider.kind(), provider.list().await) }), + .map(|entry| async move { (&entry.kind, entry.list().await) }), ) .await; @@ -50,7 +119,7 @@ impl SandboxProviderRegistry { for (kind, result) in results { match result { Ok(mut sandboxes) => data.append(&mut sandboxes), - Err(err) => provider_errors.push(provider_error(kind, &err)), + Err(err) => provider_errors.push(provider_error(kind.clone(), &err)), } } @@ -65,9 +134,9 @@ impl SandboxProviderRegistry { id: &str, ) -> Result { let results = join_all( - self.providers + self.entries .iter() - .map(|provider| async move { (provider.kind(), provider.get(id).await) }), + .map(|entry| async move { (&entry.kind, entry.get(id).await) }), ) .await; @@ -77,7 +146,7 @@ impl SandboxProviderRegistry { match result { Ok(Some(sandbox)) => matches.push(sandbox), Ok(None) => {} - Err(err) => provider_errors.push(provider_error(kind, &err)), + Err(err) => provider_errors.push(provider_error(kind.clone(), &err)), } } @@ -101,6 +170,94 @@ impl SandboxProviderRegistry { } } +impl InventoryEntry { + /// The provider narrowed to fabro's sandboxes, connected on first use; + /// `None` when the kind has nothing to list. + async fn provider(&self) -> crate::Result>> { + match &self.connection { + Connection::HostDirectories => Ok(None), + Connection::Connected(provider) => Ok(Some(provider)), + Connection::Lazy(lazy) => lazy + .provider + .get_or_try_init(|| async { + connect_provider(&self.kind, &lazy.settings, &lazy.options) + .await + .map(|connected| owned(connected.provider)) + .map_err(|error| { + crate::Error::context( + format!("Failed to connect to the {} provider", self.kind), + error, + ) + }) + }) + .await + .map(Some), + } + } + + async fn list(&self) -> crate::Result> { + let Some(provider) = self.provider().await? else { + return Ok(Vec::new()); + }; + let statuses = provider + .list(&SandboxFilter::default()) + .await + .map_err(|error| { + crate::Error::context(format!("Failed to list {} sandboxes", self.kind), error) + })?; + Ok(statuses + .into_iter() + .map(|status| SandboxInfo { + provider: self.kind.clone(), + status, + }) + .collect()) + } + + async fn get(&self, id: &str) -> crate::Result> { + let Some(provider) = self.provider().await? else { + return Ok(None); + }; + // An id the driver cannot even name is not one of ours. + let Ok(sandbox_id) = SandboxId::try_new(id) else { + return Ok(None); + }; + let handle = match provider.attach(&sandbox_id, None).await { + Ok(handle) => handle, + // Unknown to the provider, or not fabro's: neither is in the + // inventory. + Err(DriverError::NotFound { .. } | DriverError::NotOwned { .. }) => return Ok(None), + Err(error) => { + return Err(crate::Error::context( + format!("Failed to look up {} sandbox '{id}'", self.kind), + error, + )); + } + }; + let status = handle.describe().await.map_err(|error| { + crate::Error::context( + format!("Failed to describe {} sandbox '{id}'", self.kind), + error, + ) + })?; + if status.state == SandboxState::Deleted { + return Ok(None); + } + Ok(Some(SandboxInfo { + provider: self.kind.clone(), + status, + })) + } +} + +/// The provider narrowed to fabro's sandboxes. +fn owned(provider: Arc) -> Arc { + Arc::new(OwnedProvider::new( + provider, + managed_labels::ownership(None), + )) +} + #[derive(Debug, thiserror::Error)] pub enum SandboxLookupError { #[error("sandbox '{id}' was not found by any configured provider")] @@ -117,28 +274,6 @@ pub enum SandboxLookupError { }, } -#[derive(Debug, Clone, Copy, Default)] -pub struct LocalSandboxProvider; - -#[async_trait] -impl SandboxProvider for LocalSandboxProvider { - fn kind(&self) -> SandboxProviderKind { - SandboxProviderKind::LOCAL - } - - async fn list(&self) -> crate::Result> { - Ok(Vec::new()) - } - - async fn get(&self, _id: &str) -> crate::Result> { - Ok(None) - } - - async fn delete(&self, _id: &str) -> crate::Result<()> { - Ok(()) - } -} - fn provider_error( provider: SandboxProviderKind, err: &(dyn std::error::Error + 'static), @@ -151,170 +286,177 @@ fn provider_error( #[cfg(test)] mod tests { + use fabro_types::settings::server::SandboxPluginSettings; + use sandbox_driver::SandboxState; + use super::*; use crate::test_support::{ - FakeGet, FakeList, FakeSandboxProvider, fake_registry, fake_sandbox_info, + ScriptedSandbox, managed_scripted_sandbox, scripted_inventory_provider, }; + fn kind(name: &str) -> SandboxProviderKind { + SandboxProviderKind::try_new(name).expect("valid kind") + } + + fn provider(kind: SandboxProviderKind, ids: &[&str]) -> ConnectedProvider { + scripted_inventory_provider( + kind, + ids.iter().map(|id| managed_scripted_sandbox(id)).collect(), + ) + } + + /// A plugin kind whose executable does not exist, so every lookup fails + /// to connect. + fn unreachable_plugin(inventory: SandboxInventory, name: &str) -> SandboxInventory { + let settings = ServerSandboxProviderSettings { + enabled: true, + plugin: Some(SandboxPluginSettings { + path: Some(format!("/nonexistent/fabro-sandbox-{name}")), + dev: true, + ..SandboxPluginSettings::default() + }), + }; + inventory.with_lazy(kind(name), settings, ProviderConnectOptions::default()) + } + #[tokio::test] - async fn list_returns_aggregate_data_from_successful_providers() { - let docker = fake_sandbox_info(SandboxProviderKind::DOCKER, "docker-1"); - let daytona = fake_sandbox_info(SandboxProviderKind::DAYTONA, "daytona-1"); - let registry = fake_registry(vec![ - FakeSandboxProvider::new( - SandboxProviderKind::DOCKER, - FakeList::Ok(vec![docker.clone()]), - FakeGet::Missing, - ), - FakeSandboxProvider::new( - SandboxProviderKind::DAYTONA, - FakeList::Ok(vec![daytona.clone()]), - FakeGet::Missing, - ), + async fn list_aggregates_fabro_owned_sandboxes_across_providers() { + let foreign = Arc::new( + ScriptedSandbox::with_id_and_working_dir("someone-elses", "/work") + .state(SandboxState::Running), + ); + let docker = scripted_inventory_provider(SandboxProviderKind::DOCKER, vec![ + managed_scripted_sandbox("docker-1"), + foreign, ]); + let inventory = SandboxInventory::empty() + .with_host_directories(SandboxProviderKind::LOCAL) + .with_connected(docker) + .with_connected(provider(SandboxProviderKind::DAYTONA, &["daytona-1"])); - let response = registry.list_managed().await; + let response = inventory.list_managed().await; - assert_eq!(response.data, vec![docker, daytona]); + let mut ids: Vec<_> = response.data.iter().map(|s| s.status.id.as_str()).collect(); + ids.sort_unstable(); + assert_eq!(ids, ["daytona-1", "docker-1"]); assert!(response.meta.provider_errors.is_empty()); - } - - #[tokio::test] - async fn list_includes_provider_error_metadata_when_one_provider_fails() { - let docker = fake_sandbox_info(SandboxProviderKind::DOCKER, "docker-1"); - let registry = fake_registry(vec![ - FakeSandboxProvider::new( - SandboxProviderKind::DOCKER, - FakeList::Ok(vec![docker.clone()]), - FakeGet::Missing, - ), - FakeSandboxProvider::new( - SandboxProviderKind::DAYTONA, - FakeList::Err("daytona unavailable"), - FakeGet::Missing, - ), - ]); - - let response = registry.list_managed().await; - - assert_eq!(response.data, vec![docker]); - assert_eq!(response.meta.provider_errors, vec![ - SandboxProviderLookupError { - provider: SandboxProviderKind::DAYTONA, - message: "daytona unavailable".to_string(), - } + let kinds: Vec<_> = inventory.kinds().cloned().collect(); + assert_eq!(kinds, [ + SandboxProviderKind::LOCAL, + SandboxProviderKind::DOCKER, + SandboxProviderKind::DAYTONA ]); } #[tokio::test] - async fn get_returns_one_matching_sandbox() { - let docker = fake_sandbox_info(SandboxProviderKind::DOCKER, "same-id"); - let registry = fake_registry(vec![ - FakeSandboxProvider::new( - SandboxProviderKind::DOCKER, - FakeList::Ok(Vec::new()), - FakeGet::Found(Box::new(docker.clone())), - ), - FakeSandboxProvider::new( - SandboxProviderKind::DAYTONA, - FakeList::Ok(Vec::new()), - FakeGet::Missing, - ), - ]); + async fn list_reports_a_provider_that_cannot_connect_beside_the_others() { + let inventory = unreachable_plugin( + SandboxInventory::empty() + .with_connected(provider(SandboxProviderKind::DOCKER, &["docker-1"])), + "e2b", + ); - assert_eq!( - registry.get_managed_by_native_id("same-id").await.unwrap(), - docker + let response = inventory.list_managed().await; + + assert_eq!(response.data.len(), 1); + assert_eq!(response.meta.provider_errors.len(), 1); + assert_eq!(response.meta.provider_errors[0].provider, kind("e2b")); + assert!( + response.meta.provider_errors[0] + .message + .contains("Failed to connect to the e2b provider"), + "{}", + response.meta.provider_errors[0].message ); } #[tokio::test] - async fn get_returns_not_found_when_all_providers_miss() { - let registry = fake_registry(vec![ - FakeSandboxProvider::new( - SandboxProviderKind::DOCKER, - FakeList::Ok(Vec::new()), - FakeGet::Missing, - ), - FakeSandboxProvider::new( - SandboxProviderKind::DAYTONA, - FakeList::Ok(Vec::new()), - FakeGet::Missing, - ), - ]); + async fn get_finds_one_sandbox_by_native_id() { + let inventory = SandboxInventory::empty() + .with_connected(provider(SandboxProviderKind::DOCKER, &[])) + .with_connected(provider(SandboxProviderKind::DAYTONA, &["native-id"])); - let err = registry + let sandbox = inventory + .get_managed_by_native_id("native-id") + .await + .expect("one provider matches"); + + assert_eq!(sandbox.status.id.as_str(), "native-id"); + assert_eq!(sandbox.provider, SandboxProviderKind::DAYTONA); + } + + #[tokio::test] + async fn get_reports_not_found_when_every_provider_misses() { + let inventory = SandboxInventory::empty() + .with_host_directories(SandboxProviderKind::LOCAL) + .with_connected(provider(SandboxProviderKind::DOCKER, &[])); + + let error = inventory .get_managed_by_native_id("missing") .await - .unwrap_err(); + .expect_err("nothing matches"); - assert!(matches!(err, SandboxLookupError::NotFound { id } if id == "missing")); + assert!(matches!(error, SandboxLookupError::NotFound { id } if id == "missing")); } #[tokio::test] - async fn get_returns_conflict_when_two_providers_match() { - let registry = fake_registry(vec![ - FakeSandboxProvider::new( - SandboxProviderKind::DOCKER, - FakeList::Ok(Vec::new()), - FakeGet::Found(Box::new(fake_sandbox_info( - SandboxProviderKind::DOCKER, - "same-id", - ))), - ), - FakeSandboxProvider::new( - SandboxProviderKind::DAYTONA, - FakeList::Ok(Vec::new()), - FakeGet::Found(Box::new(fake_sandbox_info( - SandboxProviderKind::DAYTONA, - "same-id", - ))), - ), - ]); + async fn get_reports_a_conflict_when_two_providers_match() { + let inventory = SandboxInventory::empty() + .with_connected(provider(SandboxProviderKind::DOCKER, &["same-id"])) + .with_connected(provider(SandboxProviderKind::DAYTONA, &["same-id"])); - let err = registry + let error = inventory .get_managed_by_native_id("same-id") .await - .unwrap_err(); + .expect_err("two providers match"); - assert!(matches!( - err, - SandboxLookupError::Conflict { id, providers } - if id == "same-id" - && providers == vec![SandboxProviderKind::DOCKER, SandboxProviderKind::DAYTONA] - )); + let SandboxLookupError::Conflict { providers, .. } = error else { + panic!("expected a conflict, got {error:?}"); + }; + assert_eq!(providers, [ + SandboxProviderKind::DOCKER, + SandboxProviderKind::DAYTONA + ]); } #[tokio::test] - async fn get_returns_provider_unavailable_when_no_match_and_one_provider_fails() { - let registry = fake_registry(vec![ - FakeSandboxProvider::new( - SandboxProviderKind::DOCKER, - FakeList::Ok(Vec::new()), - FakeGet::Missing, - ), - FakeSandboxProvider::new( - SandboxProviderKind::DAYTONA, - FakeList::Ok(Vec::new()), - FakeGet::Err("daytona unavailable"), - ), - ]); + async fn get_is_unavailable_when_no_match_and_a_provider_failed() { + let inventory = unreachable_plugin( + SandboxInventory::empty().with_connected(provider(SandboxProviderKind::DOCKER, &[])), + "e2b", + ); - let err = registry + let error = inventory .get_managed_by_native_id("maybe-missing") .await - .unwrap_err(); + .expect_err("the failed provider may have held it"); - assert!(matches!( - err, - SandboxLookupError::ProviderUnavailable { - id, - provider_errors - } if id == "maybe-missing" - && provider_errors == vec![SandboxProviderLookupError { - provider: SandboxProviderKind::DAYTONA, - message: "daytona unavailable".to_string(), - }] + let SandboxLookupError::ProviderUnavailable { + provider_errors, .. + } = error + else { + panic!("expected provider unavailable, got {error:?}"); + }; + assert_eq!(provider_errors.len(), 1); + assert_eq!(provider_errors[0].provider, kind("e2b")); + } + + #[tokio::test] + async fn get_ignores_a_sandbox_without_the_managed_label() { + let foreign = Arc::new( + ScriptedSandbox::with_id_and_working_dir("foreign", "/work") + .state(SandboxState::Running), + ); + let inventory = SandboxInventory::empty().with_connected(scripted_inventory_provider( + SandboxProviderKind::DOCKER, + vec![foreign], )); + + let error = inventory + .get_managed_by_native_id("foreign") + .await + .expect_err("a foreign sandbox is not in the inventory"); + + assert!(matches!(error, SandboxLookupError::NotFound { .. })); } } diff --git a/lib/components/fabro-sandbox/src/provider/driver.rs b/lib/components/fabro-sandbox/src/provider/driver.rs deleted file mode 100644 index 069469b77..000000000 --- a/lib/components/fabro-sandbox/src/provider/driver.rs +++ /dev/null @@ -1,234 +0,0 @@ -//! Fabro-managed inventory over a sandbox-driver provider. -//! -//! Lists and looks up the sandboxes fabro created, identified by fabro's -//! own `sh.fabro.managed` label. The driver marks every sandbox it creates -//! with its own label too, but that covers every application on the same -//! daemon or account; the provider is connected through the driver's -//! ownership scope, which lists only fabro's sandboxes and refuses to -//! attach to or delete any other. - -use std::sync::Arc; - -use async_trait::async_trait; -use fabro_types::settings::server::ServerSandboxProviderSettings; -use fabro_types::{SandboxInfo, SandboxProviderKind}; -use sandbox_driver::{ - Error as DriverError, OwnedProvider, SandboxFilter, SandboxId, - SandboxProvider as DriverProvider, -}; -use tokio::sync::OnceCell; - -use super::SandboxProvider; -use crate::driver::{ConnectedProvider, ProviderConnectOptions, connect_provider}; -use crate::{details, managed_labels}; - -/// How the driver provider behind the inventory is obtained. -enum Connection { - Connected(Arc), - /// Connected on first use, so a registry can be assembled synchronously - /// and a provider that is down surfaces as a lookup error rather than a - /// startup failure. - Lazy(Box), -} - -struct LazyConnection { - settings: ServerSandboxProviderSettings, - options: ProviderConnectOptions, - provider: OnceCell>, -} - -pub struct DriverInventoryProvider { - kind: SandboxProviderKind, - connection: Connection, -} - -impl DriverInventoryProvider { - #[must_use] - pub fn new(connected: ConnectedProvider) -> Self { - Self { - kind: connected.kind, - connection: Connection::Connected(owned(connected.provider)), - } - } - - /// An inventory over a provider connected through - /// [`connect_provider`] on first use. - #[must_use] - pub fn lazy( - kind: SandboxProviderKind, - settings: ServerSandboxProviderSettings, - options: ProviderConnectOptions, - ) -> Self { - Self { - kind, - connection: Connection::Lazy(Box::new(LazyConnection { - settings, - options, - provider: OnceCell::new(), - })), - } - } - - async fn provider(&self) -> crate::Result<&Arc> { - match &self.connection { - Connection::Connected(provider) => Ok(provider), - Connection::Lazy(lazy) => { - lazy.provider - .get_or_try_init(|| async { - connect_provider(&self.kind, &lazy.settings, &lazy.options) - .await - .map(|connected| owned(connected.provider)) - .map_err(|error| { - crate::Error::context( - format!("Failed to connect to the {} provider", self.kind), - error, - ) - }) - }) - .await - } - } - } - - async fn describe_managed( - &self, - id: &str, - ) -> crate::Result> { - // An id the driver cannot even name is not one of ours. - let Ok(sandbox_id) = SandboxId::try_new(id) else { - return Ok(None); - }; - let handle = match self.provider().await?.attach(&sandbox_id, None).await { - Ok(handle) => handle, - // Unknown to the provider, or not fabro's: neither is in the - // inventory. - Err(DriverError::NotFound { .. } | DriverError::NotOwned { .. }) => return Ok(None), - Err(error) => { - return Err(crate::Error::context( - format!("Failed to look up {} sandbox '{id}'", self.kind), - error, - )); - } - }; - let status = handle.describe().await.map_err(|error| { - crate::Error::context( - format!("Failed to describe {} sandbox '{id}'", self.kind), - error, - ) - })?; - if status.state == sandbox_driver::SandboxState::Deleted { - return Ok(None); - } - Ok(Some(status)) - } -} - -/// The provider narrowed to fabro's sandboxes. -fn owned(provider: Arc) -> Arc { - Arc::new(OwnedProvider::new( - provider, - managed_labels::ownership(None), - )) -} - -#[async_trait] -impl SandboxProvider for DriverInventoryProvider { - fn kind(&self) -> SandboxProviderKind { - self.kind.clone() - } - - async fn list(&self) -> crate::Result> { - let statuses = self - .provider() - .await? - .list(&SandboxFilter::default()) - .await - .map_err(|error| { - crate::Error::context(format!("Failed to list {} sandboxes", self.kind), error) - })?; - Ok(statuses - .iter() - .map(|status| details::info_from_status(&self.kind, status)) - .collect()) - } - - async fn get(&self, id: &str) -> crate::Result> { - Ok(self - .describe_managed(id) - .await? - .map(|status| details::info_from_status(&self.kind, &status))) - } - - async fn delete(&self, id: &str) -> crate::Result<()> { - // Missing or already deleted is an idempotent success; the scope - // refuses a sandbox that is not fabro's, which must never be - // deleted here. - let Ok(sandbox_id) = SandboxId::try_new(id) else { - return Ok(()); - }; - match self.provider().await?.delete(&sandbox_id, None).await { - Ok(()) => Ok(()), - Err(DriverError::NotOwned { .. }) => Err(crate::Error::message(format!( - "Refusing to delete {} sandbox '{id}' because it is missing label {}={}", - self.kind, - managed_labels::MANAGED_LABEL, - managed_labels::MANAGED_LABEL_VALUE - ))), - Err(error) => Err(crate::Error::context( - format!("Failed to delete {} sandbox '{id}'", self.kind), - error, - )), - } - } -} - -#[cfg(test)] -mod tests { - use sandbox_driver::{SandboxSource, SandboxSpec}; - use sandbox_driver_host::HostProvider; - - use super::*; - - fn inventory() -> (DriverInventoryProvider, Arc) { - let host = Arc::new(HostProvider::new()); - let provider = DriverInventoryProvider::new(ConnectedProvider { - kind: SandboxProviderKind::try_new("host").unwrap(), - provider: host.clone(), - }); - (provider, host) - } - - #[tokio::test] - async fn lists_and_deletes_only_fabro_managed_sandboxes() { - let (inventory, host) = inventory(); - let ours = host - .create( - &SandboxSpec::new(SandboxSource::HostDirectory) - .label(managed_labels::MANAGED_LABEL, "true"), - None, - ) - .await - .unwrap(); - let theirs = host - .create(&SandboxSpec::new(SandboxSource::HostDirectory), None) - .await - .unwrap(); - - let listed = inventory.list().await.unwrap(); - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].id, ours.id().as_str()); - assert_eq!(listed[0].provider.as_str(), "host"); - assert!(inventory.get(ours.id().as_str()).await.unwrap().is_some()); - assert!(inventory.get(theirs.id().as_str()).await.unwrap().is_none()); - - let refused = inventory.delete(theirs.id().as_str()).await.unwrap_err(); - assert!( - refused.to_string().contains("Refusing to delete"), - "{refused}" - ); - inventory.delete(ours.id().as_str()).await.unwrap(); - assert!(inventory.get(ours.id().as_str()).await.unwrap().is_none()); - inventory.delete(ours.id().as_str()).await.unwrap(); - inventory.delete("never-existed").await.unwrap(); - } -} diff --git a/lib/components/fabro-sandbox/src/provider_sandbox.rs b/lib/components/fabro-sandbox/src/provider_sandbox.rs index 65e02add6..a5011a1ad 100644 --- a/lib/components/fabro-sandbox/src/provider_sandbox.rs +++ b/lib/components/fabro-sandbox/src/provider_sandbox.rs @@ -1,96 +1,107 @@ //! Run sandboxes on any provider fabro can name: a bundled kind in process //! or a sandbox-driver plugin executable. //! -//! One path builds them all. The environment's [`SandboxOptions`] become -//! the driver spec once, the provider is connected through the single +//! One path builds them all. The environment's spec arrives built (see +//! [`crate::environment`]), the provider is connected through the single //! construction function, and a bundled provider adds only what its //! backend needs on top: Docker its fixed working directory and default -//! image, Daytona the snapshot it creates sandboxes from and its lifecycle -//! timers. A plugin gets the spec as is, laid out inside the working -//! directory the provider chooses. +//! image, Daytona its fixed working directory, default snapshot, and +//! lifecycle timers, the Host the designated directory it works in, +//! created when missing. A plugin gets the spec as is, trimmed to what it +//! can honor, laid out inside the working directory the provider chooses. +use std::path::PathBuf; use std::sync::Arc; use fabro_github::GitHubCredentials; use fabro_types::{BundledProvider, RunId, SandboxProviderKind}; -use sandbox_driver::{EventContext, OwnedProvider, SandboxId, SandboxProvider}; +use sandbox_driver::{ + EventContext, OwnedProvider, SandboxId, SandboxProvider, SandboxSource, + SandboxSpec as DriverSpec, +}; +use tokio::fs; use crate::driver::{ProviderAccess, connect_provider}; use crate::driver_sandbox::{LayoutSource, RepoWorkspace, RunSandbox}; -use crate::options::{self, SandboxOptions}; +use crate::environment::{self, CloneRequest}; +use crate::sandbox_spec::SandboxSpec; use crate::{daytona, docker, managed_labels}; /// A sandbox for a run on `kind`. The sandbox is created by `initialize`; /// construction validates the clone request and connects the provider, so -/// a bad spec, a missing credential, or a missing plugin executable fails -/// before any backend call. -#[expect( - clippy::too_many_arguments, - reason = "mirrors SandboxSpec::Provider; clone inputs are validated together" -)] +/// a bad request, a missing credential, or a missing plugin executable +/// fails before any backend call. pub async fn provider_sandbox( kind: SandboxProviderKind, access: &ProviderAccess, - options: SandboxOptions, + spec: DriverSpec, + clone: &CloneRequest, github_app: Option<&GitHubCredentials>, run_id: Option, - clone_origin_url: Option, - clone_branch: Option, - clone_tag: Option, - clone_commit_sha: Option, ) -> crate::Result { - let workspace = RepoWorkspace::plan( - layout_source(&kind), - options.skip_clone, - clone_origin_url.as_deref(), - clone_branch.as_deref(), - clone_tag.as_deref(), - clone_commit_sha.as_deref(), - options.clone_depth, - github_app, - )?; + let workspace = RepoWorkspace::plan(layout_source(&kind), clone, github_app)?; let provider = connect(&kind, access, run_id.as_ref()).await?; - let base = options::base_spec(&options, run_id.as_ref()); + let mut spec = spec; + if let Some(run_id) = &run_id { + spec = spec.name(environment::run_name(run_id)); + } Ok(match kind.bundled() { Some(BundledProvider::Docker) => { - let (spec, _image) = docker::overlay(base, &options); - RunSandbox::pending(kind, provider, spec, workspace) + RunSandbox::pending(kind, provider, docker::overlay(spec), workspace) } - Some(BundledProvider::Daytona) => { - let credentials = access - .daytona - .as_ref() - .ok_or_else(|| crate::Error::message(MISSING_DAYTONA_CREDENTIALS))?; - let plan = daytona::create_plan( - Arc::clone(&provider), - credentials.api_key.clone(), - base, - options, - run_id, - ); - RunSandbox::pending_with_plan(kind, provider, Box::new(plan), workspace) - } - Some(BundledProvider::Local) => { - return Err(crate::Error::message( - "local sandboxes are built from a working directory, not a provider spec", - )); - } - None => { - let mut spec = base; - spec.network = options::supported_network(spec.network, provider.capabilities()); + Some(BundledProvider::Daytona) => RunSandbox::pending( + kind, + provider, + daytona::overlay(spec, run_id.as_ref()), + workspace, + ), + Some(BundledProvider::Local) | None => { + if kind.is_local() { + designate_directory(&spec).await?; + } + let capabilities = provider.capabilities(); + spec.network = environment::supported_network(spec.network, capabilities); + spec.timers = environment::supported_timers(spec.timers, capabilities); RunSandbox::pending(kind, provider, spec, workspace) } }) } +/// The Host provider works in a designated directory in place and needs it +/// to exist. A run may point at a fresh scratch path, so the directory is +/// created before the provider sees the spec. +async fn designate_directory(spec: &DriverSpec) -> crate::Result<()> { + let Some(directory) = &spec.working_directory else { + return Ok(()); + }; + fs::create_dir_all(directory).await.map_err(|error| { + crate::Error::context( + format!("Failed to create working directory {directory}"), + error, + ) + }) +} + +/// A sandbox on this host at `working_directory`, ready to use: the `local` +/// kind, built through the provider path with default settings and +/// initialized. For the agent CLI and tests; a run builds its sandbox from +/// its [`SandboxSpec`] and initializes it itself. +pub async fn local_sandbox(working_directory: impl Into) -> crate::Result { + let spec = SandboxSpec::local(working_directory, ProviderAccess::default()); + let sandbox = + provider_sandbox(spec.kind, &spec.access, spec.spec, &spec.clone, None, None).await?; + sandbox.initialize().await?; + Ok(sandbox) +} + /// Reattach to a run's sandbox on `kind` by its persisted id. The driver /// reports the sandbox's lifecycle from here on through `events`. /// -/// The sandbox must carry fabro's managed label and, when a run id is -/// known, the matching run label: the provider shares its backend with -/// every other application, and fabro never operates on a sandbox it did -/// not create. The ownership scope the provider is connected through -/// refuses anything else. +/// On a shared backend the sandbox must carry fabro's managed label and, +/// when a run id is known, the matching run label: fabro never operates on +/// a sandbox it did not create, and the ownership scope the provider is +/// connected through refuses anything else. A local sandbox attaches by +/// the id the Host provider derives from its directory. pub async fn attach_provider_sandbox( kind: SandboxProviderKind, access: &ProviderAccess, @@ -117,24 +128,20 @@ pub async fn attach_provider_sandbox( working_directory, clone_origin_url, ); - let sandbox = RunSandbox::attached(kind.clone(), handle, workspace); - if kind.bundled() == Some(BundledProvider::Daytona) { - if let Some(snapshot) = status.source { - sandbox.set_snapshot(snapshot); - } + let sandbox = RunSandbox::attached(kind, handle, workspace); + if let Some(snapshot) = status.snapshot { + sandbox.set_snapshot(snapshot); } Ok(sandbox) } /// The image the run record names for a sandbox on `kind`: the /// environment's, or Docker's default when the environment names none. -pub(crate) fn recorded_image( - kind: &SandboxProviderKind, - options: &SandboxOptions, -) -> Option { - match kind.bundled() { - Some(BundledProvider::Docker) => Some(docker::effective_image(options)), - _ => options.image.clone(), +pub(crate) fn recorded_image(kind: &SandboxProviderKind, spec: &DriverSpec) -> Option { + match (kind.bundled(), &spec.source) { + (Some(BundledProvider::Docker), _) => Some(docker::effective_image(spec)), + (_, SandboxSource::Image { reference }) => Some(reference.clone()), + _ => None, } } @@ -179,6 +186,11 @@ async fn connect( .map_err(|error| { crate::Error::context(format!("Failed to connect to the {kind} provider"), error) })?; + // A local sandbox is a directory the caller designated; it carries no + // labels, and nothing else shares the host's directories with fabro. + if kind.bundled() == Some(BundledProvider::Local) { + return Ok(connected.provider); + } Ok(Arc::new(OwnedProvider::new( connected.provider, managed_labels::ownership(run_id), diff --git a/lib/components/fabro-sandbox/src/push_credentials.rs b/lib/components/fabro-sandbox/src/push_credentials.rs deleted file mode 100644 index 01ad446e6..000000000 --- a/lib/components/fabro-sandbox/src/push_credentials.rs +++ /dev/null @@ -1,629 +0,0 @@ -//! Shared push-credential state for clone-based sandbox providers. -//! -//! Docker and Daytona embed GitHub credentials into the cloned repository's -//! `origin` remote and refresh them before pushes. Both providers hold this -//! state so the compare → `set-url` → record sequence, the generation -//! tracking, and the refresh-error logging behave identically across -//! providers. The token cache itself sits below the providers, in -//! [`fabro_github::token_source::InstallationTokenSource`]. - -use std::future::Future; -use std::sync::Arc; - -use fabro_github::GitHubCredentials; -use fabro_github::token_source::{InstallationTokenSource, ResolvedToken, TokenSnapshot}; -use fabro_redact::DisplaySafeUrl; -pub use fabro_types::run_event::GitCredentialRefreshError as RefreshErrorKind; -use tokio::sync::{Mutex, MutexGuard}; - -use crate::redact; -use crate::sandbox::{RefreshOutcome, RemoteCredentialAction}; - -/// Build the shared installation-token source for a clone-based sandbox. -/// -/// Returns `None` when there are no managed credentials or no GitHub origin -/// to scope them to. Minted tokens carry the same `contents: write` -/// permission the clone token uses. -pub(crate) fn build_token_source( - github_app: Option<&GitHubCredentials>, - clone_origin_url: Option<&str>, -) -> crate::Result>> { - let Some(creds) = github_app else { - return Ok(None); - }; - let Some(origin_url) = clone_origin_url.filter(|url| !url.trim().is_empty()) else { - return Ok(None); - }; - let normalized = fabro_github::normalize_repo_origin_url(origin_url); - let Ok((owner, repo)) = fabro_github::parse_github_owner_repo(&normalized) else { - // Non-GitHub origins never clone in these providers, so there is no - // remote to keep credentials fresh for. - return Ok(None); - }; - InstallationTokenSource::for_repository( - creds, - owner, - repo, - serde_json::json!({ "contents": "write" }), - ) - .map(Some) - .map_err(|err| crate::Error::context_anyhow("Failed to build GitHub token source", err)) -} - -/// Push-credential state one provider instance tracks for its `origin` -/// remote. -pub(crate) struct PushCredentialState { - source: Option>, - /// Serializes compare → `set-url` → record. The token source's - /// single-flight ends before the sandbox exec, so without this lock a - /// refresh-ahead tick and a push could both see the old embedded - /// generation and race on `.git/config.lock`. Holds the last - /// successfully embedded token: its secret is already in the remote URL - /// inside the sandbox, so retaining it adds no exposure, and it is what - /// a push falls back to when a refresh fails. The tracked value is local - /// belief, not ground truth — agent code inside the sandbox can rewrite - /// `origin`. - embedded: Mutex>, -} - -impl PushCredentialState { - pub(crate) fn new(source: Option>) -> Self { - Self { - source, - embedded: Mutex::new(None), - } - } - - pub(crate) fn source(&self) -> Option<&Arc> { - self.source.as_ref() - } - - /// Record the token embedded in `origin` outside the refresh path — the - /// clone is the first operation to embed a token, and it seeds this - /// state so the first refresh compares against the clone token instead - /// of believing nothing was ever embedded. - pub(crate) async fn record_embedded(&self, token: ResolvedToken) { - *self.embedded.lock().await = Some(token); - } - - /// Refresh the credentials embedded in `origin`. - /// - /// Resolves through the shared source, skips the `set-url` exec when the - /// resolved generation is already embedded, and records the new - /// generation only after `set_url` succeeds. `set_url` receives the - /// authenticated URL to embed and runs under the embed lock. - pub(crate) async fn refresh( - &self, - origin_url: &str, - set_url: F, - ) -> crate::Result - where - F: FnOnce(DisplaySafeUrl) -> Fut, - Fut: Future>, - { - let Some(source) = &self.source else { - return Ok(RefreshOutcome::none()); - }; - let mut embedded = self.embedded.lock().await; - let resolved = match source.resolve().await { - Ok(resolved) => resolved, - Err(err) => { - // The refresh-error path is defined, not incidental: the push - // proceeds with the last embedded token, so log which one - // that is instead of losing the credential state. - if let Some(prev) = embedded.as_ref() { - tracing::warn!( - error = %format!("{err:#}"), - generation = prev.snapshot.generation, - provenance = %prev.snapshot.provenance, - token_age_ms = prev.snapshot.age_ms(), - "GitHub token refresh failed; origin keeps the last embedded credentials" - ); - } else { - tracing::warn!( - error = %format!("{err:#}"), - "GitHub token refresh failed and no credentials were ever embedded" - ); - } - return Err(crate::Error::context_anyhow( - "Failed to refresh push credentials", - err, - )); - } - }; - if embedded - .as_ref() - .is_some_and(|prev| prev.snapshot.generation == resolved.snapshot.generation) - { - return Ok(RefreshOutcome::unchanged(resolved.snapshot)); - } - let auth_url = fabro_github::embed_token_in_url(origin_url, resolved.token.expose()) - .map_err(|err| { - crate::Error::context_anyhow("Failed to build authenticated origin URL", err) - })?; - set_url(auth_url).await?; - let snapshot = resolved.snapshot; - *embedded = Some(resolved); - Ok(RefreshOutcome::embedded(snapshot)) - } -} - -/// What [`CredentialLease::ensure_embedded`] did for one push attempt. -#[derive(Debug, Clone, Copy)] -pub(crate) struct EnsureOutcome { - pub action: RemoteCredentialAction, - /// The token embedded in the remote right now — never an unembedded mint. - pub token: Option, - pub refresh_error: Option, -} - -/// Scoped pin of push credentials for one push operation. -/// -/// Holds the provider's embed mutex until dropped, so no other refresh can -/// re-embed mid-operation — a refresh-ahead tick crossing the cache margin -/// during a retrying push waits here instead of swapping the remote out from -/// under the pin. Internally retains up to two secrets: the last successfully -/// embedded token (the fallback) and the operation's resolved target, so both -/// drift re-embedding and the refresh-error fallback work. Only non-secret -/// snapshots leave the lease. -/// -/// A successful resolve happens at most once per operation and is never -/// replaced; the pin transitions to the target only through a successful -/// embed. The token source's refresh margin exceeds every push plan's elapsed -/// bound, so the pinned token always outlives the operation. -pub(crate) struct CredentialLease<'a> { - source: Option<&'a InstallationTokenSource>, - /// Embed-mutex guard: the last successfully embedded token. - embedded: MutexGuard<'a, Option>, - /// The operation's resolved target, including a cached fallback when a - /// refresh mint failed. - target: Option, - /// Skip an immediate duplicate resolve after lease acquisition already - /// failed. A later push attempt can retry after backoff. - defer_resolve_once: bool, -} - -impl PushCredentialState { - /// Acquire the push-credential lease for one push operation. - /// - /// Resolves the operation's target token up front. A failed refresh can - /// return a valid cached token; the first attempt uses it, and - /// [`CredentialLease::ensure_embedded`] retries the refresh after push - /// backoff. A resolve with no cached or embedded token fails acquisition. - pub(crate) async fn lease(&self) -> crate::Result> { - let embedded = self.embedded.lock().await; - let Some(source) = self.source.as_deref() else { - return Ok(CredentialLease { - source: None, - embedded, - target: None, - defer_resolve_once: false, - }); - }; - match source.resolve().await { - Ok(resolved) => { - let defer_resolve_once = resolved.refresh_failed; - Ok(CredentialLease { - source: Some(source), - embedded, - target: Some(resolved), - defer_resolve_once, - }) - } - Err(err) => { - if let Some(prev) = embedded.as_ref() { - tracing::warn!( - error = %format!("{err:#}"), - generation = prev.snapshot.generation, - provenance = %prev.snapshot.provenance, - token_age_ms = prev.snapshot.age_ms(), - "token resolve failed; push pins the last embedded credentials" - ); - Ok(CredentialLease { - source: Some(source), - embedded, - target: None, - defer_resolve_once: true, - }) - } else { - tracing::warn!( - error = %format!("{err:#}"), - "token resolve failed and no credentials were ever embedded" - ); - Err(crate::Error::message( - "Failed to refresh push credentials: token_mint_failed", - )) - } - } - } - } -} - -impl CredentialLease<'_> { - /// Non-secret description of the token embedded in the remote right now. - pub(crate) fn snapshot(&self) -> Option { - self.embedded.as_ref().map(|token| token.snapshot) - } - - /// Embed the pinned generation if the remote does not carry it. - /// - /// One call covers the initial embed, a deferred embed after an earlier - /// failure, and drift repair (`force` re-embeds even when the tracked - /// generation matches, for remotes rewritten inside the sandbox). While - /// the lease has no target, this retries the failed `resolve()` first — - /// retrying a failed resolve discards no fresh token, so it cannot - /// restart any replication clock. Refresh failures are recorded, never - /// propagated: the push proceeds with the last embedded token. - pub(crate) async fn ensure_embedded( - &mut self, - sandbox: &crate::RunSandbox, - origin_url: &str, - force: bool, - ) -> crate::Result { - let Some(source) = self.source else { - return Ok(EnsureOutcome { - action: RemoteCredentialAction::None, - token: None, - refresh_error: None, - }); - }; - let mut refresh_error = self.defer_resolve_once.then_some(RefreshErrorKind::Mint); - if self.defer_resolve_once { - self.defer_resolve_once = false; - } else if self - .target - .as_ref() - .is_none_or(|resolved| resolved.refresh_failed) - { - match source.resolve().await { - Ok(resolved) => { - refresh_error = resolved.refresh_failed.then_some(RefreshErrorKind::Mint); - self.target = Some(resolved); - } - Err(err) => { - tracing::warn!( - error = %format!("{err:#}"), - "token resolve retry failed; pushing with the last embedded token" - ); - refresh_error = Some(RefreshErrorKind::Mint); - } - } - } - let Some(desired) = self.target.as_ref().or(self.embedded.as_ref()).cloned() else { - // Managed credentials with nothing resolved or embedded: - // acquisition fails before any attempt runs, so pushes never see - // this state. - return Ok(EnsureOutcome { - action: RemoteCredentialAction::None, - token: None, - refresh_error, - }); - }; - let embedded_generation = self - .embedded - .as_ref() - .map(|token| token.snapshot.generation); - if !force && embedded_generation == Some(desired.snapshot.generation) { - return Ok(EnsureOutcome { - action: RemoteCredentialAction::Unchanged, - token: Some(desired.snapshot), - refresh_error, - }); - } - match set_url_via_exec(sandbox, origin_url, &desired).await { - Ok(()) => { - let snapshot = desired.snapshot; - *self.embedded = Some(desired); - Ok(EnsureOutcome { - action: RemoteCredentialAction::Embedded, - token: Some(snapshot), - refresh_error, - }) - } - Err(err) => { - if matches!( - &err, - crate::Error::Exec { result, .. } - if result.termination != fabro_types::CommandTermination::Exited - ) { - return Err(err); - } - tracing::warn!( - error = %crate::display_for_log(&err), - "embedding push credentials in origin failed; pushing with the last embedded token" - ); - Ok(EnsureOutcome { - action: RemoteCredentialAction::Unchanged, - token: self.snapshot(), - refresh_error: Some(RefreshErrorKind::SetUrl), - }) - } - } - } -} - -/// Rewrite `origin` with the token embedded, through the sandbox's uniform -/// exec surface. -async fn set_url_via_exec( - sandbox: &crate::RunSandbox, - origin_url: &str, - token: &ResolvedToken, -) -> crate::Result<()> { - let auth_url = - fabro_github::embed_token_in_url(origin_url, token.token.expose()).map_err(|err| { - crate::Error::context( - "Failed to build authenticated origin URL", - RedactedSetUrlError(fabro_redact::redact_string(&format!("{err:#}"))), - ) - })?; - set_auth_url_via_exec(sandbox, auth_url).await -} - -pub(crate) async fn set_auth_url_via_exec( - sandbox: &crate::RunSandbox, - auth_url: DisplaySafeUrl, -) -> crate::Result<()> { - let command = format!( - "git -c maintenance.auto=0 remote set-url origin {}", - crate::shell_quote(auth_url.as_raw_url().as_str()) - ); - let result = sandbox - .exec_command(&command, 10_000, None, None, None) - .await - .map_err(|err| { - let message = redact::redact_auth_url(&crate::display_for_log(&err), Some(&auth_url)); - crate::Error::context( - "Failed to refresh push credentials: set_url_exec_failed", - RedactedSetUrlError(message), - ) - })?; - if !result.is_success() { - return Err(result.into_exec_error_with_redactor( - "git remote set-url origin (refresh push credentials)", - |s| redact::redact_auth_url(s, Some(&auth_url)), - )); - } - Ok(()) -} - -#[derive(Debug, thiserror::Error)] -#[error("{0}")] -struct RedactedSetUrlError(String); - -#[cfg(test)] -mod tests { - use std::sync::atomic::{AtomicUsize, Ordering}; - - use chrono::Utc; - use fabro_github::InstallationToken; - use fabro_github::test_support::{InstallationTokenMinter, installation_token_source}; - use tokio::time::sleep; - - use super::*; - use crate::sandbox::RemoteCredentialAction; - - struct FixedMinter { - calls: AtomicUsize, - ttl: chrono::Duration, - } - - #[async_trait::async_trait] - impl InstallationTokenMinter for FixedMinter { - async fn mint(&self) -> anyhow::Result { - let call = self.calls.fetch_add(1, Ordering::SeqCst) + 1; - Ok(InstallationToken { - token: format!("ghs_gen{call}"), - expires_at: Utc::now() + self.ttl, - }) - } - } - - struct FailingMinter; - - #[async_trait::async_trait] - impl InstallationTokenMinter for FailingMinter { - async fn mint(&self) -> anyhow::Result { - Err(anyhow::anyhow!("mint failed")) - } - } - - fn minting_state(ttl: chrono::Duration) -> PushCredentialState { - PushCredentialState::new(Some(installation_token_source( - "owner/repo", - Arc::new(FixedMinter { - calls: AtomicUsize::new(0), - ttl, - }), - ))) - } - - const ORIGIN: &str = "https://github.com/owner/repo"; - /// Long enough for a blocked task to be observably pending on paused time. - const SHORT_WAIT: std::time::Duration = std::time::Duration::from_secs(5); - - /// A refresh-ahead tick crossing the cache margin during a push waits on - /// the embed mutex until the operation releases the lease, so the remote - /// can never be swapped out from under the pinned generation. - #[tokio::test(start_paused = true)] - async fn refresh_waits_for_the_lease_to_release() { - let state = std::sync::Arc::new(minting_state(chrono::Duration::minutes(60))); - - let lease = state.lease().await.expect("lease acquires"); - - let refresh_task = { - let state = std::sync::Arc::clone(&state); - tokio::spawn(async move { - state - .refresh(ORIGIN, |_| async { Ok(()) }) - .await - .expect("refresh succeeds after the lease releases") - }) - }; - - // The refresh must be blocked while the lease holds the embed mutex. - sleep(SHORT_WAIT).await; - assert!( - !refresh_task.is_finished(), - "refresh must wait on the embed mutex" - ); - - drop(lease); - let outcome = refresh_task.await.expect("refresh task completes"); - // The lease's resolve minted generation 1; the deferred refresh - // reuses it (the operation never embedded, so the refresh embeds). - assert_eq!(outcome.token().unwrap().generation, 1); - } - - #[tokio::test] - async fn refresh_without_managed_credentials_reports_none() { - let state = PushCredentialState::new(None); - let outcome = state - .refresh(ORIGIN, |_| async { panic!("set-url must not run") }) - .await - .unwrap(); - assert_eq!(outcome, RefreshOutcome::none()); - } - - #[tokio::test] - async fn refresh_embeds_a_new_generation_and_skips_matching_ones() { - let state = minting_state(chrono::Duration::minutes(60)); - let set_url_calls = AtomicUsize::new(0); - - let first = state - .refresh(ORIGIN, |auth_url| { - set_url_calls.fetch_add(1, Ordering::SeqCst); - assert!(auth_url.as_raw_url().as_str().contains("ghs_gen1")); - async { Ok(()) } - }) - .await - .unwrap(); - assert_eq!(first.action(), RemoteCredentialAction::Embedded); - assert_eq!(first.token().unwrap().generation, 1); - - // The cached token is fresh, so the second refresh must skip set-url. - let second = state - .refresh(ORIGIN, |_| { - set_url_calls.fetch_add(1, Ordering::SeqCst); - async { Ok(()) } - }) - .await - .unwrap(); - assert_eq!(second.action(), RemoteCredentialAction::Unchanged); - assert_eq!(second.token().unwrap().generation, 1); - assert_eq!(set_url_calls.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn refresh_embeds_again_when_the_source_mints_a_new_generation() { - // Tokens expire inside the margin, so every resolve re-mints. - let state = minting_state(chrono::Duration::minutes(5)); - let set_url_calls = AtomicUsize::new(0); - - let first = state - .refresh(ORIGIN, |_| { - set_url_calls.fetch_add(1, Ordering::SeqCst); - async { Ok(()) } - }) - .await - .unwrap(); - let second = state - .refresh(ORIGIN, |_| { - set_url_calls.fetch_add(1, Ordering::SeqCst); - async { Ok(()) } - }) - .await - .unwrap(); - - assert_eq!(first.token().unwrap().generation, 1); - assert_eq!(second.action(), RemoteCredentialAction::Embedded); - assert_eq!(second.token().unwrap().generation, 2); - assert_eq!(set_url_calls.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn clone_seed_makes_the_first_refresh_a_no_op() { - let state = minting_state(chrono::Duration::minutes(60)); - let clone_token = state.source().unwrap().mint_for_clone().await.unwrap(); - state.record_embedded(clone_token).await; - - let outcome = state - .refresh(ORIGIN, |_| async { panic!("set-url must not run") }) - .await - .unwrap(); - assert_eq!(outcome.action(), RemoteCredentialAction::Unchanged); - assert_eq!(outcome.token().unwrap().generation, 1); - } - - #[tokio::test] - async fn failed_set_url_does_not_record_the_new_generation() { - let state = minting_state(chrono::Duration::minutes(60)); - - let err = state - .refresh(ORIGIN, |_| async { - Err(crate::Error::message("set-url failed")) - }) - .await - .unwrap_err(); - assert!(err.to_string().contains("set-url failed")); - - // The generation was not recorded, so the retry embeds again instead - // of wrongly skipping. - let retried = state.refresh(ORIGIN, |_| async { Ok(()) }).await.unwrap(); - assert_eq!(retried.action(), RemoteCredentialAction::Embedded); - assert_eq!(retried.token().unwrap().generation, 1); - } - - #[tokio::test] - async fn static_credentials_seeded_at_clone_skip_set_url() { - let source = InstallationTokenSource::for_origin( - &GitHubCredentials::Pat("ghp_pat".to_string()), - ORIGIN, - serde_json::json!({ "contents": "write" }), - ) - .unwrap(); - let state = PushCredentialState::new(Some(source)); - let clone_token = state.source().unwrap().mint_for_clone().await.unwrap(); - state.record_embedded(clone_token).await; - - let outcome = state - .refresh(ORIGIN, |_| async { panic!("set-url must not run") }) - .await - .unwrap(); - assert_eq!(outcome.action(), RemoteCredentialAction::Unchanged); - assert!(outcome.token().unwrap().is_static()); - } - - #[tokio::test] - async fn mint_failure_preserves_the_mint_error_chain() { - let state = PushCredentialState::new(Some(installation_token_source( - "owner/repo", - Arc::new(FailingMinter), - ))); - - let err = state - .refresh(ORIGIN, |_| async { panic!("set-url must not run") }) - .await - .unwrap_err(); - assert_eq!(err.causes(), vec![ - "minting GitHub installation access token", - "mint failed" - ]); - } - - #[test] - fn token_source_requires_managed_credentials_and_a_github_origin() { - assert!(build_token_source(None, Some(ORIGIN)).unwrap().is_none()); - let pat = GitHubCredentials::Pat("ghp_pat".to_string()); - assert!(build_token_source(Some(&pat), None).unwrap().is_none()); - assert!( - build_token_source(Some(&pat), Some("https://gitlab.com/owner/repo")) - .unwrap() - .is_none() - ); - assert!( - build_token_source(Some(&pat), Some(ORIGIN)) - .unwrap() - .is_some() - ); - } -} diff --git a/lib/components/fabro-sandbox/src/reconnect.rs b/lib/components/fabro-sandbox/src/reconnect.rs index 54841474b..fa07fa345 100644 --- a/lib/components/fabro-sandbox/src/reconnect.rs +++ b/lib/components/fabro-sandbox/src/reconnect.rs @@ -1,74 +1,52 @@ -use std::path::PathBuf; - use anyhow::{Context, Result}; -use fabro_types::{BundledProvider, RunId, RunSandboxInstance}; -use sandbox_driver::EventContext; +use fabro_types::{RunId, RunSandboxInstance}; +use sandbox_driver::{EventContext, PtySession, PtySize}; use crate::driver::ProviderAccess; -use crate::driver_sandbox::{RunSandbox, local_sandbox_with_events}; +use crate::driver_sandbox::RunSandbox; use crate::provider_sandbox; -/// Reconnect to a sandbox from a saved record. +/// Reconnect to a run's sandbox from its saved record. /// /// `access` carries the provider settings and vault credentials the record's -/// provider needs; the process environment is never consulted. -pub async fn reconnect(record: &RunSandboxInstance, access: &ProviderAccess) -> Result { - reconnect_for_run(record, access, None).await -} - +/// provider needs; the process environment is never consulted. `run_id` +/// narrows the ownership scope to the run when known, and the driver reports +/// the sandbox's lifecycle from here on through `events`. pub async fn reconnect_for_run( record: &RunSandboxInstance, access: &ProviderAccess, run_id: Option, -) -> Result { - reconnect_for_run_with_events(record, access, run_id, None).await -} - -pub async fn reconnect_for_run_with_events( - record: &RunSandboxInstance, - access: &ProviderAccess, - run_id: Option, - events: Option, -) -> Result { - reconnect_driver_for_run(record, access, run_id, events).await -} - -/// Reconnects as the driver-backed sandbox type, for callers that need a -/// driver facet fabro's [`Sandbox`](crate::Sandbox) trait does not carry -/// (VNC, signed previews, leased SSH). -pub async fn reconnect_driver_for_run( - record: &RunSandboxInstance, - access: &ProviderAccess, - run_id: Option, events: Option, ) -> Result { let runtime = &record.runtime; - // A local sandbox is its working directory: rebuilding the handle over - // that directory is the reconnect. The per-process Host registry holds - // no state worth attaching to. - let sandbox = if record.provider.bundled() == Some(BundledProvider::Local) { - local_sandbox_with_events(PathBuf::from(&runtime.working_directory), events) - .await - .context("Failed to reconnect local sandbox")? - } else { - let repo_cloned = runtime.repo_cloned.with_context(|| { - format!( - "{} run sandbox missing repo_cloned metadata", - record.provider - ) - })?; - provider_sandbox::attach_provider_sandbox( - record.provider.clone(), - access, - &runtime.id, - repo_cloned, - runtime.working_directory.clone(), - runtime.clone_origin_url.clone(), - run_id, - events, - ) - .await - .with_context(|| format!("Failed to reconnect {} sandbox", record.provider))? - }; - Ok(sandbox) + provider_sandbox::attach_provider_sandbox( + record.provider.clone(), + access, + &runtime.id, + // A record without the flag was written for a sandbox fabro never + // cloned into. + runtime.repo_cloned.unwrap_or(false), + runtime.working_directory.clone(), + runtime.clone_origin_url.clone(), + run_id, + events, + ) + .await + .with_context(|| format!("Failed to reconnect {} sandbox", record.provider)) +} + +/// Opens an interactive shell in a run's sandbox over the driver's Pty +/// facet, reconnecting from the run record first. The session is the +/// driver's own; it is closed by the caller. +pub async fn open_terminal_for_run( + record: &RunSandboxInstance, + access: &ProviderAccess, + run_id: Option, + size: PtySize, +) -> crate::Result> { + let sandbox = reconnect_for_run(record, access, run_id, None) + .await + .map_err(|err| crate::Error::context_anyhow("Failed to reconnect sandbox", err))?; + sandbox.activate().await?; + sandbox.open_terminal(size).await } diff --git a/lib/components/fabro-sandbox/src/redact.rs b/lib/components/fabro-sandbox/src/redact.rs index bd94986de..2798243eb 100644 --- a/lib/components/fabro-sandbox/src/redact.rs +++ b/lib/components/fabro-sandbox/src/redact.rs @@ -1,17 +1,9 @@ -//! Fabro's secret scanner on the text seams pebble and the sandbox expose. +//! Fabro's secret scanner on the text seams pebble exposes. use std::borrow::Cow; use pebble_coding_agent::extensions::Redactor; -/// Strips a specific authenticated URL out of `text`, when one is known. -pub fn redact_auth_url(text: &str, auth_url: Option<&fabro_redact::DisplaySafeUrl>) -> String { - let Some(auth_url) = auth_url else { - return text.to_string(); - }; - text.replace(&auth_url.raw_string(), &auth_url.redacted_string()) -} - /// Fabro's secret scanner as pebble's [`Redactor`]. /// /// Pebble calls it where text a process or the operating system wrote leaves diff --git a/lib/components/fabro-sandbox/src/sandbox.rs b/lib/components/fabro-sandbox/src/sandbox.rs index df948e873..101a9092e 100644 --- a/lib/components/fabro-sandbox/src/sandbox.rs +++ b/lib/components/fabro-sandbox/src/sandbox.rs @@ -1,30 +1,19 @@ -use std::collections::HashMap; -use std::fmt::Write; -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; use std::time::Duration; -use async_trait::async_trait; +use chrono::{DateTime, Utc}; use fabro_github::token_source::TokenSnapshot; -pub use fabro_types::run_event::GitCredentialAction as RemoteCredentialAction; -use fabro_types::{CommandOutputStream, CommandTermination}; -use fabro_util::shell; -use sandbox_driver::{Git as _, GitCheckoutOptions, GitFailureKind, GitPushOptions, Termination}; +use sandbox_driver::{ + Git as _, GitAttempt, GitCheckoutOptions, GitFetchOptions, GitPushOptions, GitRetryError, + GitRetryPolicy, retry_git, +}; use serde::{Deserialize, Serialize}; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite}; -use tokio::sync::Mutex as TokioMutex; -use tokio::task::JoinHandle; use tokio::time; -use tokio_util::sync::CancellationToken; +use crate::credentials::{self, RepoCredentials}; use crate::driver_sandbox::RunSandbox; -use crate::git_retry::{self, CredentialContext, GitRetryReason, RetryPlan}; -use crate::push_credentials::{CredentialLease, PushCredentialState, RefreshErrorKind}; +use crate::git_policy::{self, GitRetryReason}; /// Git command prefix that disables background maintenance. -pub(crate) const GIT: &str = "git -c maintenance.auto=0 -c gc.auto=0"; - pub const DEFAULT_EXEC_OUTPUT_TAIL_BYTES: usize = 8 * 1024; /// Where a clone-based sandbox put its files, as persisted on the run. @@ -59,111 +48,13 @@ pub enum GitSetupIntent { }, } -/// Formats file content with line numbers for display. -/// -/// Applies optional offset (1-based starting line number) and limit (max lines -/// to return). Line numbers are 1-based and right-aligned. -#[must_use] -pub fn format_lines_numbered(content: &str, offset: Option, limit: Option) -> String { - let all_lines: Vec<&str> = content.lines().collect(); - let skip = offset.unwrap_or(1).saturating_sub(1); - let take = limit.unwrap_or(all_lines.len()); - let selected: Vec<&str> = all_lines.into_iter().skip(skip).take(take).collect(); - let width = (skip + selected.len()).to_string().len().max(1); - let mut result = String::new(); - for (i, line) in selected.iter().enumerate() { - let line_num = skip + i + 1; - let _ = writeln!(result, "{line_num:>width$} | {line}"); - } - result -} - -#[derive(Debug, Clone)] -pub struct ExecResult { - pub stdout: String, - pub stderr: String, - pub exit_code: Option, - pub termination: CommandTermination, - pub duration_ms: u64, -} - -impl ExecResult { - pub fn is_success(&self) -> bool { - self.exit_code == Some(0) && self.termination == CommandTermination::Exited - } - - pub fn is_timed_out(&self) -> bool { - self.termination == CommandTermination::TimedOut - } - - pub fn is_cancelled(&self) -> bool { - self.termination == CommandTermination::Cancelled - } - - pub fn display_exit_code(&self) -> i32 { - self.exit_code.unwrap_or(-1) - } - - pub fn into_exec_error(self, label: impl Into) -> crate::Error { - crate::Error::exec(label, self) - } - - pub fn into_exec_error_with_redactor( - self, - label: impl Into, - redactor: impl Fn(&str) -> String, - ) -> crate::Error { - crate::Error::exec(label, Self { - stdout: redactor(&self.stdout), - stderr: redactor(&self.stderr), - ..self - }) - } - - pub fn into_result(self, label: impl Into) -> crate::Result { - if self.is_success() { - Ok(self) - } else { - Err(self.into_exec_error(label)) - } - } - - pub fn redacted_output_tail( - &self, - max_bytes_per_stream: usize, - ) -> Option { - redacted_output_tail(&self.stdout, &self.stderr, max_bytes_per_stream) - } - - pub fn default_redacted_output_tail(&self) -> Option { - self.redacted_output_tail(DEFAULT_EXEC_OUTPUT_TAIL_BYTES) - } - - /// Converts host process output into the canonical full exec result. - /// - /// This stores raw stdout/stderr. Callers must not log these fields - /// directly; use `default_redacted_output_tail()` for events and - /// `display_for_log()` for tracing. - #[cfg(test)] - pub fn from_process_output(output: std::process::Output, duration_ms: u64) -> Self { - let std::process::Output { - status, - stdout, - stderr, - } = output; - Self { - stdout: String::from_utf8_lossy(&stdout).into_owned(), - stderr: String::from_utf8_lossy(&stderr).into_owned(), - exit_code: Some(status.code().unwrap_or(-1)), - termination: CommandTermination::Exited, - duration_ms, - } - } -} - -/// Build a redacted `ExecOutputTail` from raw stdout/stderr without -/// fabricating a synthetic `ExecResult`. Pass `""` for either stream that -/// isn't relevant. Returns `None` when both streams are empty. +/// Build a redacted `ExecOutputTail` from stdout/stderr text without +/// fabricating a synthetic `ExecResult`. Each stream is redacted, then +/// capped to its newest `max_bytes_per_stream`. Terminal control sequences +/// are not stripped here: command output reaches fabro with them already +/// removed by the driver under [`crate::exec::SandboxExec`]'s output policy. +/// Pass `""` for either stream that isn't relevant. Returns `None` when both +/// streams are empty. #[must_use] pub fn redacted_output_tail( stdout: &str, @@ -187,288 +78,16 @@ fn redacted_tail(text: &str, max_bytes: usize) -> (Option, bool) { } let redacted = fabro_redact::redact_string(text); - let sanitized = sanitize_exec_output(&redacted); - let truncated = sanitized.len() > max_bytes; + let truncated = redacted.len() > max_bytes; let start = if truncated { - sanitized.floor_char_boundary(sanitized.len() - max_bytes) + redacted.floor_char_boundary(redacted.len() - max_bytes) } else { 0 }; - let tail = sanitized[start..].to_string(); + let tail = redacted[start..].to_string(); ((!tail.is_empty()).then_some(tail), truncated) } -fn sanitize_exec_output(text: &str) -> String { - let mut sanitized = String::with_capacity(text.len()); - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '\u{1b}' { - match chars.peek().copied() { - Some('[') => { - chars.next(); - for next in chars.by_ref() { - if ('@'..='~').contains(&next) { - break; - } - } - } - Some(']') => { - chars.next(); - let mut saw_esc = false; - for next in chars.by_ref() { - if next == '\u{7}' || (saw_esc && next == '\\') { - break; - } - saw_esc = next == '\u{1b}'; - } - } - Some('(' | ')' | '*' | '+' | '-' | '.' | '/') => { - chars.next(); - chars.next(); - } - Some('@'..='_') => { - chars.next(); - } - _ => {} - } - continue; - } - if ch == '\n' || ch == '\r' || ch == '\t' || !ch.is_control() { - sanitized.push(ch); - } - } - sanitized -} - -#[derive(Debug, Clone)] -pub struct ExecStreamingResult { - pub result: ExecResult, - pub streams_separated: bool, - pub live_streaming: bool, - pub stdout_capture: OutputCaptureStats, - pub stderr_capture: OutputCaptureStats, -} - -impl ExecStreamingResult { - #[must_use] - pub fn output_capture(&self) -> OutputCaptureStats { - self.stdout_capture.combine(self.stderr_capture) - } -} - -/// Byte counts for output observed and retained while draining a process. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct OutputCaptureStats { - pub observed_bytes: usize, - pub retained_bytes: usize, - pub omitted_bytes: usize, -} - -impl OutputCaptureStats { - #[must_use] - pub fn complete(byte_count: usize) -> Self { - Self { - observed_bytes: byte_count, - retained_bytes: byte_count, - omitted_bytes: 0, - } - } - - #[must_use] - pub fn combine(self, other: Self) -> Self { - Self { - observed_bytes: self.observed_bytes.saturating_add(other.observed_bytes), - retained_bytes: self.retained_bytes.saturating_add(other.retained_bytes), - omitted_bytes: self.omitted_bytes.saturating_add(other.omitted_bytes), - } - } -} - -pub type CommandOutputCallback = Arc< - dyn Fn(CommandOutputStream, Vec) -> Pin> + Send>> - + Send - + Sync, ->; - -/// Inputs for a streaming command execution. -/// -/// Construct with a struct literal over [`ExecStreamingRequest::new`]: -/// `ExecStreamingRequest { stdin, ..ExecStreamingRequest::new(command) }`. -/// Providers should destructure exhaustively so a new field is a compile -/// error rather than silently ignored input. -/// -/// Standard input is owned so providers can move it into a writer task. This -/// type does not implement `Debug` because standard input can contain -/// sensitive workflow data. -pub struct ExecStreamingRequest<'a> { - pub command: &'a str, - pub timeout_ms: Option, - pub working_dir: Option<&'a str>, - pub env_vars: Option<&'a HashMap>, - pub cancel_token: Option, - pub stdin: Option>, - pub output_callback: Option, - /// Maximum bytes retained from each stream. Providers continue draining - /// stdout and stderr after the cap is reached. - pub stream_output_bytes_cap: Option, -} - -impl<'a> ExecStreamingRequest<'a> { - #[must_use] - pub fn new(command: &'a str) -> Self { - Self { - command, - timeout_ms: None, - working_dir: None, - env_vars: None, - cancel_token: None, - stdin: None, - output_callback: None, - stream_output_bytes_cap: None, - } - } -} - -pub struct StdioProcess { - pub stdin: Pin>, - pub stdout: Pin>, - pub stderr: StderrCollector, - pub handle: StdioProcessHandle, -} - -#[derive(Debug, Clone)] -pub struct StderrCollector { - inner: StderrCollectorInner, -} - -#[derive(Debug, Clone)] -enum StderrCollectorInner { - Buffer { - bytes: Arc>>, - max_bytes: usize, - }, - /// A tail the sandbox driver already keeps for a spawned process. - Driver(sandbox_driver::StderrTail), -} - -impl StderrCollector { - #[must_use] - pub fn new(max_bytes: usize) -> Self { - Self { - inner: StderrCollectorInner::Buffer { - bytes: Arc::new(TokioMutex::new(Vec::new())), - max_bytes, - }, - } - } - - /// Wraps the rolling stderr tail of a driver-spawned process. - #[must_use] - pub fn from_driver_tail(tail: sandbox_driver::StderrTail) -> Self { - Self { - inner: StderrCollectorInner::Driver(tail), - } - } - - pub async fn push(&self, bytes: &[u8]) { - match &self.inner { - StderrCollectorInner::Buffer { - bytes: buffer, - max_bytes, - } => { - let mut tail = buffer.lock().await; - tail.extend_from_slice(bytes); - if tail.len() > *max_bytes { - let excess = tail.len() - max_bytes; - tail.drain(..excess); - } - } - StderrCollectorInner::Driver(tail) => tail.push(bytes), - } - } - - pub async fn tail_string(&self) -> String { - match &self.inner { - StderrCollectorInner::Buffer { bytes, .. } => { - let tail = bytes.lock().await; - String::from_utf8_lossy(&tail).into_owned() - } - StderrCollectorInner::Driver(tail) => tail.to_string_lossy(), - } - } - - pub fn spawn_reader(&self, mut reader: R) -> JoinHandle<()> - where - R: AsyncRead + Unpin + Send + 'static, - { - let collector = self.clone(); - tokio::spawn(async move { - let mut buf = [0_u8; 8192]; - loop { - match reader.read(&mut buf).await { - Ok(0) => return, - Ok(read) => collector.push(&buf[..read]).await, - Err(err) => { - tracing::warn!(error = %err, "Failed to read stdio process stderr"); - return; - } - } - } - }) - } -} - -#[derive(Clone)] -pub struct StdioProcessHandle { - control: Arc, -} - -impl StdioProcessHandle { - pub(crate) fn new(control: impl StdioProcessControl + 'static) -> Self { - Self { - control: Arc::new(control), - } - } - - pub async fn terminate(&self) -> crate::Result<()> { - self.control.terminate().await - } - - pub async fn wait(&self) -> crate::Result { - self.control.wait().await - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct StdioProcessTermination { - pub termination: CommandTermination, - pub exit_code: Option, -} - -impl StdioProcessTermination { - #[must_use] - pub fn exited(exit_code: Option) -> Self { - Self { - termination: CommandTermination::Exited, - exit_code, - } - } - - #[must_use] - pub fn cancelled() -> Self { - Self { - termination: CommandTermination::Cancelled, - exit_code: None, - } - } -} - -#[async_trait] -pub(crate) trait StdioProcessControl: Send + Sync { - async fn terminate(&self) -> crate::Result<()>; - async fn wait(&self) -> crate::Result; -} - /// A regular file discovered inside a sandbox. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SandboxFile { @@ -479,56 +98,6 @@ pub struct SandboxFile { pub size: u64, } -/// Outcome of -/// [`RunSandbox::refresh_push_credentials`](crate::RunSandbox::refresh_push_credentials): -/// what this call did to the remote, and the non-secret description of the -/// token embedded in it. `token` is `None` only when `action` is -/// [`RemoteCredentialAction::None`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RefreshOutcome { - /// No managed credentials exist for this sandbox. - None, - /// The remote already carried this token generation. - Unchanged(TokenSnapshot), - /// The remote was updated to carry this token generation. - Embedded(TokenSnapshot), -} - -impl RefreshOutcome { - /// No managed credentials to refresh. - #[must_use] - pub const fn none() -> Self { - Self::None - } - - #[must_use] - pub const fn unchanged(token: TokenSnapshot) -> Self { - Self::Unchanged(token) - } - - #[must_use] - pub const fn embedded(token: TokenSnapshot) -> Self { - Self::Embedded(token) - } - - #[must_use] - pub const fn action(self) -> RemoteCredentialAction { - match self { - Self::None => RemoteCredentialAction::None, - Self::Unchanged(_) => RemoteCredentialAction::Unchanged, - Self::Embedded(_) => RemoteCredentialAction::Embedded, - } - } - - #[must_use] - pub const fn token(self) -> Option { - match self { - Self::None => None, - Self::Unchanged(token) | Self::Embedded(token) => Some(token), - } - } -} - pub(crate) fn resolve_path(path: &str, working_dir: &str) -> String { if std::path::Path::new(path).is_absolute() { path.to_string() @@ -550,13 +119,6 @@ pub(crate) fn join_sandbox_path(base: &str, relative_path: &str) -> String { format!("{}/{relative_path}", base.trim_end_matches('/')) } -/// Shell-quote a string using `shlex::try_quote`, with a fallback for edge -/// cases. Re-exported from [`fabro_util::shell::shell_quote`] so sandbox code -/// and the config resolve layer share one audited implementation. -pub fn shell_quote(s: &str) -> String { - shell::shell_quote(s) -} - /// Creates the run branch in the sandbox's checkout through the driver's /// git facet: a new run branches from `HEAD`, a fork from the source run's /// checkpoint. The branch is created at that base, or moved to it when an @@ -613,38 +175,27 @@ pub(crate) async fn fetch_source_run_ref( ) -> crate::Result<()> { let remote_ref = format!("refs/heads/fabro/run/{source_run_id}"); let tracking_ref = format!("refs/remotes/origin/fabro/run/{source_run_id}"); - let fetch_cmd = format!( - "{GIT} fetch origin {}:{}", - shell_quote(&remote_ref), - shell_quote(&tracking_ref) - ); - let check_cmd = format!( - "{GIT} merge-base --is-ancestor {} {}", - shell_quote(checkpoint_sha), - shell_quote(&tracking_ref) - ); + let git = sandbox.git()?; + let repo = sandbox.working_directory(); + let mut fetch = GitFetchOptions::default(); + fetch.remote = Some("origin".to_owned()); + fetch.refspecs = vec![format!("{remote_ref}:{tracking_ref}")]; + fetch.timeout = Some(Duration::from_secs(30)); + // The source run's checkpoint may still be landing on the remote; a + // few short retries cover the replication. let mut last_error = String::new(); for _ in 0..5 { - let fetch = sandbox - .exec_command(&fetch_cmd, 30_000, None, None, None) - .await?; - if fetch.is_success() { - let check = sandbox - .exec_command(&check_cmd, 10_000, None, None, None) - .await?; - if check.is_success() { - return Ok(()); - } - last_error = check - .into_exec_error(format!( - "checkpoint {checkpoint_sha} is not reachable from {remote_ref}" - )) - .to_string(); - } else { - last_error = fetch - .into_exec_error("git fetch source run ref") - .to_string(); + match git.fetch(repo, &fetch).await { + Ok(()) => match git.is_ancestor(repo, checkpoint_sha, &tracking_ref).await { + Ok(true) => return Ok(()), + Ok(false) => { + last_error = + format!("checkpoint {checkpoint_sha} is not reachable from {remote_ref}"); + } + Err(error) => last_error = format!("git merge-base --is-ancestor: {error}"), + }, + Err(error) => last_error = format!("git fetch source run ref: {error}"), } time::sleep(Duration::from_millis(500)).await; } @@ -658,21 +209,18 @@ pub(crate) async fn fetch_source_run_ref( #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PushAttempt { /// 1-based attempt number within this operation. - pub attempt: u32, - pub started_at: chrono::DateTime, - pub success: bool, + pub attempt: u32, + pub started_at: chrono::DateTime, + pub success: bool, /// The classifier's verdict for a failed attempt — recorded on the /// terminal attempt too; whether a retry actually followed is positional /// (every entry except the last). - pub retry_reason: Option, + pub retry_reason: Option, /// Redacted, bounded output tail; failed attempts only. - pub exec_output_tail: Option, - /// The token embedded in the remote during this attempt. - pub token: Option, - /// What `ensure_embedded` did to the remote this attempt. - pub credential_action: Option, - /// A mint or `set-url` failure this attempt pushed through. - pub refresh_error: Option, + pub exec_output_tail: Option, + /// The token this attempt pushed with; `None` without managed + /// credentials. + pub token: Option, } /// The attempt history of one push operation. @@ -691,49 +239,19 @@ pub struct PushError { pub error: crate::Error, } -/// What a failed push attempt means for retrying. The driver classified -/// the failure; a push that did not run to completion (timed out or -/// cancelled) is never retried, because the remote may still be applying -/// it. -fn classify_push_error(error: &crate::Error, cred: CredentialContext) -> Option { - let driver = error.driver()?; - if let sandbox_driver::Error::Git(failure) = driver { - if failure - .output() - .is_some_and(|output| output.termination() != Termination::Exited) - { - return None; - } - } - git_retry::classify_driver_failure(driver, cred) -} - -/// Whether a failed push attempt was rejected as unauthenticated, the shape -/// a drifted or missing embedded token also produces. -fn push_failure_looks_auth_shaped(error: &crate::Error) -> bool { - matches!( - error.driver(), - Some(sandbox_driver::Error::Git(failure)) if failure.kind() == GitFailureKind::AuthRejected - ) -} - -/// Pushes a refspec to origin through the driver's git facet, retrying per -/// `plan` with one pinned credential generation for the whole operation. -/// `credentials` is the provider's push-credential state plus the origin -/// URL; `None` pushes with whatever the remote already carries (the local -/// sandbox, or a workspace without managed credentials). +/// Pushes a refspec to origin through the driver's git facet, retried by +/// the driver under `policy` with one token for the whole operation. +/// `credentials` is the checkout's managed credentials; `None` pushes with +/// whatever the checkout already has (a checkout fabro did not clone, or a +/// clone made without a GitHub App). #[tracing::instrument(name = "git_op", skip_all, fields(op = "push"))] pub(crate) async fn git_push( sandbox: &RunSandbox, - credentials: Option<(&PushCredentialState, &str)>, + credentials: Option<&RepoCredentials>, refspec: &str, - plan: &RetryPlan, + policy: &GitRetryPolicy, ) -> Result { - use CredentialContext; - use CredentialLease; - let start = time::Instant::now(); - let deadline = plan.effective_deadline(start); let git = match sandbox.git() { Ok(git) => git, Err(error) => { @@ -745,164 +263,118 @@ pub(crate) async fn git_push( }; let repo = sandbox.working_directory().to_owned(); - // The lease pins one token generation and owns the embed mutex for the - // whole operation; no concurrent refresh can re-embed mid-operation, and - // no attempt can cross the refresh margin and restart the replication - // clock. - let mut lease: Option<(CredentialLease<'_>, &str)> = match credentials { - Some((state, origin_url)) => match match deadline { - Some(deadline) => match time::timeout_at(deadline, state.lease()).await { - Ok(result) => result, - Err(_) => { - return Err(push_deadline_error( - Vec::new(), - "while acquiring credentials", - )); + // One token for the whole operation. A retry after replication lag must + // present the same token, because replication of a given token only + // makes progress, and a fresh mint would restart that clock. + let token = match credentials { + Some(credentials) => { + let resolved = match policy.max_elapsed { + Some(max_elapsed) => { + match time::timeout(max_elapsed, credentials.resolve()).await { + Ok(resolved) => resolved, + Err(_) => { + return Err(push_deadline_error( + Vec::new(), + "while acquiring credentials", + )); + } + } + } + None => credentials.resolve().await, + }; + match resolved { + Ok(token) => token, + Err(error) => { + return Err(PushError { + report: PushReport::default(), + error, + }); } - }, - None => state.lease().await, - } { - Ok(lease) => Some((lease, origin_url)), - Err(error) => { - return Err(PushError { - report: PushReport::default(), - error, - }); } - }, + } None => None, }; + let snapshot = token.as_ref().map(|token| token.snapshot); + let git_credentials = token.as_ref().map(credentials::git_credentials); + // Resolving the token spent part of the operation's budget. + let policy = match policy.max_elapsed { + Some(max_elapsed) => policy.max_elapsed(max_elapsed.saturating_sub(start.elapsed())), + None => *policy, + }; - let mut attempts: Vec = Vec::new(); - let mut force_reembed = false; - let mut drift_repaired = false; let label = format!("git push origin {refspec}"); - - loop { - let attempt_number = u32::try_from(attempts.len()).unwrap_or(u32::MAX) + 1; - let started_at = chrono::Utc::now(); - let attempt_timeout = plan - .attempt_timeout(deadline) - .unwrap_or(Duration::from_mins(1)); - if attempt_timeout.is_zero() { - return Err(push_deadline_error(attempts, "before the next attempt")); + let result = retry_git( + &policy, + git_credentials.as_ref(), + &label, + |_attempt, timeout| { + let mut options = GitPushOptions::default(); + options.remote = Some("origin".to_owned()); + options.refspec = Some(refspec.to_owned()); + options.timeout = Some(timeout.unwrap_or(Duration::from_mins(1))); + options.credentials.clone_from(&git_credentials); + let git = &git; + let repo = &repo; + async move { git.push(repo, &options).await } + }, + ) + .await; + match result { + Ok(report) => { + tracing::info!( + refspec = %refspec, + attempts = report.attempts.len(), + token_generation = snapshot.map(|token| token.generation), + token_age_ms = snapshot.and_then(|token| token.age_ms()), + "Pushed git ref to origin" + ); + Ok(PushReport { + attempts: push_attempts(report.attempts, Ok(()), snapshot), + }) } - let attempt_deadline = time::Instant::now() + attempt_timeout; - let (token, credential_action, refresh_error) = match lease.as_mut() { - Some((lease, origin_url)) => { - let ensured = match time::timeout_at( - attempt_deadline, - lease.ensure_embedded(sandbox, origin_url, force_reembed), - ) - .await - { - Ok(Ok(ensured)) => ensured, - Ok(Err(error)) => { - return Err(PushError { - report: PushReport { attempts }, - error, - }); - } - Err(_) => { - return Err(push_deadline_error( - attempts, - "while refreshing credentials", - )); - } - }; - force_reembed = false; - (ensured.token, Some(ensured.action), ensured.refresh_error) - } - None => (None, None, None), - }; - - let remaining = attempt_deadline.saturating_duration_since(time::Instant::now()); - if remaining.is_zero() { - return Err(push_deadline_error(attempts, "before running git push")); - } - let mut options = GitPushOptions::default(); - options.remote = Some("origin".to_owned()); - options.refspec = Some(refspec.to_owned()); - options.timeout = Some(remaining); - let push_result = git - .push(&repo, &options) - .await - .map_err(|error| crate::Error::context(label.clone(), error)); - - match push_result { - Ok(()) => { - attempts.push(PushAttempt { - attempt: attempt_number, - started_at, - success: true, - retry_reason: None, - exec_output_tail: None, - token, - credential_action, - refresh_error, - }); - tracing::info!( - refspec = %refspec, - attempt = attempt_number, - token_generation = token.map(|token| token.generation), - token_age_ms = token.and_then(|token| token.age_ms()), - "Pushed git ref to origin" - ); - return Ok(PushReport { attempts }); - } - Err(error) => { - // Drift recovery: the tracked generation is local belief, and - // agent code inside the sandbox can rewrite `origin`. The - // first auth/not-found failure earns one forced re-embed of - // the pinned token, inside the same retry budget. - if !drift_repaired && lease.is_some() && push_failure_looks_auth_shaped(&error) { - drift_repaired = true; - force_reembed = true; - } - let cred = CredentialContext::from_snapshot(token.as_ref()); - let retry_reason = classify_push_error(&error, cred); - attempts.push(PushAttempt { - attempt: attempt_number, - started_at, - success: false, - retry_reason, - exec_output_tail: error.default_redacted_output_tail(), - token, - credential_action, - refresh_error, - }); - - let exhausted = attempt_number >= plan.max_attempts.max(1); - let Some(reason) = retry_reason.filter(|_| !exhausted) else { - return Err(PushError { - report: PushReport { attempts }, - error, - }); - }; - let Some(delay) = plan.retry_delay(attempt_number, deadline) else { - return Err(PushError { - report: PushReport { attempts }, - error, - }); - }; - // The failure text can carry git stderr, so log the category - // rather than the message. - tracing::warn!( - refspec = %refspec, - attempt = attempt_number, - max_attempts = plan.max_attempts, - reason = %reason, - token_generation = token.map(|token| token.generation), - token_age_ms = token.and_then(|token| token.age_ms()), - delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX), - "Git push failed, retrying with the same token" - ); - time::sleep(delay).await; - } + Err(GitRetryError { attempts, error }) => { + let error = crate::Error::context(label, error); + Err(PushError { + report: PushReport { + attempts: push_attempts(attempts, Err(&error), snapshot), + }, + error, + }) } } } +/// The driver's attempt history as fabro records it. In a completed +/// operation every attempt but the last failed; in a failed one every +/// attempt failed, and the last attempt's failure is `outcome`'s error. +fn push_attempts( + attempts: Vec, + outcome: Result<(), &crate::Error>, + token: Option, +) -> Vec { + let last = attempts.len(); + attempts + .into_iter() + .enumerate() + .map(|(index, attempt)| { + let is_last = index + 1 == last; + let exec_output_tail = match (attempt.failure, &outcome) { + (Some(failure), _) => crate::Error::from(failure).default_redacted_output_tail(), + (None, Err(error)) if is_last => error.default_redacted_output_tail(), + (None, _) => None, + }; + PushAttempt { + attempt: attempt.attempt, + started_at: DateTime::::from(attempt.started_at), + success: is_last && outcome.is_ok(), + retry_reason: attempt.retry_reason.map(git_policy::recorded_reason), + exec_output_tail, + token, + } + }) + .collect() +} + fn push_deadline_error(attempts: Vec, stage: &str) -> PushError { PushError { report: PushReport { attempts }, @@ -913,57 +385,45 @@ fn push_deadline_error(attempts: Vec, stage: &str) -> PushError { #[cfg(test)] mod push_tests { use std::collections::VecDeque; - use std::sync::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use async_trait::async_trait; use chrono::Utc; use fabro_github::InstallationToken; use fabro_github::test_support::{InstallationTokenMinter, installation_token_source}; use fabro_github::token_source::{InstallationTokenSource, REFRESH_MARGIN}; use fabro_types::SandboxProviderKind; + use sandbox_driver::{ExecResult, Termination}; use sandbox_driver_testing::ScriptedSandbox; use tokio::sync::Mutex as AsyncMutex; use super::*; - use crate::git_retry::{GitRetryReason, RetryPlan}; - use crate::push_credentials::{PushCredentialState, RefreshErrorKind}; + use crate::credentials::RepoCredentials; + use crate::git_policy::{GitRetryReason, checkpoint_push_policy, publish_push_policy}; const ORIGIN: &str = "https://github.com/fabro-testing/repo"; const REFSPEC: &str = "refs/heads/fabro/run/01M0DH033P2XSTHAGVBHG6922F"; - fn ok_fabro_exec() -> ExecResult { - ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: Some(0), - termination: CommandTermination::Exited, - duration_ms: 5, - } + fn ok_exec() -> ExecResult { + ExecResult::new(Termination::Exited, Some(0), Duration::from_millis(5)) } fn failed_exec(stderr: &str) -> ExecResult { - ExecResult { - stdout: String::new(), - stderr: stderr.to_string(), - exit_code: Some(128), - termination: CommandTermination::Exited, - duration_ms: 5, - } + let mut result = ExecResult::new(Termination::Exited, Some(128), Duration::from_millis(5)); + result.stderr = stderr.as_bytes().to_vec(); + result } fn timed_out_exec() -> ExecResult { - ExecResult { - stdout: String::new(), - stderr: "Command timed out".to_string(), - exit_code: None, - termination: CommandTermination::TimedOut, - duration_ms: 60_000, - } + let mut result = ExecResult::new(Termination::TimedOut, None, Duration::from_mins(1)); + result.stderr = b"Command timed out".to_vec(); + result } - /// A run sandbox over a scripted driver double: `git push` answers come - /// from a script, `git remote set-url` succeeds unless scripted - /// otherwise, and every command is recorded. + /// A run sandbox over a scripted driver double. The driver's push reads + /// `origin`'s URL when it carries credentials and then runs `git push`; + /// push answers come from a script, and every command is recorded. struct ScriptedGitSandbox { run: RunSandbox, driver: Arc, @@ -971,41 +431,29 @@ mod push_tests { impl ScriptedGitSandbox { fn new(push_results: Vec) -> Self { - Self::with_set_url_results(push_results, Vec::new()) - } - - fn with_set_url_results( - push_results: Vec, - set_url_results: Vec, - ) -> Self { let driver = Arc::new(ScriptedSandbox::with_id_and_working_dir( "scripted-git", "/workspace", )); let pushes = Mutex::new(VecDeque::from(push_results)); - let set_urls = Mutex::new(VecDeque::from(set_url_results)); driver.scripted_exec().respond_with(move |spec| { let script = spec.args.last().map(String::as_str).unwrap_or_default(); - if script.contains("remote set-url") { - return Some( - set_urls - .lock() - .unwrap() - .pop_front() - .map_or_else(ok_exec, driver_result), - ); + if script.contains("'remote' 'get-url' 'origin'") { + let mut url = ok_exec(); + url.stdout = format!("{ORIGIN}\n").into_bytes(); + return Some(url); } assert!( script.contains("'push' 'origin'"), "unexpected exec: {script}" ); - Some(driver_result( + Some( pushes .lock() .unwrap() .pop_front() .expect("push script exhausted"), - )) + ) }); let run = RunSandbox::new(SandboxProviderKind::LOCAL, Arc::clone(&driver) as _); Self { run, driver } @@ -1015,43 +463,30 @@ mod push_tests { self.driver.scripted_exec().commands() } - fn push_count(&self) -> usize { - self.commands() - .iter() - .filter(|command| command.contains("'push' 'origin'")) - .count() - } - - fn set_url_commands(&self) -> Vec { + /// The `git push` commands that ran, in order. + fn pushes(&self) -> Vec { self.commands() .into_iter() - .filter(|command| command.contains("remote set-url")) + .filter(|command| command.contains("'push' 'origin'")) .collect() } - } - /// The driver-level result fabro's exec policy reads back as the fabro - /// result the push tests script. - fn driver_result(result: ExecResult) -> sandbox_driver::ExecResult { - let termination = match result.termination { - CommandTermination::TimedOut => sandbox_driver::Termination::TimedOut, - CommandTermination::Cancelled => sandbox_driver::Termination::Cancelled, - // `CommandTermination` is non-exhaustive; `Exited` and anything - // newer read back as a plain exit. - _ => sandbox_driver::Termination::Exited, - }; - let mut driver = sandbox_driver::ExecResult::new( - termination, - result.exit_code, - Duration::from_millis(result.duration_ms), - ); - driver.stdout = result.stdout.into_bytes(); - driver.stderr = result.stderr.into_bytes(); - driver - } + fn push_count(&self) -> usize { + self.pushes().len() + } - fn ok_exec() -> sandbox_driver::ExecResult { - driver_result(ok_fabro_exec()) + /// The token each push carried in its per-call rewrite; `None` for + /// a push without credentials. + fn push_tokens(&self) -> Vec> { + self.pushes() + .iter() + .map(|push| { + let start = push.find("x-access-token:")? + "x-access-token:".len(); + let end = push[start..].find('@')? + start; + Some(push[start..end].to_owned()) + }) + .collect() + } } enum MintAction { @@ -1065,8 +500,8 @@ mod push_tests { } impl ScriptedMinter { - fn new(script: Vec) -> std::sync::Arc { - std::sync::Arc::new(Self { + fn new(script: Vec) -> Arc { + Arc::new(Self { calls: AtomicUsize::new(0), script: AsyncMutex::new(script.into()), }) @@ -1104,25 +539,23 @@ mod push_tests { } } - fn minting_state( - script: Vec, - ) -> (PushCredentialState, std::sync::Arc) { + fn minting_credentials(script: Vec) -> (RepoCredentials, Arc) { let minter = ScriptedMinter::new(script); let source = installation_token_source( "fabro-testing/repo", - std::sync::Arc::clone(&minter) as std::sync::Arc, + Arc::clone(&minter) as Arc, ); - (PushCredentialState::new(Some(source)), minter) + (RepoCredentials::new(Some(source)), minter) } - async fn seed_clone_token(state: &PushCredentialState) { - let clone_token = state - .source() - .expect("state has a source") + /// Mint the clone token first, the way `initialize` does, so the push + /// resolves the cached token instead of minting one. + async fn seed_clone_token(credentials: &RepoCredentials) { + credentials .mint_for_clone() .await - .expect("clone mint succeeds"); - state.record_embedded(clone_token).await; + .expect("clone mint succeeds") + .expect("managed credentials mint"); } /// Regression for run `01M0DH033P2XSTHAGVBHG6922F` (the push variant of @@ -1132,21 +565,21 @@ mod push_tests { /// token only makes progress — and recover inside the plan's budget. #[tokio::test(start_paused = true)] async fn push_not_found_after_a_successful_mint_is_retried_with_the_same_token() { - let (state, minter) = minting_state(vec![MintAction::Token( + let (credentials, minter) = minting_credentials(vec![MintAction::Token( "ghs_gen1", chrono::Duration::minutes(60), )]); let sandbox = ScriptedGitSandbox::new(vec![ failed_exec("remote: Repository not found."), failed_exec("remote: Repository not found."), - ok_fabro_exec(), + ok_exec(), ]); let report = git_push( &sandbox.run, - Some((&state, ORIGIN)), + Some(&credentials), REFSPEC, - &RetryPlan::checkpoint_push(), + &checkpoint_push_policy(), ) .await .expect("push should recover within the checkpoint plan"); @@ -1161,21 +594,20 @@ mod push_tests { Some(GitRetryReason::TokenReplication) ); assert!(report.attempts[0].exec_output_tail.is_some()); - assert_eq!( - report.attempts[0].credential_action, - Some(RemoteCredentialAction::Embedded), - "first attempt embeds the resolved token" - ); assert!(report.attempts[2].success); assert!(report.attempts[2].exec_output_tail.is_none()); - assert_eq!(sandbox.push_count(), 3); + assert_eq!( + sandbox.push_tokens(), + vec![Some("ghs_gen1".to_owned()); 3], + "every attempt presents the same token" + ); } /// The publish plan gives the terminal push a real budget: four /// replication-lag failures still recover on the fifth attempt. #[tokio::test(start_paused = true)] async fn publish_plan_survives_four_not_found_failures() { - let (state, minter) = minting_state(vec![MintAction::Token( + let (credentials, minter) = minting_credentials(vec![MintAction::Token( "ghs_gen1", chrono::Duration::minutes(60), )]); @@ -1184,14 +616,14 @@ mod push_tests { failed_exec("remote: Repository not found."), failed_exec("remote: Repository not found."), failed_exec("remote: Repository not found."), - ok_fabro_exec(), + ok_exec(), ]); let report = git_push( &sandbox.run, - Some((&state, ORIGIN)), + Some(&credentials), REFSPEC, - &RetryPlan::publish_push(), + &publish_push_policy(), ) .await .expect("push should recover within the publish plan"); @@ -1208,287 +640,173 @@ mod push_tests { #[tokio::test(start_paused = true)] async fn token_resolved_just_above_the_margin_stays_pinned_through_retries() { let ttl = REFRESH_MARGIN + Duration::from_secs(5); - let (state, minter) = minting_state(vec![MintAction::Token( - "ghs_gen1", - chrono::Duration::from_std(ttl).unwrap(), - )]); + let (credentials, minter) = minting_credentials(vec![ + MintAction::Token("ghs_gen1", chrono::Duration::from_std(ttl).unwrap()), + MintAction::Token("ghs_gen2", chrono::Duration::minutes(60)), + ]); let sandbox = ScriptedGitSandbox::new(vec![ failed_exec("remote: Repository not found."), failed_exec("remote: Repository not found."), - ok_fabro_exec(), + ok_exec(), ]); let report = git_push( &sandbox.run, - Some((&state, ORIGIN)), + Some(&credentials), REFSPEC, - &RetryPlan::checkpoint_push(), + &checkpoint_push_policy(), ) .await - .expect("push should recover"); + .expect("push recovers"); - assert_eq!(minter.calls(), 1, "no mid-operation mint"); - let generations: Vec = report - .attempts - .iter() - .map(|attempt| attempt.token.expect("token recorded").generation) - .collect(); - assert_eq!(generations, vec![1, 1, 1]); + assert_eq!(minter.calls(), 1, "the operation never re-resolves"); + assert_eq!(sandbox.push_tokens(), vec![Some("ghs_gen1".to_owned()); 3]); + assert!( + report + .attempts + .iter() + .all(|attempt| attempt.token.map(|token| token.generation) == Some(1)) + ); } #[tokio::test(start_paused = true)] async fn static_credential_auth_failure_fails_fast() { - let source = InstallationTokenSource::for_origin( - &fabro_github::GitHubCredentials::Pat("ghp_pat".to_string()), - ORIGIN, - serde_json::json!({ "contents": "write" }), - ) - .unwrap(); - let state = PushCredentialState::new(Some(source)); - seed_clone_token(&state).await; - let sandbox = ScriptedGitSandbox::new(vec![failed_exec( - "fatal: Authentication failed for 'https://github.com/fabro-testing/repo'", - )]); + let credentials = + RepoCredentials::new(Some(InstallationTokenSource::pat("ghp_static".to_owned()))); + let sandbox = ScriptedGitSandbox::new(vec![failed_exec("remote: Repository not found.")]); let push_error = git_push( &sandbox.run, - Some((&state, ORIGIN)), + Some(&credentials), REFSPEC, - &RetryPlan::publish_push(), + &publish_push_policy(), ) .await .expect_err("static credentials cannot become valid by waiting"); assert_eq!(push_error.report.attempts.len(), 1); assert_eq!(push_error.report.attempts[0].retry_reason, None); - assert!(push_error.report.attempts[0].token.unwrap().is_static()); + assert_eq!( + push_error.report.attempts[0] + .token + .map(|token| token.generation), + Some(0) + ); + assert_eq!(sandbox.push_tokens(), vec![Some("ghp_static".to_owned())]); } - /// Clone seeding closes the "nothing was ever embedded" hole: when the - /// first refresh mint fails, the push falls back to the clone token - /// recorded as last-embedded instead of aborting. + /// A refresh that fails while the cached token is still valid pushes + /// with the cached token. #[tokio::test(start_paused = true)] - async fn mint_failure_falls_back_to_the_clone_token() { - let (state, minter) = minting_state(vec![ - MintAction::Token("ghs_clone", chrono::Duration::minutes(5)), - // The clone token is inside the margin, so lease acquisition - // re-mints and fails. - MintAction::Error("mint failed"), + async fn mint_failure_falls_back_to_the_cached_token() { + // The clone token is already inside the refresh margin, so the + // push's resolve tries to re-mint and fails. + let (credentials, minter) = minting_credentials(vec![ + MintAction::Token( + "ghs_clone", + chrono::Duration::from_std( + REFRESH_MARGIN + .checked_sub(Duration::from_mins(1)) + .expect("the margin is longer than a minute"), + ) + .unwrap(), + ), + MintAction::Error("github unavailable"), ]); - seed_clone_token(&state).await; - let sandbox = ScriptedGitSandbox::new(vec![ok_fabro_exec()]); + seed_clone_token(&credentials).await; + let sandbox = ScriptedGitSandbox::new(vec![ok_exec()]); let report = git_push( &sandbox.run, - Some((&state, ORIGIN)), + Some(&credentials), REFSPEC, - &RetryPlan::checkpoint_push(), + &checkpoint_push_policy(), ) .await - .expect("push proceeds with the still-valid clone token"); + .expect("the cached token still pushes"); - assert_eq!(minter.calls(), 2); - let attempt = &report.attempts[0]; - assert!(attempt.success); - assert_eq!(attempt.refresh_error, Some(RefreshErrorKind::Mint)); + assert_eq!(minter.calls(), 2, "the push tried to refresh once"); + assert_eq!(sandbox.push_tokens(), vec![Some("ghs_clone".to_owned())]); assert_eq!( - attempt.token.expect("fallback token recorded").generation, - 1, - "attempts classify against the embedded clone token, never None" - ); - assert_eq!( - attempt.credential_action, - Some(RemoteCredentialAction::Unchanged) + report.attempts[0].token.map(|token| token.generation), + Some(1) ); } #[tokio::test(start_paused = true)] - async fn acquisition_fails_when_mint_fails_and_nothing_was_embedded() { - let (state, _minter) = minting_state(vec![MintAction::Error("mint failed")]); + async fn mint_failure_without_a_cached_token_fails_before_any_push() { + let (credentials, minter) = + minting_credentials(vec![MintAction::Error("github unavailable")]); let sandbox = ScriptedGitSandbox::new(vec![]); let push_error = git_push( &sandbox.run, - Some((&state, ORIGIN)), + Some(&credentials), REFSPEC, - &RetryPlan::checkpoint_push(), + &checkpoint_push_policy(), ) .await - .expect_err("there is nothing to push with"); + .expect_err("no token to push with"); assert!(push_error.report.attempts.is_empty()); - assert!(push_error.error.to_string().contains("token_mint_failed")); assert_eq!(sandbox.push_count(), 0); - } - - /// Late-mint recovery: the fallback push fails on the expired-ish old - /// token, a later attempt's resolve retry succeeds, the target embeds, - /// and the push recovers — all inside one operation's budget. - #[tokio::test(start_paused = true)] - async fn late_mint_recovery_lands_the_target_inside_the_operation() { - let (state, minter) = minting_state(vec![ - MintAction::Token("ghs_gen1", chrono::Duration::minutes(5)), - MintAction::Error("mint failed"), - MintAction::Token("ghs_gen2", chrono::Duration::minutes(60)), - ]); - seed_clone_token(&state).await; - let sandbox = ScriptedGitSandbox::new(vec![ - failed_exec("fatal: Authentication failed for 'https://github.com'"), - ok_fabro_exec(), - ]); - - let report = git_push( - &sandbox.run, - Some((&state, ORIGIN)), - REFSPEC, - &RetryPlan::checkpoint_push(), - ) - .await - .expect("late mint should recover the push"); - - assert_eq!(minter.calls(), 3); - let first = &report.attempts[0]; - assert_eq!(first.refresh_error, Some(RefreshErrorKind::Mint)); - assert_eq!(first.token.unwrap().generation, 1); - let second = &report.attempts[1]; - assert!(second.success); - assert_eq!(second.refresh_error, None); - assert_eq!(second.token.unwrap().generation, 2); - assert_eq!( - second.credential_action, - Some(RemoteCredentialAction::Embedded), - "the report shows the single generation transition" + assert_eq!(minter.calls(), 1); + assert!( + push_error + .error + .to_string() + .contains("Failed to refresh GitHub App credentials"), + "{}", + push_error.error ); } - /// A failed `set-url` defers the embed: attempt 1 records the old - /// generation with the refresh error, attempt 2 lands the target, and the - /// report shows the one generation transition via `credential_action`. + /// The token reaches git through the driver's per-call rewrite and never + /// through the remote URL. #[tokio::test(start_paused = true)] - async fn set_url_failure_defers_the_embed_until_the_next_attempt() { - let (state, minter) = minting_state(vec![ - MintAction::Token("ghs_gen1", chrono::Duration::minutes(5)), - MintAction::Token("ghs_gen2", chrono::Duration::minutes(60)), - ]); - seed_clone_token(&state).await; - let sandbox = ScriptedGitSandbox::with_set_url_results( - vec![ - failed_exec("error: RPC failed; connection reset by peer"), - ok_fabro_exec(), - ], - vec![failed_exec("error: could not lock config file")], - ); - - let report = git_push( - &sandbox.run, - Some((&state, ORIGIN)), - REFSPEC, - &RetryPlan::checkpoint_push(), - ) - .await - .expect("deferred embed should land on the retry"); - - assert_eq!( - minter.calls(), - 2, - "the successful resolve is never repeated" - ); - let first = &report.attempts[0]; - assert_eq!(first.refresh_error, Some(RefreshErrorKind::SetUrl)); - assert_eq!( - first.token.unwrap().generation, - 1, - "pin stays on the old token" - ); - assert_eq!( - first.credential_action, - Some(RemoteCredentialAction::Unchanged) - ); - let second = &report.attempts[1]; - assert_eq!(second.token.unwrap().generation, 2); - assert_eq!( - second.credential_action, - Some(RemoteCredentialAction::Embedded) - ); - assert!(second.success); - } - - #[tokio::test(start_paused = true)] - async fn timed_out_set_url_stops_before_push_while_it_may_still_run() { - let (state, minter) = minting_state(vec![ - MintAction::Token("ghs_gen1", chrono::Duration::minutes(5)), - MintAction::Token("ghs_gen2", chrono::Duration::minutes(60)), - ]); - seed_clone_token(&state).await; - let sandbox = ScriptedGitSandbox::with_set_url_results(vec![], vec![timed_out_exec()]); - - let push_error = git_push( - &sandbox.run, - Some((&state, ORIGIN)), - REFSPEC, - &RetryPlan::checkpoint_push(), - ) - .await - .expect_err("a timed-out set-url can still rewrite origin later"); - - assert_eq!(minter.calls(), 2); - assert!(push_error.report.attempts.is_empty()); - assert_eq!(sandbox.push_count(), 0); - } - - /// Remote drift: agent code rewrote `origin`, so the push fails on auth - /// even though the tracked generation looks current. The first - /// auth-shaped failure earns one forced re-embed of the pinned token. - #[tokio::test(start_paused = true)] - async fn remote_drift_gets_one_forced_reembed_of_the_pinned_token() { - let (state, minter) = minting_state(vec![MintAction::Token( + async fn credentials_travel_per_call_and_never_touch_the_remote() { + let (credentials, _minter) = minting_credentials(vec![MintAction::Token( "ghs_gen1", chrono::Duration::minutes(60), )]); - seed_clone_token(&state).await; - let sandbox = ScriptedGitSandbox::new(vec![ - failed_exec( - "fatal: could not read Username for 'https://github.com': No such device or address\nremote: Repository not found.", - ), - ok_fabro_exec(), - ]); + let sandbox = ScriptedGitSandbox::new(vec![ok_exec()]); - let report = git_push( + git_push( &sandbox.run, - Some((&state, ORIGIN)), + Some(&credentials), REFSPEC, - &RetryPlan::checkpoint_push(), + &checkpoint_push_policy(), ) .await - .expect("drift repair should restore the pinned credentials"); + .expect("push succeeds"); - assert_eq!(minter.calls(), 1, "drift repair re-embeds, never re-mints"); - assert_eq!( - report.attempts[0].credential_action, - Some(RemoteCredentialAction::Unchanged), - "before the failure the tracked generation matched" + let commands = sandbox.commands(); + assert!( + commands.iter().all(|command| !command.contains("set-url")), + "{commands:#?}" ); - assert_eq!( - report.attempts[1].credential_action, - Some(RemoteCredentialAction::Embedded), - "the retry force-re-embeds the pinned token" + let push = &sandbox.pushes()[0]; + assert!( + push.contains("insteadOf=https://github.com/fabro-testing/repo"), + "{push}" + ); + assert!( + push.contains("'push' 'origin' 'refs/heads/fabro/run/"), + "{push}" ); - let set_urls = sandbox.set_url_commands(); - assert_eq!(set_urls.len(), 1); - assert!(set_urls[0].contains("ghs_gen1")); } #[tokio::test(start_paused = true)] async fn push_without_managed_credentials_reports_no_token() { - let sandbox = ScriptedGitSandbox::new(vec![ok_fabro_exec()]); + let sandbox = ScriptedGitSandbox::new(vec![ok_exec()]); - let report = git_push(&sandbox.run, None, REFSPEC, &RetryPlan::checkpoint_push()) + let report = git_push(&sandbox.run, None, REFSPEC, &checkpoint_push_policy()) .await .expect("push succeeds"); assert_eq!(report.attempts.len(), 1); assert_eq!(report.attempts[0].token, None); - assert_eq!(report.attempts[0].credential_action, None); + assert_eq!(sandbox.push_tokens(), vec![None]); } #[tokio::test(start_paused = true)] @@ -1497,7 +815,7 @@ mod push_tests { "fatal: Authentication failed for 'https://github.com/fabro-testing/repo'", )]); - let push_error = git_push(&sandbox.run, None, REFSPEC, &RetryPlan::publish_push()) + let push_error = git_push(&sandbox.run, None, REFSPEC, &publish_push_policy()) .await .expect_err("no credentials to wait on"); @@ -1509,7 +827,7 @@ mod push_tests { async fn timed_out_push_is_not_retried_while_the_remote_process_may_still_run() { let sandbox = ScriptedGitSandbox::new(vec![timed_out_exec()]); - let push_error = git_push(&sandbox.run, None, REFSPEC, &RetryPlan::publish_push()) + let push_error = git_push(&sandbox.run, None, REFSPEC, &publish_push_policy()) .await .expect_err("an unconfirmed timeout must fail without another push"); @@ -1519,16 +837,15 @@ mod push_tests { } #[tokio::test(start_paused = true)] - async fn retry_deadline_includes_credential_lease_acquisition() { + async fn retry_deadline_includes_credential_resolution() { let source = installation_token_source("fabro-testing/repo", Arc::new(SlowMinter)); - let state = PushCredentialState::new(Some(source)); + let credentials = RepoCredentials::new(Some(source)); let sandbox = ScriptedGitSandbox::new(vec![]); - let mut plan = RetryPlan::checkpoint_push(); - plan.max_elapsed = Some(Duration::from_secs(1)); + let policy = checkpoint_push_policy().max_elapsed(Duration::from_secs(1)); - let push_error = git_push(&sandbox.run, Some((&state, ORIGIN)), REFSPEC, &plan) + let push_error = git_push(&sandbox.run, Some(&credentials), REFSPEC, &policy) .await - .expect_err("credential acquisition must stop at the operation deadline"); + .expect_err("credential resolution must stop at the operation deadline"); assert!(push_error.report.attempts.is_empty()); assert_eq!(sandbox.push_count(), 0); @@ -1538,10 +855,9 @@ mod push_tests { #[tokio::test(start_paused = true)] async fn expired_retry_deadline_does_not_launch_a_zero_timeout_push() { let sandbox = ScriptedGitSandbox::new(vec![]); - let mut plan = RetryPlan::checkpoint_push(); - plan.max_elapsed = Some(Duration::ZERO); + let policy = checkpoint_push_policy().max_elapsed(Duration::ZERO); - let push_error = git_push(&sandbox.run, None, REFSPEC, &plan) + let push_error = git_push(&sandbox.run, None, REFSPEC, &policy) .await .expect_err("an expired operation must stop before exec"); @@ -1552,191 +868,6 @@ mod push_tests { #[cfg(test)] mod tests { - use super::*; - - #[test] - fn exec_result_fields() { - let result = ExecResult { - stdout: "out".into(), - stderr: "err".into(), - exit_code: Some(1), - termination: CommandTermination::Exited, - duration_ms: 5000, - }; - assert_eq!(result.exit_code, Some(1)); - assert_eq!(result.termination, CommandTermination::Exited); - assert_eq!(result.duration_ms, 5000); - } - - #[test] - fn exec_result_helpers_convert_failure_to_exec_error() { - let result = ExecResult { - stdout: "out".into(), - stderr: "fatal: could not read Username".into(), - exit_code: Some(128), - termination: CommandTermination::Exited, - duration_ms: 42, - }; - let error = result.into_result("git push").unwrap_err(); - let crate::Error::Exec { label, result, .. } = &error else { - panic!("expected Error::Exec, got {error:?}"); - }; - assert_eq!(label, "git push"); - assert_eq!(result.exit_code, Some(128)); - assert!(error.to_string().contains("no credentials in origin URL")); - } - - #[test] - fn exec_result_success_honors_timeouts() { - let success = ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: Some(0), - termination: CommandTermination::Exited, - duration_ms: 1, - }; - assert!(success.is_success()); - - let timeout = ExecResult { - exit_code: None, - termination: CommandTermination::TimedOut, - ..success - }; - assert!(!timeout.is_success()); - } - - #[test] - fn exec_result_redactor_applies_to_stderr_and_stdout() { - let result = ExecResult { - stdout: "stdout https://token@example.com".into(), - stderr: "stderr https://token@example.com".into(), - exit_code: Some(1), - termination: CommandTermination::Exited, - duration_ms: 1, - }; - let error = result.into_exec_error_with_redactor("git set-url", |s| { - s.replace("https://token@example.com", "https://****@example.com") - }); - - let crate::Error::Exec { result, .. } = &error else { - panic!("expected Error::Exec, got {error:?}"); - }; - assert_eq!(result.stderr, "stderr https://****@example.com"); - assert_eq!(result.stdout, "stdout https://****@example.com"); - } - - #[test] - fn exec_result_redacts_before_taking_tail() { - let secret = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"; - let result = ExecResult { - stdout: format!("{} {secret} done", "context ".repeat(20)), - stderr: String::new(), - exit_code: Some(1), - termination: CommandTermination::Exited, - duration_ms: 1, - }; - - let tail = result - .redacted_output_tail(32) - .expect("redacted output tail"); - let stdout = tail.stdout.expect("stdout tail"); - assert!(stdout.contains("REDACTED"), "{stdout}"); - assert!(!stdout.contains("F0gH3jE6pA"), "{stdout}"); - assert!(tail.stdout_truncated); - } - - #[test] - fn exec_result_tail_sanitizes_terminal_control_sequences() { - let result = ExecResult { - stdout: "\u{1b}[31mred\u{1b}[0m \u{1b}]0;window-title\u{7}shown \ - \u{1b}(Bset \u{1b}Mtwo-byte \u{8}backspace" - .to_string(), - stderr: String::new(), - exit_code: Some(1), - termination: CommandTermination::Exited, - duration_ms: 1, - }; - - let tail = result - .redacted_output_tail(1024) - .expect("redacted output tail"); - let stdout = tail.stdout.expect("stdout tail"); - assert_eq!(stdout, "red shown set two-byte backspace"); - } - - #[cfg(unix)] - #[test] - #[expect( - clippy::disallowed_methods, - reason = "test intentionally creates host process output for conversion coverage" - )] - fn from_process_output_uses_minus_one_for_signal_exit_without_code() { - let output = std::process::Command::new("sh") - .arg("-c") - .arg("printf out; printf err >&2; kill -9 $$") - .output() - .expect("signal-killed process output"); - - let result = ExecResult::from_process_output(output, 12); - - assert_eq!(result.stdout, "out"); - assert_eq!(result.stderr, "err"); - assert_eq!(result.exit_code, Some(-1)); - assert_eq!(result.termination, CommandTermination::Exited); - assert_eq!(result.duration_ms, 12); - } - - #[cfg(unix)] - #[test] - #[expect( - clippy::disallowed_methods, - reason = "test intentionally creates host process output for conversion coverage" - )] - fn from_process_output_handles_lossy_non_utf8_output() { - let output = std::process::Command::new("sh") - .arg("-c") - .arg("printf '\\377'; printf '\\376' >&2") - .output() - .expect("non-utf8 process output"); - - let result = ExecResult::from_process_output(output, 3); - let tail = result - .redacted_output_tail(16) - .expect("redacted output tail"); - - assert!(tail.stdout.expect("stdout tail").len() <= 16); - assert!(tail.stderr.expect("stderr tail").len() <= 16); - } - - #[test] - fn default_exec_output_tail_serialized_budget_stays_below_40_kib() { - let result = ExecResult { - stdout: "o".repeat(DEFAULT_EXEC_OUTPUT_TAIL_BYTES + 128), - stderr: "e".repeat(DEFAULT_EXEC_OUTPUT_TAIL_BYTES + 128), - exit_code: Some(1), - termination: CommandTermination::Exited, - duration_ms: 1, - }; - - let tail = result.default_redacted_output_tail().expect("tail present"); - assert_eq!( - tail.stdout.as_deref().map(str::len), - Some(DEFAULT_EXEC_OUTPUT_TAIL_BYTES) - ); - assert_eq!( - tail.stderr.as_deref().map(str::len), - Some(DEFAULT_EXEC_OUTPUT_TAIL_BYTES) - ); - assert!(tail.stdout_truncated); - assert!(tail.stderr_truncated); - let serialized = serde_json::to_vec(&tail).expect("serialize tail"); - assert!( - serialized.len() < 40 * 1024, - "tail JSON was {} bytes", - serialized.len() - ); - } - #[test] fn sandbox_tracing_events_do_not_log_raw_command_or_stdin_fields() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); @@ -1749,27 +880,6 @@ mod tests { ); } - #[test] - fn format_lines_numbered_basic() { - let result = format_lines_numbered("hello\nworld\nfoo", None, None); - assert_eq!(result, "1 | hello\n2 | world\n3 | foo\n"); - } - - #[test] - fn format_lines_numbered_with_offset_limit() { - let result = format_lines_numbered("a\nb\nc\nd\ne", Some(2), Some(2)); - assert!(result.contains("2 | b")); - assert!(result.contains("3 | c")); - assert!(!result.contains("1 | a")); - assert!(!result.contains("4 | d")); - } - - #[test] - fn shell_quote_basic() { - assert_eq!(shell_quote("hello"), "hello"); - assert_eq!(shell_quote("hello world"), "'hello world'"); - } - #[expect( clippy::disallowed_methods, reason = "unit test performs a small synchronous source scan of local Rust files" diff --git a/lib/components/fabro-sandbox/src/sandbox_spec.rs b/lib/components/fabro-sandbox/src/sandbox_spec.rs index 0efe6611e..3250163c8 100644 --- a/lib/components/fabro-sandbox/src/sandbox_spec.rs +++ b/lib/components/fabro-sandbox/src/sandbox_spec.rs @@ -4,201 +4,138 @@ use std::sync::Arc; use anyhow::Context as _; use fabro_github::GitHubCredentials; use fabro_types::{RunId, RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind}; -use sandbox_driver::EventContext; +use sandbox_driver::{EventContext, SandboxSource, SandboxSpec as DriverSpec}; use crate::driver::ProviderAccess; -use crate::driver_sandbox::{LayoutSource, RunSandbox, local_sandbox_with_events}; -use crate::options::SandboxOptions; +use crate::driver_sandbox::{LayoutSource, RunSandbox}; +use crate::environment::CloneRequest; use crate::{clone_source, provider_sandbox}; -/// Options for sandbox initialization and construction. +/// A run's sandbox on any provider fabro can name: a bundled kind in +/// process or a sandbox-driver plugin. What the environment asked for, and +/// how the repository is cloned into it. #[derive(Clone, Debug)] -pub enum SandboxSpec { - Local { - working_directory: PathBuf, - }, - /// A sandbox on any provider fabro can name: a bundled kind in process - /// or a sandbox-driver plugin. - Provider(Box), -} - -/// A run's sandbox on a provider: what the environment asked for and how -/// the repository is cloned into it. -#[derive(Clone, Debug)] -pub struct ProviderSandboxSpec { - pub kind: SandboxProviderKind, +pub struct SandboxSpec { + pub kind: SandboxProviderKind, /// The provider settings and vault credentials the kind needs. - pub access: ProviderAccess, - pub options: SandboxOptions, - pub github_app: Option, - pub run_id: Option, - pub clone_origin_url: Option, - pub clone_branch: Option, - pub clone_tag: Option, - pub clone_commit_sha: Option, + pub access: ProviderAccess, + /// The environment's request, as the driver spec every provider + /// starts from. + pub spec: DriverSpec, + pub clone: CloneRequest, + pub github_app: Option, + pub run_id: Option, } impl SandboxSpec { - pub fn provider(&self) -> SandboxProviderKind { - match self { - Self::Local { .. } => SandboxProviderKind::LOCAL, - Self::Provider(spec) => spec.kind.clone(), + /// A sandbox on this host at `working_directory`, the fabro `local` + /// kind. The directory is designated: the sandbox uses it in place, + /// never removes it, and clones nothing into it. The Host provider has + /// no image, labels, or lifecycle timers, so the spec names only the + /// directory. + #[must_use] + pub fn local(working_directory: impl Into, access: ProviderAccess) -> Self { + Self { + kind: SandboxProviderKind::LOCAL, + access, + spec: DriverSpec::new(SandboxSource::HostDirectory) + .working_directory(working_directory.into().display().to_string()), + clone: CloneRequest::none(), + github_app: None, + run_id: None, } } + pub fn provider(&self) -> SandboxProviderKind { + self.kind.clone() + } + pub fn provider_name(&self) -> String { - self.provider().to_string() + self.kind.to_string() + } + + /// The directory the spec designates on the provider, when it names one. + #[must_use] + pub fn working_directory(&self) -> Option<&str> { + self.spec.working_directory.as_deref() } /// The image the run record names for this sandbox: the environment's, - /// or the provider's default when the environment names none. A local - /// sandbox has no image. + /// or the provider's default when the environment names none. pub fn image(&self) -> Option { - match self { - Self::Local { .. } => None, - Self::Provider(spec) => provider_sandbox::recorded_image(&spec.kind, &spec.options), - } + provider_sandbox::recorded_image(&self.kind, &self.spec) } /// Build initialized sandbox metadata for persistence. - pub fn to_run_sandbox_instance( - &self, - sandbox: &RunSandbox, - run_id: RunId, - ) -> RunSandboxInstance { + pub fn to_run_sandbox_instance(&self, sandbox: &RunSandbox) -> RunSandboxInstance { let working_directory = sandbox.working_directory().to_string(); - let id = { - let info = sandbox.sandbox_info(); - if info.is_empty() { - format!("local:{run_id}") - } else { - info - } - }; - - match self { - Self::Provider(spec) => { - let ProviderSandboxSpec { - kind, - options, - clone_origin_url, - clone_branch, - .. - } = spec.as_ref(); - let repo_cloned = clone_source::repo_cloned_for_record( - options.skip_clone, + let id = sandbox.sandbox_info(); + let clone_origin_url = &self.clone.origin_url; + let repo_cloned = + clone_source::repo_cloned_for_record(self.clone.skip, clone_origin_url.as_deref()); + // A fixed layout is known before the sandbox exists; a + // provider-chosen one only from the sandbox. + let layout = match provider_sandbox::layout_source(&self.kind) { + LayoutSource::Fixed(fixed) => { + let repo = runtime_layout_metadata( + repo_cloned, clone_origin_url.as_deref(), + &fixed.workspace_root, + &fixed.repos_root, ); - // A fixed layout is known before the sandbox exists; a - // provider-chosen one only from the sandbox. - let layout = match provider_sandbox::layout_source(kind) { - LayoutSource::Fixed(fixed) => { - let repo = runtime_layout_metadata( - repo_cloned, - clone_origin_url.as_deref(), - &fixed.workspace_root, - &fixed.repos_root, - ); - Some(crate::SandboxWorkspaceLayout { - workspace_root: fixed.workspace_root, - repos_root: fixed.repos_root, - primary_repo_path: repo - .as_ref() - .map(|layout| layout.primary_repo_path.clone()), - primary_repo_link: repo - .as_ref() - .map(|layout| layout.primary_repo_link.clone()), - }) - } - LayoutSource::ProviderWorkingDirectory => sandbox.workspace_layout(), - }; - RunSandboxInstance { - provider: kind.clone(), - image: provider_sandbox::recorded_image(kind, options), - snapshot: sandbox.snapshot_info(), - runtime: RunSandboxRuntime { - id, - working_directory, - repo_cloned, - clone_origin_url: clone_source::clean_clone_origin_for_record( - clone_origin_url.as_deref(), - ), - clone_branch: clone_branch.clone(), - workspace_root: layout.as_ref().map(|layout| layout.workspace_root.clone()), - repos_root: layout.as_ref().map(|layout| layout.repos_root.clone()), - primary_repo_path: layout - .as_ref() - .and_then(|layout| layout.primary_repo_path.clone()), - primary_repo_link: layout - .as_ref() - .and_then(|layout| layout.primary_repo_link.clone()), - }, - } + Some(crate::SandboxWorkspaceLayout { + workspace_root: fixed.workspace_root, + repos_root: fixed.repos_root, + primary_repo_path: repo.as_ref().map(|layout| layout.primary_repo_path.clone()), + primary_repo_link: repo.as_ref().map(|layout| layout.primary_repo_link.clone()), + }) } - Self::Local { .. } => RunSandboxInstance { - provider: self.provider(), - image: None, - snapshot: None, - runtime: RunSandboxRuntime { - id, - working_directory, - repo_cloned: None, - clone_origin_url: None, - clone_branch: None, - workspace_root: None, - repos_root: None, - primary_repo_path: None, - primary_repo_link: None, - }, + LayoutSource::ProviderWorkingDirectory => sandbox.workspace_layout(), + }; + RunSandboxInstance { + provider: self.kind.clone(), + image: self.image(), + snapshot: sandbox.snapshot_info(), + runtime: RunSandboxRuntime { + id, + working_directory, + repo_cloned, + clone_origin_url: clone_source::clean_clone_origin_for_record( + clone_origin_url.as_deref(), + ), + clone_branch: self.clone.branch.clone(), + workspace_root: layout.as_ref().map(|layout| layout.workspace_root.clone()), + repos_root: layout.as_ref().map(|layout| layout.repos_root.clone()), + primary_repo_path: layout + .as_ref() + .and_then(|layout| layout.primary_repo_path.clone()), + primary_repo_link: layout + .as_ref() + .and_then(|layout| layout.primary_repo_link.clone()), }, } } - /// Builds the sandbox. The driver reports its lifecycle through - /// `events`: the local sandbox's from creation here, a provider - /// sandbox's from `initialize` on. + /// Builds the sandbox; `initialize` creates it on the provider. The + /// driver reports its lifecycle through `events` from then on. pub async fn build( &self, events: Option, ) -> Result, anyhow::Error> { - match self { - Self::Local { working_directory } => { - let sandbox = local_sandbox_with_events(working_directory.clone(), events) - .await - .context("Failed to create local sandbox")?; - Ok(Arc::new(sandbox)) - } - Self::Provider(spec) => { - let ProviderSandboxSpec { - kind, - access, - options, - github_app, - run_id, - clone_origin_url, - clone_branch, - clone_tag, - clone_commit_sha, - } = spec.as_ref(); - let mut sandbox = provider_sandbox::provider_sandbox( - kind.clone(), - access, - options.clone(), - github_app.as_ref(), - *run_id, - clone_origin_url.clone(), - clone_branch.clone(), - clone_tag.clone(), - clone_commit_sha.clone(), - ) - .await - .with_context(|| format!("Failed to create {kind} sandbox"))?; - if let Some(events) = events { - sandbox.set_events(events); - } - Ok(Arc::new(sandbox)) - } + let mut sandbox = provider_sandbox::provider_sandbox( + self.kind.clone(), + &self.access, + self.spec.clone(), + &self.clone, + self.github_app.as_ref(), + self.run_id, + ) + .await + .with_context(|| format!("Failed to create {} sandbox", self.kind))?; + if let Some(events) = events { + sandbox.set_events(events); } + Ok(Arc::new(sandbox)) } } @@ -216,14 +153,24 @@ fn runtime_layout_metadata( #[cfg(test)] mod tests { - use fabro_types::RunId; use sandbox_driver_testing::ScriptedSandbox; use super::*; - fn sandbox_at(working_dir: &str) -> RunSandbox { + fn docker_spec(clone: CloneRequest) -> SandboxSpec { + SandboxSpec { + kind: SandboxProviderKind::DOCKER, + access: ProviderAccess::default(), + spec: DriverSpec::new(SandboxSource::HostDirectory), + clone, + github_app: None, + run_id: None, + } + } + + fn sandbox_at(kind: SandboxProviderKind, working_dir: &str) -> RunSandbox { RunSandbox::new( - SandboxProviderKind::DOCKER, + kind, Arc::new(ScriptedSandbox::with_id_and_working_dir( "scripted-1", working_dir, @@ -233,21 +180,14 @@ mod tests { #[test] fn docker_run_sandbox_persists_layout_metadata_for_cloned_repo() { - let spec = SandboxSpec::Provider(Box::new(ProviderSandboxSpec { - kind: SandboxProviderKind::DOCKER, - access: ProviderAccess::default(), - options: SandboxOptions::default(), - github_app: None, - run_id: None, - clone_origin_url: Some("git@github.com:brynary/rack-test.git".to_string()), - clone_branch: Some("main".to_string()), - clone_tag: None, - clone_commit_sha: None, - })); - let sandbox = sandbox_at("/workspace/rack-test"); + let spec = docker_spec(CloneRequest { + origin_url: Some("git@github.com:brynary/rack-test.git".to_string()), + branch: Some("main".to_string()), + ..CloneRequest::default() + }); + let sandbox = sandbox_at(SandboxProviderKind::DOCKER, "/workspace/rack-test"); - let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); - let record = spec.to_run_sandbox_instance(&sandbox, run_id); + let record = spec.to_run_sandbox_instance(&sandbox); let runtime = record.runtime; assert_eq!(runtime.working_directory, "/workspace/rack-test"); @@ -272,17 +212,12 @@ mod tests { #[tokio::test] async fn invalid_exact_checkout_spec_fails_before_provider_connection() { - let spec = SandboxSpec::Provider(Box::new(ProviderSandboxSpec { - kind: SandboxProviderKind::DOCKER, - access: ProviderAccess::default(), - options: SandboxOptions::default(), - github_app: None, - run_id: None, - clone_origin_url: Some("https://github.com/acme/widgets".to_string()), - clone_branch: Some("main".to_string()), - clone_tag: None, - clone_commit_sha: Some("not-a-sha".to_string()), - })); + let spec = docker_spec(CloneRequest { + origin_url: Some("https://github.com/acme/widgets".to_string()), + branch: Some("main".to_string()), + commit_sha: Some("not-a-sha".to_string()), + ..CloneRequest::default() + }); let error = spec .build(None) @@ -300,24 +235,13 @@ mod tests { #[test] fn docker_run_sandbox_omits_primary_repo_metadata_for_empty_workspace() { - let spec = SandboxSpec::Provider(Box::new(ProviderSandboxSpec { - kind: SandboxProviderKind::DOCKER, - access: ProviderAccess::default(), - options: SandboxOptions { - skip_clone: true, - ..SandboxOptions::default() - }, - github_app: None, - run_id: None, - clone_origin_url: Some("https://gitlab.com/acme/widgets".to_string()), - clone_branch: None, - clone_tag: None, - clone_commit_sha: None, - })); - let sandbox = sandbox_at("/workspace"); + let spec = docker_spec(CloneRequest { + origin_url: Some("https://gitlab.com/acme/widgets".to_string()), + ..CloneRequest::none() + }); + let sandbox = sandbox_at(SandboxProviderKind::DOCKER, "/workspace"); - let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); - let record = spec.to_run_sandbox_instance(&sandbox, run_id); + let record = spec.to_run_sandbox_instance(&sandbox); let runtime = record.runtime; assert_eq!(runtime.working_directory, "/workspace"); @@ -327,4 +251,34 @@ mod tests { assert!(runtime.primary_repo_path.is_none()); assert!(runtime.primary_repo_link.is_none()); } + + #[test] + fn local_spec_designates_the_directory_and_clones_nothing() { + let spec = SandboxSpec::local("/home/dev/project", ProviderAccess::default()); + + assert_eq!(spec.kind, SandboxProviderKind::LOCAL); + assert_eq!(spec.working_directory(), Some("/home/dev/project")); + assert!(spec.clone.skip); + assert_eq!(spec.clone.origin_url, None); + assert_eq!(spec.image(), None); + assert!(matches!(spec.spec.source, SandboxSource::HostDirectory)); + + let sandbox = sandbox_at(SandboxProviderKind::LOCAL, "/home/dev/project"); + let record = spec.to_run_sandbox_instance(&sandbox); + + assert_eq!(record.provider, SandboxProviderKind::LOCAL); + assert_eq!(record.image, None); + assert_eq!(record.snapshot, None); + assert_eq!(record.runtime.id, "scripted-1"); + assert_eq!(record.runtime.working_directory, "/home/dev/project"); + assert_eq!(record.runtime.repo_cloned, Some(false)); + assert_eq!(record.runtime.clone_origin_url, None); + assert_eq!(record.runtime.clone_branch, None); + assert_eq!( + record.runtime.workspace_root.as_deref(), + Some("/home/dev/project") + ); + assert!(record.runtime.primary_repo_path.is_none()); + assert!(record.runtime.primary_repo_link.is_none()); + } } diff --git a/lib/components/fabro-sandbox/src/terminal.rs b/lib/components/fabro-sandbox/src/terminal.rs deleted file mode 100644 index f42d6fd94..000000000 --- a/lib/components/fabro-sandbox/src/terminal.rs +++ /dev/null @@ -1,92 +0,0 @@ -use async_trait::async_trait; -use fabro_types::{BundledProvider, RunId, RunSandboxInstance}; - -use crate::driver::ProviderAccess; -use crate::reconnect; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct TerminalSize { - pub cols: u16, - pub rows: u16, -} - -impl Default for TerminalSize { - fn default() -> Self { - Self { - cols: 120, - rows: 32, - } - } -} - -#[async_trait] -pub trait TerminalSession: Send + Sync { - async fn write_input(&self, bytes: &[u8]) -> crate::Result<()>; - async fn read_output(&self) -> crate::Result>>; - async fn resize(&self, size: TerminalSize) -> crate::Result<()>; - async fn close(&self) -> crate::Result<()>; -} - -/// A terminal over the driver's Pty facet. -pub struct DriverTerminalSession { - session: Box, -} - -impl DriverTerminalSession { - #[must_use] - pub fn new(session: Box) -> Self { - Self { session } - } -} - -#[async_trait] -impl TerminalSession for DriverTerminalSession { - async fn write_input(&self, bytes: &[u8]) -> crate::Result<()> { - self.session - .write_input(bytes) - .await - .map_err(|err| crate::Error::context("Failed to write terminal input", err)) - } - - async fn read_output(&self) -> crate::Result>> { - self.session - .read_output() - .await - .map_err(|err| crate::Error::context("Failed to read terminal output", err)) - } - - async fn resize(&self, size: TerminalSize) -> crate::Result<()> { - self.session - .resize(sandbox_driver::PtySize { - rows: size.rows, - cols: size.cols, - }) - .await - .map_err(|err| crate::Error::context("Failed to resize terminal", err)) - } - - async fn close(&self) -> crate::Result<()> { - self.session - .close() - .await - .map_err(|err| crate::Error::context("Failed to close terminal", err)) - } -} - -pub async fn open_terminal_for_run( - record: &RunSandboxInstance, - access: &ProviderAccess, - run_id: Option, - size: TerminalSize, -) -> crate::Result> { - if record.provider.bundled() == Some(BundledProvider::Local) { - return Err(crate::Error::message( - "Local sandboxes do not support embedded terminals", - )); - } - let sandbox = reconnect::reconnect_driver_for_run(record, access, run_id, None) - .await - .map_err(|err| crate::Error::context_anyhow("Failed to reconnect sandbox", err))?; - sandbox.activate().await?; - Ok(Box::new(sandbox.open_terminal(size).await?)) -} diff --git a/lib/components/fabro-sandbox/src/test_support.rs b/lib/components/fabro-sandbox/src/test_support.rs index d8ebabb88..05c84b16e 100644 --- a/lib/components/fabro-sandbox/src/test_support.rs +++ b/lib/components/fabro-sandbox/src/test_support.rs @@ -1,23 +1,65 @@ //! Test doubles for fabro's sandbox layer. //! -//! [`MockSandbox`] is a configuration and a recorder over the sandbox -//! driver's scripted double: a test writes down the files, the command -//! answer, and the failures it wants, takes a [`RunSandbox`] from it, and -//! reads back what the code under test ran or wrote. Nothing here fakes +//! [`MockSandbox`] is a configuration over the sandbox driver's scripted +//! double: a test writes down the files, the command answer, and the +//! failures it wants, and takes a [`RunSandbox`] from it. What the code +//! under test ran or wrote is read back from the driver double itself, +//! through [`MockSandbox::driver`]; the few accessors here convert what a +//! spec records into the shape fabro's tests assert on. Nothing here fakes //! fabro's own logic; every call goes through the real `RunSandbox` and //! fabro's exec policy, down to the scripted driver. use std::collections::HashMap; +use std::path::Path; use std::sync::{Arc, OnceLock}; use std::time::Duration; -use fabro_types::{CommandTermination, SandboxProviderKind}; -use sandbox_driver::{GrepMatch, PlatformInfo, SandboxState, Termination, WalkedFile}; -pub use sandbox_driver_testing::{ScriptedExec, ScriptedSandbox, ScriptedStdioProcess}; +use fabro_types::SandboxProviderKind; +use sandbox_driver::{ + ExecResult, GrepMatch, PlatformInfo, SandboxState, StderrTail, Termination, WalkedFile, +}; +use sandbox_driver_host::HostProvider; +pub use sandbox_driver_testing::{ + ScriptedExec, ScriptedProvider, ScriptedSandbox, ScriptedStdioProcess, +}; use tokio::io::DuplexStream; +use crate::driver::ConnectedProvider; use crate::driver_sandbox::RunSandbox; -use crate::sandbox::{ExecResult, SandboxFile, StderrCollector}; +use crate::managed_labels::{MANAGED_LABEL, MANAGED_LABEL_VALUE}; +use crate::sandbox::SandboxFile; + +/// The id a run record carries for a local sandbox at `working_directory`, +/// as the Host provider derives it from the canonical path. A record a test +/// writes by hand reconnects the way one fabro wrote would. The directory +/// must exist. +pub async fn local_sandbox_id(working_directory: &Path) -> String { + HostProvider::directory_id(working_directory) + .await + .unwrap_or_else(|| { + panic!( + "no local sandbox id for {}: the directory must exist", + working_directory.display() + ) + }) + .to_string() +} + +/// A driver [`ExecResult`] with the given streams, for scripting a mock +/// sandbox's answers. +#[must_use] +pub fn exec_result( + stdout: &str, + stderr: &str, + exit_code: Option, + termination: Termination, + duration_ms: u64, +) -> ExecResult { + let mut result = ExecResult::new(termination, exit_code, Duration::from_millis(duration_ms)); + result.stdout = stdout.as_bytes().to_vec(); + result.stderr = stderr.as_bytes().to_vec(); + result +} // --- MockSandbox --- @@ -71,12 +113,11 @@ impl Default for MockSandbox { fn default() -> Self { Self { files: HashMap::new(), - exec_result: ExecResult { - stdout: "mock output".into(), - stderr: String::new(), - exit_code: Some(0), - termination: CommandTermination::Exited, - duration_ms: 10, + exec_result: { + let mut result = + ExecResult::new(Termination::Exited, Some(0), Duration::from_millis(10)); + result.stdout = b"mock output".to_vec(); + result }, exec_error: None, working_dir: "/work", @@ -145,25 +186,16 @@ impl MockSandbox { ) -> &Self { self.driver().scripted_exec().respond_with(move |spec| { let command = spec.args.last().map(String::as_str).unwrap_or_default(); - responder(command).map(|result| driver_result(&result)) + responder(command) }); self } - /// Queues the result for the next command, ahead of `exec_result`. - /// Results answer in the order they were pushed. - pub fn push_exec_result(&self, result: &ExecResult) -> &Self { - self.driver() - .scripted_exec() - .push_result(driver_result(result)); - self - } - fn built(&self) -> &Built { self.built.get_or_init(|| { let driver = Arc::new(self.build_driver()); - // An isolated provider: explicit environment passes as the - // caller composed it, as it does for Docker and Daytona runs. + // The kind is nominal for exec: the explicit environment reaches + // the scripted driver as the caller composed it on every provider. let run = RunSandbox::new_with_platform( SandboxProviderKind::DOCKER, Arc::clone(&driver) as Arc, @@ -197,7 +229,7 @@ impl MockSandbox { let exec = driver.scripted_exec(); match &self.exec_error { Some(message) => exec.fail_by_default(message.clone()), - None => exec.set_default(driver_result(&self.exec_result)), + None => exec.set_default(self.exec_result.clone()), }; exec.set_streams_separated(self.streams_separated); if let Some(message) = &self.stdio_process_error { @@ -240,17 +272,12 @@ impl MockSandbox { .unwrap_or_default() } - /// The Bash source of every command run so far, in order. - pub fn captured_commands(&self) -> Vec { - self.recorded() - .iter() - .map(|spec| spec.args.last().cloned().unwrap_or_default()) - .collect() - } - - /// The last command's Bash source. + /// The last command's Bash source. Every command, in order, is + /// `driver().scripted_exec().commands()`. pub fn captured_command(&self) -> Option { - self.captured_commands().pop() + self.recorded() + .last() + .and_then(|spec| spec.args.last().cloned()) } /// The last command's timeout in milliseconds. @@ -270,25 +297,9 @@ impl MockSandbox { .collect() } - /// Whether each command was given the run's cancellation to stop on, - /// in order. - pub fn captured_term_stops(&self) -> Vec { - self.built - .get() - .map(|built| built.driver.scripted_exec().term_stops()) - .unwrap_or_default() - } - - /// The working directory of every command, in order. - pub fn captured_working_dirs(&self) -> Vec> { - self.recorded() - .iter() - .map(|spec| spec.working_dir.clone()) - .collect() - } - /// The explicit variables of the last command as the caller passed them. - /// The exec policy's own `BASH_ENV` blank is not the caller's. + /// The driver's Bash helper records its own `BASH_ENV` blank on the + /// spec; that is not the caller's. pub fn captured_env_vars(&self) -> Option> { self.recorded().last().map(|spec| { spec.env @@ -299,13 +310,6 @@ impl MockSandbox { }) } - /// The bytes the last streaming command was fed on standard input. - pub fn captured_stdin(&self) -> Option> { - self.built - .get() - .and_then(|built| built.driver.scripted_exec().captured_stdin().pop()) - } - /// Every file written so far as `(path, content)`, in order. pub fn written_files(&self) -> Vec<(String, String)> { self.built @@ -321,65 +325,6 @@ impl MockSandbox { }) .unwrap_or_default() } - - /// Every file deleted so far by absolute path, in order. - pub fn deleted_files(&self) -> Vec { - self.built - .get() - .map(|built| built.driver.memory_fs().deletes()) - .unwrap_or_default() - } - - /// How many times the code under test asked whether a path exists. - pub fn exists_calls(&self) -> usize { - self.built - .get() - .map_or(0, |built| built.driver.memory_fs().exists_calls()) - } - - pub fn start_count(&self) -> u32 { - self.built - .get() - .map_or(0, |built| built.driver.start_count()) - } - - pub fn stop_count(&self) -> u32 { - self.built - .get() - .map_or(0, |built| built.driver.stop_count()) - } - - pub fn delete_count(&self) -> u32 { - self.built - .get() - .map_or(0, |built| built.driver.delete_count()) - } - - /// How many walks the code under test ran. - pub fn walk_files_was_called(&self) -> bool { - self.built - .get() - .is_some_and(|built| built.driver.scripted_search().walk_calls() > 0) - } -} - -/// The driver result fabro's exec policy reads back as `result`. -fn driver_result(result: &ExecResult) -> sandbox_driver::ExecResult { - let termination = match result.termination { - CommandTermination::TimedOut => Termination::TimedOut, - CommandTermination::Cancelled => Termination::Cancelled, - // `CommandTermination` is non-exhaustive; `Exited` and anything newer - // read back as a plain exit. - _ => Termination::Exited, - }; - let mut driver = sandbox_driver::ExecResult::new( - termination, - result.exit_code, - Duration::from_millis(result.duration_ms), - ); - driver.stdout = result.stdout.clone().into_bytes(); - driver.stderr = result.stderr.clone().into_bytes(); - driver } // --- MockStdioProcess --- @@ -387,21 +332,17 @@ fn driver_result(result: &ExecResult) -> sandbox_driver::ExecResult { /// A stdio process a test drives, over the driver's scripted process. /// /// The driver closure receives the process's end of standard input, its -/// end of standard output, and fabro's stderr collector for the process. +/// end of standard output, and the rolling stderr tail the process reports. pub struct MockStdioProcess { inner: std::sync::Mutex>, } impl MockStdioProcess { pub fn new( - driver: impl FnOnce(DuplexStream, DuplexStream, StderrCollector) + Send + 'static, + driver: impl FnOnce(DuplexStream, DuplexStream, StderrTail) + Send + 'static, ) -> Self { Self { - inner: std::sync::Mutex::new(Some(ScriptedStdioProcess::new( - move |stdin, stdout, tail| { - driver(stdin, stdout, StderrCollector::from_driver_tail(tail)); - }, - ))), + inner: std::sync::Mutex::new(Some(ScriptedStdioProcess::new(driver))), } } @@ -426,98 +367,32 @@ impl MockStdioProcess { } } -// --- FakeSandboxProvider --- +// --- Inventory doubles --- -pub use fake_provider::{FakeGet, FakeList, FakeSandboxProvider, fake_registry, fake_sandbox_info}; +/// A running scripted sandbox carrying fabro's managed label, so an owned +/// inventory lists it and attaches to it. +#[must_use] +pub fn managed_scripted_sandbox(id: &str) -> Arc { + Arc::new( + ScriptedSandbox::with_id_and_working_dir(id, "/work") + .state(SandboxState::Running) + .label(MANAGED_LABEL, MANAGED_LABEL_VALUE), + ) +} -mod fake_provider { - use std::collections::BTreeMap; - use std::sync::Arc; - - use async_trait::async_trait; - use fabro_types::{ - SandboxInfo, SandboxNetwork, SandboxProviderKind, SandboxResources, SandboxState, - SandboxTimestamps, - }; - - use crate::provider::{SandboxProvider, SandboxProviderRegistry}; - - #[derive(Clone)] - pub enum FakeList { - Ok(Vec), - Err(&'static str), +/// A connected inventory provider of `kind` holding `sandboxes`, over the +/// driver's scripted provider. +#[must_use] +pub fn scripted_inventory_provider( + kind: SandboxProviderKind, + sandboxes: Vec>, +) -> ConnectedProvider { + let provider = ScriptedProvider::new(kind.as_str()); + for sandbox in sandboxes { + provider.register(sandbox); } - - #[derive(Clone)] - pub enum FakeGet { - Found(Box), - Missing, - Err(&'static str), - } - - pub struct FakeSandboxProvider { - kind: SandboxProviderKind, - list: FakeList, - get: FakeGet, - } - - impl FakeSandboxProvider { - pub fn new(kind: SandboxProviderKind, list: FakeList, get: FakeGet) -> Self { - Self { kind, list, get } - } - } - - #[async_trait] - impl SandboxProvider for FakeSandboxProvider { - fn kind(&self) -> SandboxProviderKind { - self.kind.clone() - } - - async fn list(&self) -> crate::Result> { - match &self.list { - FakeList::Ok(sandboxes) => Ok(sandboxes.clone()), - FakeList::Err(message) => Err(crate::Error::message(*message)), - } - } - - async fn get(&self, _id: &str) -> crate::Result> { - match &self.get { - FakeGet::Found(sandbox) => Ok(Some((**sandbox).clone())), - FakeGet::Missing => Ok(None), - FakeGet::Err(message) => Err(crate::Error::message(*message)), - } - } - - async fn delete(&self, _id: &str) -> crate::Result<()> { - Ok(()) - } - } - - pub fn fake_registry(providers: Vec) -> SandboxProviderRegistry { - SandboxProviderRegistry::new( - providers - .into_iter() - .map(|provider| Arc::new(provider) as Arc) - .collect(), - ) - } - - pub fn fake_sandbox_info(provider: SandboxProviderKind, id: &str) -> SandboxInfo { - SandboxInfo { - provider, - id: id.to_string(), - display_name: None, - state: SandboxState::Running, - native_state: None, - image: None, - snapshot: None, - region: None, - web_url: None, - working_directory: None, - resources: SandboxResources::default(), - network: SandboxNetwork::unknown(), - labels: BTreeMap::new(), - timestamps: SandboxTimestamps::default(), - } + ConnectedProvider { + kind, + provider: Arc::new(provider), } } diff --git a/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs b/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs index 4ac6193c2..2d05dce0f 100644 --- a/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs +++ b/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs @@ -4,18 +4,19 @@ mod daytona_streaming_live { use anyhow::{Context, Result, ensure}; use fabro_sandbox::{ - CommandOutputCallback, DaytonaCredentials, ExecStreamingResult, ProviderAccess, RunSandbox, - SandboxOptions, SandboxProviderKind, provider_sandbox, + CloneRequest, DaytonaCredentials, ExecControls, ExecSpec, ExecStreamingResult, OutputSink, + OutputStream, ProviderAccess, RunSandbox, SandboxProviderKind, Termination, + provider_sandbox, }; use fabro_static::EnvVars; - use fabro_types::{CommandOutputStream, CommandTermination}; + use sandbox_driver::{SandboxSource, SandboxSpec}; use tokio::sync::Mutex; use tokio::time::{Instant, sleep}; use tokio_util::sync::CancellationToken; #[derive(Debug, Clone)] struct CapturedChunk { - stream: CommandOutputStream, + stream: OutputStream, text: String, } @@ -31,14 +32,8 @@ mod daytona_streaming_live { provider_sandbox( SandboxProviderKind::DAYTONA, &daytona_access(live_credentials()?), - SandboxOptions { - skip_clone: true, - ..Default::default() - }, - None, - None, - None, - None, + SandboxSpec::new(SandboxSource::HostDirectory), + &CloneRequest::none(), None, None, ) @@ -48,7 +43,7 @@ mod daytona_streaming_live { sandbox.initialize().await?; let smoke_result = run_smoke(Arc::clone(&sandbox)).await; - let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox"); + let cleanup_result = sandbox.delete().await.context("clean up Daytona sandbox"); smoke_result?; cleanup_result?; @@ -71,14 +66,8 @@ mod daytona_streaming_live { let sandbox = provider_sandbox( SandboxProviderKind::DAYTONA, &daytona_access(live_credentials()?), - SandboxOptions { - skip_clone: true, - ..Default::default() - }, - None, - None, - None, - None, + SandboxSpec::new(SandboxSource::HostDirectory), + &CloneRequest::none(), None, None, ) @@ -100,7 +89,7 @@ mod daytona_streaming_live { &format!("exec_command should run Bash-only syntax: {non_streaming:?}"), )?; ensure_contains( - &non_streaming.stdout, + &non_streaming.stdout_lossy(), "two", "exec_command should report the Bash-only result", )?; @@ -112,7 +101,7 @@ mod daytona_streaming_live { &format!("exec_command_streaming should run Bash-only syntax: {streaming:?}"), )?; ensure_contains( - &streaming.result.stdout, + &streaming.result.stdout_lossy(), "two", "exec_command_streaming should report the Bash-only result", )?; @@ -133,7 +122,7 @@ mod daytona_streaming_live { )?; ensure_eq( &stdin_result.result.stdout, - &stdin.to_string(), + &stdin.as_bytes().to_vec(), "exec_command_streaming should preserve exact stdin bytes", )?; let stdin_cleanup = sandbox @@ -147,7 +136,7 @@ mod daytona_streaming_live { ) .await?; ensure!( - stdin_cleanup.is_success(), + stdin_cleanup.success(), "Daytona stdin data must stay inert and its temporary file must be deleted: {stdin_cleanup:?}" ); @@ -155,7 +144,7 @@ mod daytona_streaming_live { } .await; - let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox"); + let cleanup_result = sandbox.delete().await.context("clean up Daytona sandbox"); checks?; cleanup_result?; @@ -177,20 +166,11 @@ mod daytona_streaming_live { let sandbox = provider_sandbox( SandboxProviderKind::DAYTONA, &daytona_access(live_credentials()?), - SandboxOptions { - skip_clone: true, - labels: std::collections::BTreeMap::from([( - "team".to_string(), - "platform".to_string(), - )]), - ..Default::default() - }, + SandboxSpec::new(SandboxSource::HostDirectory) + .label("team".to_string(), "platform".to_string()), + &CloneRequest::none(), None, Some(run_id), - None, - None, - None, - None, ) .await?; @@ -202,7 +182,7 @@ mod daytona_streaming_live { .await .context("describe sandbox")? .labels; - let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox"); + let cleanup_result = sandbox.delete().await.context("clean up Daytona sandbox"); ensure_eq( &labels.get("sh.fabro.managed").map(String::as_str), @@ -235,16 +215,13 @@ mod daytona_streaming_live { let sandbox = provider_sandbox( SandboxProviderKind::DAYTONA, &daytona_access(live_credentials()?), - SandboxOptions { - skip_clone: false, - ..Default::default() + SandboxSpec::new(SandboxSource::HostDirectory), + &CloneRequest { + origin_url: Some("https://github.com/brynary/rack-test".to_string()), + ..CloneRequest::default() }, None, None, - Some("https://github.com/brynary/rack-test".to_string()), - None, - None, - None, ) .await?; @@ -269,16 +246,16 @@ mod daytona_streaming_live { None, ) .await?; - let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox"); + let cleanup_result = sandbox.delete().await.context("clean up Daytona sandbox"); ensure!( - result.is_success(), + result.success(), "layout verification failed: stdout={} stderr={}", - result.stdout, - result.stderr + result.stdout_lossy(), + result.stderr_lossy() ); ensure_contains( - &result.stdout, + &result.stdout_lossy(), "true", "default cwd should be inside the work tree", )?; @@ -305,14 +282,8 @@ mod daytona_streaming_live { let sandbox = provider_sandbox( SandboxProviderKind::DAYTONA, &daytona_access(live_credentials()?), - SandboxOptions { - skip_clone: true, - ..Default::default() - }, - None, - None, - None, - None, + SandboxSpec::new(SandboxSource::HostDirectory), + &CloneRequest::none(), None, None, ) @@ -321,7 +292,7 @@ mod daytona_streaming_live { sandbox.initialize().await?; let glob_result = run_glob_checks(&sandbox).await; - let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox"); + let cleanup_result = sandbox.delete().await.context("clean up Daytona sandbox"); glob_result?; cleanup_result?; @@ -343,10 +314,10 @@ mod daytona_streaming_live { ) .await?; ensure!( - seed.is_success(), + seed.success(), "seeding the skills tree failed: stdout={} stderr={}", - seed.stdout, - seed.stderr + seed.stdout_lossy(), + seed.stderr_lossy() ); // `*/SKILL.md` matches exactly one path segment: only the file one level @@ -383,21 +354,22 @@ mod daytona_streaming_live { let live_exec = tokio::spawn(async move { sandbox_for_exec - .exec_command_streaming(fabro_sandbox::ExecStreamingRequest { - timeout_ms: Some(60_000), - cancel_token: Some(cancel_for_exec), - output_callback: Some(callback), - ..fabro_sandbox::ExecStreamingRequest::new( - "printf 'live-out\\n'; printf 'live-err\\n' >&2; sleep 30", - ) - }) + .exec_command_streaming( + ExecSpec::bash("printf 'live-out\\n'; printf 'live-err\\n' >&2; sleep 30") + .timeout(Duration::from_mins(1)), + ExecControls { + term: Some(cancel_for_exec), + sink: Some(callback), + ..ExecControls::default() + }, + ) .await }); let saw_live_stdout_and_stderr = wait_for_chunks(&chunks, Duration::from_secs(20), |chunks| { - contains_chunk(chunks, CommandOutputStream::Stdout, "live-out") - && contains_chunk(chunks, CommandOutputStream::Stderr, "live-err") + contains_chunk(chunks, OutputStream::Stdout, "live-out") + && contains_chunk(chunks, OutputStream::Stderr, "live-err") }) .await; @@ -423,16 +395,16 @@ mod daytona_streaming_live { ); ensure_eq( &live_result.result.termination, - &CommandTermination::Cancelled, + &Termination::Cancelled, "cancelled command should preserve cancellation termination", )?; ensure_contains( - &live_result.result.stdout, + &live_result.result.stdout_lossy(), "live-out", "cancelled command stdout should preserve partial logs", )?; ensure_contains( - &live_result.result.stderr, + &live_result.result.stderr_lossy(), "live-err", "cancelled command stderr should preserve partial logs", )?; @@ -451,25 +423,25 @@ mod daytona_streaming_live { )?; ensure_eq( &nonzero.result.termination, - &CommandTermination::Exited, + &Termination::Exited, "nonzero command should be represented as a completed process", )?; ensure_contains( - &nonzero.result.stdout, + &nonzero.result.stdout_lossy(), "exit-out", "nonzero command stdout should be captured", )?; ensure_contains( - &nonzero.result.stderr, + &nonzero.result.stderr_lossy(), "exit-err", "nonzero command stderr should be captured", )?; ensure!( - contains_chunk(&nonzero_chunks, CommandOutputStream::Stdout, "exit-out"), + contains_chunk(&nonzero_chunks, OutputStream::Stdout, "exit-out"), "nonzero command should stream stdout chunks" ); ensure!( - contains_chunk(&nonzero_chunks, CommandOutputStream::Stderr, "exit-err"), + contains_chunk(&nonzero_chunks, OutputStream::Stderr, "exit-err"), "nonzero command should stream stderr chunks" ); @@ -482,16 +454,16 @@ mod daytona_streaming_live { .await?; ensure_eq( &timed_out.result.termination, - &CommandTermination::TimedOut, + &Termination::TimedOut, "timed-out command should preserve timeout termination", )?; ensure_contains( - &timed_out.result.stdout, + &timed_out.result.stdout_lossy(), "timeout-out", "timed-out command stdout should preserve partial logs", )?; ensure_contains( - &timed_out.result.stderr, + &timed_out.result.stderr_lossy(), "timeout-err", "timed-out command stderr should preserve partial logs", )?; @@ -517,13 +489,15 @@ mod daytona_streaming_live { ) -> Result<(ExecStreamingResult, Vec)> { let chunks = Arc::new(Mutex::new(Vec::new())); let callback = capture_callback(Arc::clone(&chunks)); + let mut spec = ExecSpec::bash(command).timeout(Duration::from_millis(timeout_ms)); + if let Some(stdin) = stdin { + spec = spec.stdin(stdin); + } let result = sandbox - .exec_command_streaming(fabro_sandbox::ExecStreamingRequest { - timeout_ms: Some(timeout_ms), - cancel_token, - stdin, - output_callback: Some(callback), - ..fabro_sandbox::ExecStreamingRequest::new(command) + .exec_command_streaming(spec, ExecControls { + term: cancel_token, + sink: Some(callback), + ..ExecControls::default() }) .await?; let chunks = chunks.lock().await.clone(); @@ -531,7 +505,7 @@ mod daytona_streaming_live { Ok((result, chunks)) } - fn capture_callback(chunks: Arc>>) -> CommandOutputCallback { + fn capture_callback(chunks: Arc>>) -> OutputSink { Arc::new(move |stream, bytes| { let chunks = Arc::clone(&chunks); Box::pin(async move { @@ -559,16 +533,11 @@ mod daytona_streaming_live { reason = "live smoke tests take Daytona credentials from the developer's environment" )] fn live_credentials() -> Result { - Ok(DaytonaCredentials { - api_key: std::env::var(EnvVars::DAYTONA_API_KEY) - .context("DAYTONA_API_KEY must be set")?, - api_url: std::env::var(EnvVars::DAYTONA_API_URL) - .or_else(|_| std::env::var(EnvVars::DAYTONA_SERVER_URL)) - .ok(), - organization_id: std::env::var(EnvVars::DAYTONA_ORGANIZATION_ID).ok(), - target: None, - http_client: None, - }) + let api_key = + std::env::var(EnvVars::DAYTONA_API_KEY).context("DAYTONA_API_KEY must be set")?; + Ok(DaytonaCredentials::from_api_key(api_key, |name| { + std::env::var(name).ok() + })) } fn daytona_access(credentials: DaytonaCredentials) -> ProviderAccess { @@ -597,7 +566,7 @@ mod daytona_streaming_live { } } - fn contains_chunk(chunks: &[CapturedChunk], stream: CommandOutputStream, text: &str) -> bool { + fn contains_chunk(chunks: &[CapturedChunk], stream: OutputStream, text: &str) -> bool { chunks .iter() .any(|chunk| chunk.stream == stream && chunk.text.contains(text)) diff --git a/lib/components/fabro-sandbox/tests/docker_streaming.rs b/lib/components/fabro-sandbox/tests/docker_streaming.rs index 36eac0289..7ba4f2951 100644 --- a/lib/components/fabro-sandbox/tests/docker_streaming.rs +++ b/lib/components/fabro-sandbox/tests/docker_streaming.rs @@ -1,12 +1,13 @@ //! Docker sandbox behaviour through the sandbox-driver Docker provider. -use std::collections::BTreeMap; use std::sync::Arc; +use std::time::Duration; use fabro_sandbox::{ - CommandOutputCallback, ExecStreamingRequest, ProviderAccess, SandboxOptions, - SandboxProviderKind, provider_sandbox, + CloneRequest, ExecControls, ExecSpec, OutputSink, ProviderAccess, SandboxProviderKind, + Termination, provider_sandbox, }; +use sandbox_driver::{SandboxSource, SandboxSpec}; use tokio::process::Command; use tokio::sync::Mutex; @@ -22,7 +23,7 @@ async fn docker_image_available(image: &str) -> bool { .is_ok_and(|status| status.success()) } -fn capture_bytes(chunks: Arc>>) -> CommandOutputCallback { +fn capture_bytes(chunks: Arc>>) -> OutputSink { Arc::new(move |_stream, bytes| { let chunks = Arc::clone(&chunks); Box::pin(async move { @@ -43,15 +44,10 @@ async fn streaming_timeout_terminates_docker_exec_before_returning() { let sandbox = provider_sandbox( SandboxProviderKind::DOCKER, &ProviderAccess::default(), - SandboxOptions { - image: Some(image.to_string()), - skip_clone: true, - ..SandboxOptions::default() - }, - None, - None, - None, - None, + SandboxSpec::new(SandboxSource::Image { + reference: image.to_string(), + }), + &CloneRequest::none(), None, None, ) @@ -67,15 +63,17 @@ async fn streaming_timeout_terminates_docker_exec_before_returning() { let marker = "fabro_streaming_timeout_sentinel"; let command = format!("trap '' HUP TERM; echo start; sleep 5 # {marker}"); let result = sandbox - .exec_command_streaming(ExecStreamingRequest { - timeout_ms: Some(200), - output_callback: Some(capture_bytes(Arc::clone(&chunks))), - ..ExecStreamingRequest::new(&command) - }) + .exec_command_streaming( + ExecSpec::bash(&command).timeout(Duration::from_millis(200)), + ExecControls { + sink: Some(capture_bytes(Arc::clone(&chunks))), + ..ExecControls::default() + }, + ) .await .expect("streaming command should return a timeout result"); - assert!(result.result.is_timed_out()); + assert_eq!(result.result.termination, Termination::TimedOut); assert!( String::from_utf8_lossy(&chunks.lock().await).contains("start"), "stream should include output emitted before timeout" @@ -94,14 +92,14 @@ async fn streaming_timeout_terminates_docker_exec_before_returning() { .await .expect("process probe should run"); sandbox - .cleanup() + .delete() .await .expect("docker cleanup should succeed"); + let probe = probe.stdout_lossy(); assert!( - !probe.stdout.contains(marker), - "timed-out docker exec should be terminated before returning, found: {}", - probe.stdout + !probe.contains(marker), + "timed-out docker exec should be terminated before returning, found: {probe}" ); } @@ -116,15 +114,10 @@ async fn streaming_command_receives_exact_stdin_and_eof() { let sandbox = provider_sandbox( SandboxProviderKind::DOCKER, &ProviderAccess::default(), - SandboxOptions { - image: Some(image.to_string()), - skip_clone: true, - ..SandboxOptions::default() - }, - None, - None, - None, - None, + SandboxSpec::new(SandboxSource::Image { + reference: image.to_string(), + }), + &CloneRequest::none(), None, None, ) @@ -137,11 +130,12 @@ async fn streaming_command_receives_exact_stdin_and_eof() { let stdin = b"first line\n$(touch /tmp/must-not-run)\nlast line".to_vec(); let result = sandbox - .exec_command_streaming(ExecStreamingRequest { - timeout_ms: Some(10_000), - stdin: Some(stdin.clone()), - ..ExecStreamingRequest::new("cat") - }) + .exec_command_streaming( + ExecSpec::bash("cat") + .timeout(Duration::from_secs(10)) + .stdin(stdin.clone()), + ExecControls::default(), + ) .await .expect("streaming command should read stdin and finish at EOF"); let injection_probe = sandbox @@ -150,19 +144,19 @@ async fn streaming_command_receives_exact_stdin_and_eof() { .expect("injection probe should run"); sandbox - .cleanup() + .delete() .await .expect("docker cleanup should succeed"); assert!( - result.result.is_success(), + result.result.success(), "stdin command failed: stdout={} stderr={}", - result.result.stdout, - result.result.stderr + result.result.stdout_lossy(), + result.result.stderr_lossy() ); - assert_eq!(result.result.stdout.as_bytes(), stdin); + assert_eq!(result.result.stdout, stdin); assert!( - injection_probe.is_success(), + injection_probe.success(), "stdin bytes must not be evaluated as shell source" ); } @@ -178,17 +172,15 @@ async fn cloned_docker_sandbox_uses_repos_checkout_and_workspace_symlink() { let sandbox = provider_sandbox( SandboxProviderKind::DOCKER, &ProviderAccess::default(), - SandboxOptions { - image: Some(image.to_string()), - skip_clone: false, - ..SandboxOptions::default() + SandboxSpec::new(SandboxSource::Image { + reference: image.to_string(), + }), + &CloneRequest { + origin_url: Some("https://github.com/brynary/rack-test".to_string()), + ..CloneRequest::default() }, None, None, - Some("https://github.com/brynary/rack-test".to_string()), - None, - None, - None, ) .await .expect("docker sandbox should construct"); @@ -215,17 +207,17 @@ async fn cloned_docker_sandbox_uses_repos_checkout_and_workspace_symlink() { .await .expect("layout verification command should run"); sandbox - .cleanup() + .delete() .await .expect("docker cleanup should succeed"); assert!( - result.is_success(), + result.success(), "layout verification failed: stdout={} stderr={}", - result.stdout, - result.stderr + result.stdout_lossy(), + result.stderr_lossy() ); - assert!(result.stdout.contains("true")); + assert!(result.stdout_lossy().contains("true")); } // Both command paths must evaluate the same interpreter, so Bash-only syntax @@ -244,16 +236,11 @@ async fn docker_runs_clean_bash_through_both_command_paths() { let sandbox = provider_sandbox( SandboxProviderKind::DOCKER, &ProviderAccess::default(), - SandboxOptions { - image: Some(image.to_string()), - env: BTreeMap::from([("BASH_ENV".to_string(), "/tmp/fabro-bash-env".to_string())]), - skip_clone: true, - ..SandboxOptions::default() - }, - None, - None, - None, - None, + SandboxSpec::new(SandboxSource::Image { + reference: image.to_string(), + }) + .env_var("BASH_ENV".to_string(), "/tmp/fabro-bash-env".to_string()), + &CloneRequest::none(), None, None, ) @@ -276,7 +263,7 @@ async fn docker_runs_clean_bash_through_both_command_paths() { ) .await .expect("startup-file fixture should be created"); - assert!(setup.is_success()); + assert!(setup.success()); // Arrays, `[[ ]]`, and `${arr[@]}` are Bash-only; `shopt -q login_shell` // proves the command did not run under a login shell. Exact output also @@ -291,33 +278,35 @@ async fn docker_runs_clean_bash_through_both_command_paths() { let chunks = Arc::new(Mutex::new(Vec::new())); let streaming = sandbox - .exec_command_streaming(ExecStreamingRequest { - timeout_ms: Some(10_000), - output_callback: Some(capture_bytes(Arc::clone(&chunks))), - ..ExecStreamingRequest::new(command) - }) + .exec_command_streaming( + ExecSpec::bash(command).timeout(Duration::from_secs(10)), + ExecControls { + sink: Some(capture_bytes(Arc::clone(&chunks))), + ..ExecControls::default() + }, + ) .await .expect("streaming command should run"); sandbox - .cleanup() + .delete() .await .expect("docker cleanup should succeed"); assert!( - non_streaming.is_success(), + non_streaming.success(), "non-streaming Bash-only command failed: stdout={} stderr={}", - non_streaming.stdout, - non_streaming.stderr + non_streaming.stdout_lossy(), + non_streaming.stderr_lossy() ); - assert_eq!(non_streaming.stdout.trim(), "two"); + assert_eq!(non_streaming.stdout_lossy().trim(), "two"); assert!( - streaming.result.is_success(), + streaming.result.success(), "streaming Bash-only command failed: stdout={} stderr={}", - streaming.result.stdout, - streaming.result.stderr + streaming.result.stdout_lossy(), + streaming.result.stderr_lossy() ); - assert_eq!(streaming.result.stdout.trim(), "two"); + assert_eq!(streaming.result.stdout_lossy().trim(), "two"); assert_eq!(String::from_utf8_lossy(&chunks.lock().await).trim(), "two"); } @@ -339,15 +328,10 @@ async fn docker_glob_matches_patterns_containing_a_path_separator() { let sandbox = provider_sandbox( SandboxProviderKind::DOCKER, &ProviderAccess::default(), - SandboxOptions { - image: Some(image.to_string()), - skip_clone: true, - ..SandboxOptions::default() - }, - None, - None, - None, - None, + SandboxSpec::new(SandboxSource::Image { + reference: image.to_string(), + }), + &CloneRequest::none(), None, None, ) @@ -379,15 +363,15 @@ async fn docker_glob_matches_patterns_containing_a_path_separator() { let recursive = sandbox.glob("**/SKILL.md", Some("skills")).await; sandbox - .cleanup() + .delete() .await .expect("docker cleanup should succeed"); assert!( - seed.is_success(), + seed.success(), "seeding the skills tree failed: stdout={} stderr={}", - seed.stdout, - seed.stderr + seed.stdout_lossy(), + seed.stderr_lossy() ); let one_level = one_level.expect("glob should run"); @@ -424,15 +408,10 @@ async fn docker_runtime_directory_is_private_and_outside_workspace() { let sandbox = provider_sandbox( SandboxProviderKind::DOCKER, &ProviderAccess::default(), - SandboxOptions { - image: Some(image.to_string()), - skip_clone: true, - ..SandboxOptions::default() - }, - None, - None, - None, - None, + SandboxSpec::new(SandboxSource::Image { + reference: image.to_string(), + }), + &CloneRequest::none(), None, None, ) @@ -471,12 +450,13 @@ async fn docker_runtime_directory_is_private_and_outside_workspace() { let readback = sandbox.read_file_text(&blob_path).await; sandbox - .cleanup() + .delete() .await .expect("docker cleanup should succeed"); - assert!(modes.is_success(), "stat failed: {}", modes.stderr); - let modes: Vec<&str> = modes.stdout.split_whitespace().collect(); + assert!(modes.success(), "stat failed: {}", modes.stderr_lossy()); + let modes = modes.stdout_lossy(); + let modes: Vec<&str> = modes.split_whitespace().collect(); assert_eq!( modes, ["700", "600"], @@ -503,15 +483,10 @@ async fn docker_sandbox_satisfies_pebbles_environment_contract() { let sandbox = provider_sandbox( SandboxProviderKind::DOCKER, &ProviderAccess::default(), - SandboxOptions { - image: Some(image.to_string()), - skip_clone: true, - ..SandboxOptions::default() - }, - None, - None, - None, - None, + SandboxSpec::new(SandboxSource::Image { + reference: image.to_string(), + }), + &CloneRequest::none(), None, None, ) @@ -531,7 +506,7 @@ async fn docker_sandbox_satisfies_pebbles_environment_contract() { } .await; sandbox - .cleanup() + .delete() .await .expect("docker sandbox should clean up"); outcome.expect("the Docker sandbox satisfies pebble's environment contract"); diff --git a/lib/components/fabro-sandbox/tests/driver_bench.rs b/lib/components/fabro-sandbox/tests/driver_bench.rs index 5f9646319..dfb2771c5 100644 --- a/lib/components/fabro-sandbox/tests/driver_bench.rs +++ b/lib/components/fabro-sandbox/tests/driver_bench.rs @@ -36,8 +36,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use fabro_sandbox::{ - ProviderAccess, RunSandbox, SandboxOptions, SandboxProviderKind, local_sandbox, - provider_sandbox, + CloneRequest, ProviderAccess, RunSandbox, SandboxProviderKind, local_sandbox, provider_sandbox, }; use sandbox_driver::{ ExecSpec, GrepOptions, Sandbox as DriverHandle, SandboxProvider, SandboxSource, SandboxSpec, @@ -279,7 +278,7 @@ async fn unpack_fabro(sandbox: &RunSandbox, repo: &Repository) { ) .await .expect("unpack exec"); - assert!(result.is_success(), "unpack failed: {}", result.stderr); + assert!(result.success(), "unpack failed: {}", result.stderr_lossy()); } async fn unpack_driver(sandbox: &dyn DriverHandle, repo: &Repository) { @@ -363,15 +362,10 @@ async fn agent_tool_call_latency_through_the_driver() { let fabro_docker = provider_sandbox( SandboxProviderKind::DOCKER, &ProviderAccess::default(), - SandboxOptions { - image: Some(IMAGE.to_owned()), - skip_clone: true, - ..SandboxOptions::default() - }, - None, - None, - None, - None, + SandboxSpec::new(SandboxSource::Image { + reference: IMAGE.to_owned(), + }), + &CloneRequest::none(), None, None, ) @@ -380,7 +374,7 @@ async fn agent_tool_call_latency_through_the_driver() { fabro_docker.initialize().await.expect("fabro docker init"); unpack_fabro(&fabro_docker, &repo).await; rows.extend(bench_fabro("fabro Docker (driver-backed)", &fabro_docker, &repo).await); - fabro_docker.cleanup().await.expect("fabro docker cleanup"); + fabro_docker.delete().await.expect("fabro docker cleanup"); let docker_provider = Arc::new(DockerProvider::connect().await.expect("docker connect")); let container = docker_provider diff --git a/lib/components/fabro-workflow/src/artifact.rs b/lib/components/fabro-workflow/src/artifact.rs index 79924c4e0..7199881fa 100644 --- a/lib/components/fabro-workflow/src/artifact.rs +++ b/lib/components/fabro-workflow/src/artifact.rs @@ -1239,7 +1239,7 @@ mod tests { .unwrap(); assert_eq!( - env.exists_calls(), + env.driver().memory_fs().exists_calls(), 1, "sandbox locality should be probed once per resolution pass" ); diff --git a/lib/components/fabro-workflow/src/event.rs b/lib/components/fabro-workflow/src/event.rs index ec0115925..b972bf6a9 100644 --- a/lib/components/fabro-workflow/src/event.rs +++ b/lib/components/fabro-workflow/src/event.rs @@ -1,9 +1,9 @@ mod convert; +mod driver_events; mod emitter; mod events; mod names; mod redaction; -mod sandbox_bridge; mod sink; mod stored_fields; #[cfg(test)] @@ -12,13 +12,13 @@ mod test_support; pub use fabro_types::{EventBody, RunNoticeCode, RunNoticeLevel}; pub use self::convert::{to_run_event, to_run_event_at}; +pub use self::driver_events::DriverEventRecorder; pub use self::emitter::Emitter; pub use self::events::{Event, SandboxLifecycle}; pub use self::names::event_name; pub use self::redaction::{ build_redacted_event_payload, event_payload_from_redacted_json, redacted_event_json, }; -pub use self::sandbox_bridge::SandboxEventBridge; pub use self::sink::{ RunEventLogger, RunEventPersistenceError, RunEventSink, StoreProgressLogger, append_event, append_event_if, append_event_to_sink, create_run, diff --git a/lib/components/fabro-workflow/src/event/convert.rs b/lib/components/fabro-workflow/src/event/convert.rs index 076cabc7a..012f2485e 100644 --- a/lib/components/fabro-workflow/src/event/convert.rs +++ b/lib/components/fabro-workflow/src/event/convert.rs @@ -52,8 +52,6 @@ fn git_push_attempt_props( .token .and_then(|token| token.age_at(attempt.started_at)) .map(|age| u64::try_from(age.as_millis()).unwrap_or(u64::MAX)), - credential_action: attempt.credential_action, - refresh_error: attempt.refresh_error, }) .collect() } @@ -684,91 +682,8 @@ fn event_body_from_event(event: &Event) -> EventBody { causes: causes.clone(), duration_ms: *duration_ms, }), - SandboxLifecycle::StartStarted { provider } => { - EventBody::SandboxStartStarted(fabro_types::SandboxStartStartedProps { - provider: provider.clone(), - }) - } - SandboxLifecycle::StartCompleted { - provider, - duration_ms, - } => EventBody::SandboxStartCompleted(fabro_types::SandboxStartCompletedProps { - provider: provider.clone(), - duration_ms: *duration_ms, - }), - SandboxLifecycle::StartFailed { - provider, - error, - causes, - } => EventBody::SandboxStartFailed(fabro_types::SandboxStartFailedProps { - provider: provider.clone(), - error: error.clone(), - causes: causes.clone(), - }), - SandboxLifecycle::StopStarted { provider } => { - EventBody::SandboxStopStarted(fabro_types::SandboxStopStartedProps { - provider: provider.clone(), - }) - } - SandboxLifecycle::StopCompleted { - provider, - duration_ms, - } => EventBody::SandboxStopCompleted(fabro_types::SandboxStopCompletedProps { - provider: provider.clone(), - duration_ms: *duration_ms, - }), - SandboxLifecycle::StopFailed { - provider, - error, - causes, - } => EventBody::SandboxStopFailed(fabro_types::SandboxStopFailedProps { - provider: provider.clone(), - error: error.clone(), - causes: causes.clone(), - }), - SandboxLifecycle::DeleteStarted { provider } => { - EventBody::SandboxDeleteStarted(fabro_types::SandboxDeleteStartedProps { - provider: provider.clone(), - }) - } - SandboxLifecycle::DeleteCompleted { - provider, - duration_ms, - } => EventBody::SandboxDeleteCompleted(fabro_types::SandboxDeleteCompletedProps { - provider: provider.clone(), - duration_ms: *duration_ms, - }), - SandboxLifecycle::DeleteFailed { - provider, - error, - causes, - } => EventBody::SandboxDeleteFailed(fabro_types::SandboxDeleteFailedProps { - provider: provider.clone(), - error: error.clone(), - causes: causes.clone(), - }), - SandboxLifecycle::SnapshotPulling { name } => { - EventBody::SnapshotPulling(fabro_types::SnapshotNameProps { name: name.clone() }) - } - SandboxLifecycle::SnapshotCreating { name } => { - EventBody::SnapshotCreating(fabro_types::SnapshotNameProps { name: name.clone() }) - } - SandboxLifecycle::SnapshotReady { name, duration_ms } => { - EventBody::SnapshotReady(fabro_types::SnapshotCompletedProps { - name: name.clone(), - duration_ms: *duration_ms, - }) - } - SandboxLifecycle::SnapshotFailed { - name, - error, - causes, - } => EventBody::SnapshotFailed(fabro_types::SnapshotFailedProps { - name: name.clone(), - error: error.clone(), - causes: causes.clone(), - }), }, + Event::SandboxDriver { event } => EventBody::sandbox_driver(event.clone()), Event::SandboxInitialized { working_directory, provider, @@ -1298,22 +1213,49 @@ mod tests { } #[test] - fn run_event_sandbox_stop_and_delete_use_distinct_event_names() { - let stopped = to_run_event(&fixtures::RUN_5, &Event::Sandbox { - event: SandboxLifecycle::StopCompleted { - provider: "docker".to_string(), - duration_ms: 10, - }, + fn run_event_driver_events_are_named_from_the_subject_action_and_phase() { + let stopped = to_run_event(&fixtures::RUN_5, &Event::SandboxDriver { + event: driver_event(serde_json::json!({ + "id": {"source_id": "test", "sequence": 1}, + "occurred_at": "2026-05-09T12:00:00Z", + "provider": "docker", + "subject": {"type": "sandbox", "id": "container-1"}, + "type": "operation_completed", + "action": "stop", + "duration": {"secs": 0, "nanos": 10_000_000} + })), }); - let deleted = to_run_event(&fixtures::RUN_5, &Event::Sandbox { - event: SandboxLifecycle::DeleteCompleted { - provider: "docker".to_string(), - duration_ms: 20, - }, + let building = to_run_event(&fixtures::RUN_5, &Event::SandboxDriver { + event: driver_event(serde_json::json!({ + "id": {"source_id": "test", "sequence": 2}, + "occurred_at": "2026-05-09T12:00:01Z", + "provider": "daytona", + "subject": {"type": "snapshot", "name": "sandbox-driver-abc"}, + "type": "operation_started", + "action": "create" + })), }); assert_eq!(stopped.event_name(), "sandbox.stop.completed"); - assert_eq!(deleted.event_name(), "sandbox.delete.completed"); + assert_eq!(building.event_name(), "snapshot.create.started"); + let properties = stopped.properties().unwrap(); + assert_eq!(properties["action"], "stop"); + assert_eq!(properties["subject"]["id"], "container-1"); + assert_eq!(properties["duration"]["nanos"], 10_000_000); + + // The stored form reads back as the driver's event. + let round_trip: RunEvent = serde_json::from_value(serde_json::to_value(&stopped).unwrap()) + .expect("a stored driver event decodes"); + assert!(matches!( + &round_trip.body, + EventBody::SandboxDriver { name, event } + if name == "sandbox.stop.completed" + && matches!(event.body, sandbox_driver::EventBody::OperationCompleted { .. }) + )); + } + + fn driver_event(value: serde_json::Value) -> sandbox_driver::Event { + serde_json::from_value(value).expect("a driver event") } #[test] @@ -1770,26 +1712,22 @@ mod tests { expires_at, }, }), - credential_action: Some(fabro_sandbox::RemoteCredentialAction::Embedded), - refresh_error: None, }, // Terminal classified failure with a refresh error: the last // attempt carries its classification too. fabro_sandbox::PushAttempt { - attempt: 2, - started_at: started_at + chrono::Duration::seconds(3), - success: false, - retry_reason: Some(fabro_sandbox::GitRetryReason::TransientInfra), - exec_output_tail: Some(exec_tail()), - token: Some(fabro_sandbox::TokenSnapshot { + attempt: 2, + started_at: started_at + chrono::Duration::seconds(3), + success: false, + retry_reason: Some(fabro_sandbox::GitRetryReason::TransientInfra), + exec_output_tail: Some(exec_tail()), + token: Some(fabro_sandbox::TokenSnapshot { generation: 14, provenance: fabro_sandbox::TokenProvenance::Reused { minted_at, expires_at, }, }), - credential_action: Some(fabro_sandbox::RemoteCredentialAction::Unchanged), - refresh_error: Some(fabro_sandbox::RefreshErrorKind::SetUrl), }, ]; let expected_attempts = git_push_attempt_props(&runtime_attempts); @@ -1808,11 +1746,8 @@ mod tests { assert_eq!(serialized[0]["token_generation"], 14); assert_eq!(serialized[0]["token_provenance"], "minted"); assert_eq!(serialized[0]["token_age_ms"], 180); - assert_eq!(serialized[0]["credential_action"], "embedded"); - assert!(serialized[0].get("refresh_error").is_none()); assert_eq!(serialized[1]["classified_reason"], "transient_infra"); assert_eq!(serialized[1]["token_provenance"], "reused"); - assert_eq!(serialized[1]["refresh_error"], "set_url"); // The provenance enum never nests in stored events. assert!(serialized[0].get("token").is_none()); @@ -1826,20 +1761,43 @@ mod tests { } } + /// Attempts stored by earlier releases carried `credential_action` and + /// `refresh_error` from the origin-URL credential design. The fields are + /// gone; the stored events still read. + #[test] + fn stored_attempts_with_retired_credential_fields_still_deserialize() { + let json = serde_json::json!({ + "attempt": 1, + "started_at": "2026-03-30T12:00:01.000Z", + "success": true, + "token_generation": 3, + "token_provenance": "reused", + "token_age_ms": 120, + "credential_action": "embedded", + "refresh_error": "set_url" + }); + let props: ::fabro_types::run_event::GitPushAttemptProps = + serde_json::from_value(json).unwrap(); + assert_eq!(props.attempt, 1); + assert_eq!(props.token_generation, Some(3)); + assert_eq!( + props.token_provenance, + Some(::fabro_types::run_event::GitTokenProvenance::Reused) + ); + } + #[test] fn successful_single_attempt_push_omits_failure_fields() { let attempts = vec![fabro_sandbox::PushAttempt { - attempt: 1, - started_at: Utc::now(), - success: true, - retry_reason: None, - exec_output_tail: None, - token: Some(fabro_sandbox::TokenSnapshot { + attempt: 1, + started_at: Utc::now(), + success: true, + retry_reason: None, + exec_output_tail: None, + token: Some(fabro_sandbox::TokenSnapshot { generation: 0, provenance: fabro_sandbox::TokenProvenance::Static, }), - credential_action: Some(fabro_sandbox::RemoteCredentialAction::Unchanged), - refresh_error: None, }]; let stored = to_run_event(&fixtures::RUN_1, &Event::GitPush { branch: "fabro/run/run-1".to_string(), @@ -1852,12 +1810,7 @@ mod tests { let attempt = &json["properties"]["attempts"][0]; assert_eq!(attempt["success"], true); assert_eq!(attempt["token_provenance"], "static"); - for absent in [ - "classified_reason", - "exec_output_tail", - "token_age_ms", - "refresh_error", - ] { + for absent in ["classified_reason", "exec_output_tail", "token_age_ms"] { assert!(attempt.get(absent).is_none(), "{absent} should be omitted"); } } diff --git a/lib/components/fabro-workflow/src/event/driver_events.rs b/lib/components/fabro-workflow/src/event/driver_events.rs new file mode 100644 index 000000000..a14637685 --- /dev/null +++ b/lib/components/fabro-workflow/src/event/driver_events.rs @@ -0,0 +1,36 @@ +//! The sandbox driver's events for a run's sandbox, kept as run events. +//! +//! A run's sandbox is created or attached with a driver [`EventContext`] +//! whose observer is a [`DriverEventRecorder`]. Everything the driver +//! reports about the sandbox — the operations it performs and their +//! outcome, progress inside a create such as an image pull, snapshot +//! builds, state observations, notices — is stored whole as an +//! [`Event::SandboxDriver`], named from the event (see +//! `fabro_types::sandbox_driver_event_name`). +//! +//! [`EventContext`]: sandbox_driver::EventContext + +use std::sync::Arc; + +use async_trait::async_trait; +use sandbox_driver::{Event as DriverEvent, EventObserver}; + +use super::{Emitter, Event}; + +/// Records every event the driver reports as a run event. +pub struct DriverEventRecorder { + emitter: Arc, +} + +impl DriverEventRecorder { + pub fn new(emitter: Arc) -> Self { + Self { emitter } + } +} + +#[async_trait] +impl EventObserver for DriverEventRecorder { + async fn observe(&self, event: DriverEvent) { + self.emitter.emit(&Event::SandboxDriver { event }); + } +} diff --git a/lib/components/fabro-workflow/src/event/events.rs b/lib/components/fabro-workflow/src/event/events.rs index 772ae35fb..c486953ee 100644 --- a/lib/components/fabro-workflow/src/event/events.rs +++ b/lib/components/fabro-workflow/src/event/events.rs @@ -513,11 +513,17 @@ pub enum Event { status: String, duration_ms: u64, }, - /// A fact about the run's sandbox: the pipeline bringing it up, or a - /// driver operation on it. + /// A fact about the run's sandbox from the pipeline bringing it up. Sandbox { event: SandboxLifecycle, }, + /// An event the sandbox driver reported about the run's sandbox (an + /// operation and its outcome, progress inside a create, a state + /// observation, a notice), kept whole. Named from the event; see + /// `fabro_types::sandbox_driver_event_name`. + SandboxDriver { + event: sandbox_driver::Event, + }, /// Emitted after the sandbox has been initialized (by engine lifecycle). SandboxInitialized { working_directory: String, @@ -763,7 +769,7 @@ pub enum Event { /// Initializing, ready, and failed are the pipeline's view of bringing the /// sandbox up — create, activate, and prepare the workspace as one step. /// The rest are the sandbox driver's own operations and snapshot work, -/// translated from its events by [`super::SandboxEventBridge`]. +/// the driver's own events are kept whole as [`Event::SandboxDriver`]. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum SandboxLifecycle { Initializing { @@ -784,68 +790,11 @@ pub enum SandboxLifecycle { causes: Vec, duration_ms: u64, }, - StartStarted { - provider: String, - }, - StartCompleted { - provider: String, - duration_ms: u64, - }, - StartFailed { - provider: String, - error: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - causes: Vec, - }, - StopStarted { - provider: String, - }, - StopCompleted { - provider: String, - duration_ms: u64, - }, - StopFailed { - provider: String, - error: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - causes: Vec, - }, - DeleteStarted { - provider: String, - }, - DeleteCompleted { - provider: String, - duration_ms: u64, - }, - DeleteFailed { - provider: String, - error: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - causes: Vec, - }, - /// The provider is pulling the image the sandbox is created from. - SnapshotPulling { - name: String, - }, - /// The provider is building or activating the snapshot. - SnapshotCreating { - name: String, - }, - SnapshotReady { - name: String, - duration_ms: u64, - }, - SnapshotFailed { - name: String, - error: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - causes: Vec, - }, } impl SandboxLifecycle { pub fn trace(&self) { - use tracing::{debug, error, info, warn}; + use tracing::{debug, error, info}; match self { Self::Initializing { provider } => { debug!(provider, "Sandbox initializing"); @@ -865,70 +814,6 @@ impl SandboxLifecycle { } => { error!(provider, error, causes = ?causes, duration_ms, "Sandbox init failed"); } - Self::StartStarted { provider } => { - info!(provider, "Sandbox start started"); - } - Self::StartCompleted { - provider, - duration_ms, - } => { - info!(provider, duration_ms, "Sandbox start completed"); - } - Self::StartFailed { - provider, - error, - causes, - } => { - warn!(provider, error, causes = ?causes, "Sandbox start failed"); - } - Self::StopStarted { provider } => { - info!(provider, "Sandbox stop started"); - } - Self::StopCompleted { - provider, - duration_ms, - } => { - info!(provider, duration_ms, "Sandbox stop completed"); - } - Self::StopFailed { - provider, - error, - causes, - } => { - warn!(provider, error, causes = ?causes, "Sandbox stop failed"); - } - Self::DeleteStarted { provider } => { - info!(provider, "Sandbox delete started"); - } - Self::DeleteCompleted { - provider, - duration_ms, - } => { - info!(provider, duration_ms, "Sandbox delete completed"); - } - Self::DeleteFailed { - provider, - error, - causes, - } => { - warn!(provider, error, causes = ?causes, "Sandbox delete failed"); - } - Self::SnapshotPulling { name } => { - debug!(name, "Snapshot pulling"); - } - Self::SnapshotCreating { name } => { - debug!(name, "Snapshot creating"); - } - Self::SnapshotReady { name, duration_ms } => { - info!(name, duration_ms, "Snapshot ready"); - } - Self::SnapshotFailed { - name, - error, - causes, - } => { - error!(name, error, causes = ?causes, "Snapshot failed"); - } } } } @@ -1484,6 +1369,7 @@ impl Event { } Self::Agent { event, .. } => event.event.trace(&event.session_id), Self::Sandbox { event } => event.trace(), + Self::SandboxDriver { event } => trace_driver_event(event), Self::SandboxInitialized { working_directory, provider, @@ -1761,3 +1647,22 @@ impl Event { } } } + +/// Traces a sandbox driver event under its run event name. +fn trace_driver_event(event: &sandbox_driver::Event) { + use sandbox_driver::EventBody as Body; + use tracing::{debug, info, warn}; + + let name = fabro_types::sandbox_driver_event_name(event); + match &event.body { + Body::OperationFailed { error, .. } => { + warn!(event = %name, error = %error.message, "Sandbox driver operation failed"); + } + Body::OperationCompleted { duration, .. } => { + let duration_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX); + info!(event = %name, duration_ms, "Sandbox driver operation completed"); + } + Body::OperationStarted { .. } => info!(event = %name, "Sandbox driver operation started"), + _ => debug!(event = %name, "Sandbox driver event"), + } +} diff --git a/lib/components/fabro-workflow/src/event/names.rs b/lib/components/fabro-workflow/src/event/names.rs index 55e6134f1..a6ba1d4eb 100644 --- a/lib/components/fabro-workflow/src/event/names.rs +++ b/lib/components/fabro-workflow/src/event/names.rs @@ -1,8 +1,13 @@ +use std::borrow::Cow; + use super::{Event, SandboxLifecycle}; #[must_use] -pub fn event_name(event: &Event) -> &'static str { - match event { +pub fn event_name(event: &Event) -> Cow<'static, str> { + let name: &'static str = match event { + Event::SandboxDriver { event } => { + return Cow::Owned(fabro_types::sandbox_driver_event_name(event)); + } Event::RunCreated { .. } => "run.created", Event::WorkflowRunStarted { .. } => "run.started", Event::RunSubmitted { .. } => "run.submitted", @@ -67,19 +72,6 @@ pub fn event_name(event: &Event) -> &'static str { SandboxLifecycle::Initializing { .. } => "sandbox.initializing", SandboxLifecycle::Ready { .. } => "sandbox.ready", SandboxLifecycle::InitializeFailed { .. } => "sandbox.failed", - SandboxLifecycle::StartStarted { .. } => "sandbox.start.started", - SandboxLifecycle::StartCompleted { .. } => "sandbox.start.completed", - SandboxLifecycle::StartFailed { .. } => "sandbox.start.failed", - SandboxLifecycle::StopStarted { .. } => "sandbox.stop.started", - SandboxLifecycle::StopCompleted { .. } => "sandbox.stop.completed", - SandboxLifecycle::StopFailed { .. } => "sandbox.stop.failed", - SandboxLifecycle::DeleteStarted { .. } => "sandbox.delete.started", - SandboxLifecycle::DeleteCompleted { .. } => "sandbox.delete.completed", - SandboxLifecycle::DeleteFailed { .. } => "sandbox.delete.failed", - SandboxLifecycle::SnapshotPulling { .. } => "sandbox.snapshot.pulling", - SandboxLifecycle::SnapshotCreating { .. } => "sandbox.snapshot.creating", - SandboxLifecycle::SnapshotReady { .. } => "sandbox.snapshot.ready", - SandboxLifecycle::SnapshotFailed { .. } => "sandbox.snapshot.failed", }, Event::SandboxInitialized { .. } => "sandbox.initialized", Event::SetupStarted { .. } => "setup.started", @@ -112,7 +104,8 @@ pub fn event_name(event: &Event) -> &'static str { Event::PullRequestLinked { .. } => "pull_request.linked", Event::PullRequestUnlinked { .. } => "pull_request.unlinked", Event::PullRequestFailed { .. } => "pull_request.failed", - } + }; + Cow::Borrowed(name) } #[cfg(test)] diff --git a/lib/components/fabro-workflow/src/event/sandbox_bridge.rs b/lib/components/fabro-workflow/src/event/sandbox_bridge.rs deleted file mode 100644 index d00446a57..000000000 --- a/lib/components/fabro-workflow/src/event/sandbox_bridge.rs +++ /dev/null @@ -1,192 +0,0 @@ -//! The sandbox driver's events for a run's sandbox, as workflow events. -//! -//! A run's sandbox is created or attached with a driver [`EventContext`] -//! whose observer is a [`SandboxEventBridge`]. The driver reports every -//! operation it performs — start, stop, delete, the image pull inside a -//! create, snapshot builds — and the bridge turns the ones fabro records -//! on a run into [`SandboxLifecycle`] events. Everything else the driver -//! reports (state observations, notices, other operations) is not a run -//! event and is dropped here. - -use std::collections::HashMap; -use std::sync::{Arc, Mutex, PoisonError}; -use std::time::{Duration, Instant}; - -use async_trait::async_trait; -use sandbox_driver::{ - Action, ErrorReport, Event as DriverEvent, EventBody as DriverEventBody, EventObserver, - EventSubject, OperationId, ProgressCode, -}; - -use super::{Emitter, Event, SandboxLifecycle}; - -/// Emits the workflow's sandbox lifecycle events from the driver's. -pub struct SandboxEventBridge { - emitter: Arc, - /// Fabro's name for the provider, which is what the run records; the - /// driver's own kind name can differ (`host` for a `local` run). - provider: String, - /// The image the sandbox is created from, named on pull events. - image: Option, - /// Creates that pulled an image, by operation, with when the pull began. - pulls: Mutex>, -} - -impl SandboxEventBridge { - pub fn new(emitter: Arc, provider: impl Into, image: Option) -> Self { - Self { - emitter, - provider: provider.into(), - image, - pulls: Mutex::new(HashMap::new()), - } - } - - /// The lifecycle event a driver event stands for, if fabro records one. - fn translate(&self, event: &DriverEvent) -> Option { - match &event.subject { - EventSubject::Sandbox { .. } => self.translate_sandbox(event), - EventSubject::Snapshot { id, name } => { - let name = name - .clone() - .or_else(|| id.as_ref().map(ToString::to_string)) - .unwrap_or_default(); - match &event.body { - DriverEventBody::OperationStarted { .. } => { - Some(SandboxLifecycle::SnapshotCreating { name }) - } - DriverEventBody::OperationCompleted { duration, .. } => { - Some(SandboxLifecycle::SnapshotReady { - name, - duration_ms: duration_ms(*duration), - }) - } - DriverEventBody::OperationFailed { error, .. } => { - Some(SandboxLifecycle::SnapshotFailed { - name, - error: error.message.clone(), - causes: error.causes.clone(), - }) - } - _ => None, - } - } - _ => None, - } - } - - fn translate_sandbox(&self, event: &DriverEvent) -> Option { - let provider = self.provider.clone(); - match &event.body { - DriverEventBody::OperationStarted { action } => match action { - Action::Start => Some(SandboxLifecycle::StartStarted { provider }), - Action::Stop => Some(SandboxLifecycle::StopStarted { provider }), - Action::Delete => Some(SandboxLifecycle::DeleteStarted { provider }), - _ => None, - }, - DriverEventBody::OperationProgress { action, progress } => { - if *action != Action::Create || progress.code.as_str() != ProgressCode::IMAGE_PULL { - return None; - } - // The first pull report of a create opens the pull; later - // ones are the same pull's progress. - let operation_id = event.operation_id.clone()?; - let mut pulls = self.pulls.lock().unwrap_or_else(PoisonError::into_inner); - if pulls.contains_key(&operation_id) { - return None; - } - pulls.insert(operation_id, Instant::now()); - Some(SandboxLifecycle::SnapshotPulling { - name: self - .image - .clone() - .or_else(|| progress.message.clone()) - .unwrap_or_default(), - }) - } - DriverEventBody::OperationCompleted { action, duration } => match action { - Action::Create => { - let pulled = self.take_pull(event.operation_id.as_ref())?; - Some(SandboxLifecycle::SnapshotReady { - name: self.image.clone().unwrap_or_default(), - duration_ms: duration_ms(pulled.elapsed()), - }) - } - Action::Start => Some(SandboxLifecycle::StartCompleted { - provider, - duration_ms: duration_ms(*duration), - }), - Action::Stop => Some(SandboxLifecycle::StopCompleted { - provider, - duration_ms: duration_ms(*duration), - }), - Action::Delete => Some(SandboxLifecycle::DeleteCompleted { - provider, - duration_ms: duration_ms(*duration), - }), - _ => None, - }, - DriverEventBody::OperationFailed { action, error, .. } => match action { - Action::Create => { - self.take_pull(event.operation_id.as_ref())?; - Some(SandboxLifecycle::SnapshotFailed { - name: self.image.clone().unwrap_or_default(), - error: error.message.clone(), - causes: error.causes.clone(), - }) - } - Action::Start => Some(failed(error, |error, causes| { - SandboxLifecycle::StartFailed { - provider, - error, - causes, - } - })), - Action::Stop => Some(failed(error, |error, causes| { - SandboxLifecycle::StopFailed { - provider, - error, - causes, - } - })), - Action::Delete => Some(failed(error, |error, causes| { - SandboxLifecycle::DeleteFailed { - provider, - error, - causes, - } - })), - _ => None, - }, - _ => None, - } - } - - /// When the create `operation_id` began pulling its image, if it did. - fn take_pull(&self, operation_id: Option<&OperationId>) -> Option { - self.pulls - .lock() - .unwrap_or_else(PoisonError::into_inner) - .remove(operation_id?) - } -} - -fn failed( - error: &ErrorReport, - build: impl FnOnce(String, Vec) -> SandboxLifecycle, -) -> SandboxLifecycle { - build(error.message.clone(), error.causes.clone()) -} - -fn duration_ms(duration: Duration) -> u64 { - u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) -} - -#[async_trait] -impl EventObserver for SandboxEventBridge { - async fn observe(&self, event: DriverEvent) { - if let Some(lifecycle) = self.translate(&event) { - self.emitter.emit(&Event::Sandbox { event: lifecycle }); - } - } -} diff --git a/lib/components/fabro-workflow/src/handler/command.rs b/lib/components/fabro-workflow/src/handler/command.rs index 5e7f03d38..5d4c0dd4c 100644 --- a/lib/components/fabro-workflow/src/handler/command.rs +++ b/lib/components/fabro-workflow/src/handler/command.rs @@ -2,8 +2,11 @@ use std::path::Path; use async_trait::async_trait; use fabro_graphviz::graph::{ContextKeyAttr, Graph, Node}; -use fabro_sandbox::sandbox::{CommandOutputCallback, ExecStreamingRequest}; -use fabro_types::{CommandTermination, StageTiming}; +use fabro_sandbox::{ + ExecControls, ExecResultExt, ExecSpec, OutputSink, Termination, TransportError, + command_termination, +}; +use fabro_types::StageTiming; use fabro_util::shell::shell_quote; use super::structured_output::{self, StructuredOutputError}; @@ -108,7 +111,7 @@ impl Handler for CommandHandler { let cancel_token = services.run.cancel_token().child_token(); let stage_id = stage_scope.stage_id(); let recorder = CommandLogRecorder::create(run_dir, &stage_id).await?; - let output_callback: CommandOutputCallback = { + let sink: OutputSink = { let recorder = recorder.clone(); std::sync::Arc::new(move |_stream, bytes| { let recorder = recorder.clone(); @@ -116,21 +119,26 @@ impl Handler for CommandHandler { recorder .append(&bytes) .await - .map_err(|err| fabro_sandbox::Error::message(err.to_string())) + .map_err(|err| TransportError::new(err.to_string()).into()) }) }) }; + let mut spec = + ExecSpec::bash(&command).timeout(std::time::Duration::from_millis(timeout_ms)); + for (key, value) in env_vars.into_iter().flatten() { + spec = spec.env_var(key, value); + } + if let Some(stdin) = stdin { + spec = spec.stdin(stdin); + } let result = services .run .sandbox - .exec_command_streaming(ExecStreamingRequest { - timeout_ms: Some(timeout_ms), - env_vars, - cancel_token: Some(cancel_token.clone()), - stdin, - output_callback: Some(output_callback), - ..ExecStreamingRequest::new(&command) + .exec_command_streaming(spec, ExecControls { + term: Some(cancel_token.clone()), + sink: Some(sink), + ..ExecControls::default() }) .await; cancel_token.cancel(); @@ -148,28 +156,31 @@ impl Handler for CommandHandler { &Event::CommandCompleted { node_id: node.id.clone(), output: finalized.output_ref.clone(), - exit_code: result.exit_code, - duration_ms: result.duration_ms, - termination: result.termination, + exit_code: result.program_exit_code(), + duration_ms: result.duration_ms(), + termination: command_termination(result.termination), output_bytes: finalized.output_bytes, live_streaming: streaming.live_streaming, }, &stage_scope, ); - if result.termination == CommandTermination::TimedOut { + if result.termination == Termination::TimedOut { let mut reason = format!("Script timed out after {timeout_ms}ms: {script}"); append_output_tail(&mut reason, &finalized.output_text); return Err(Error::handler(reason)); } - if result.termination == CommandTermination::Cancelled { + if matches!( + result.termination, + Termination::Cancelled | Termination::Killed + ) { let mut reason = format!("Script cancelled: {script}"); append_output_tail(&mut reason, &finalized.output_text); return Err(Error::handler(reason)); } - if result.exit_code == Some(0) { + if result.success() { let validation = output_schema.as_ref().map(|schema| { ( schema, @@ -191,7 +202,7 @@ impl Handler for CommandHandler { keys::COMMAND_OUTPUT.to_string(), serde_json::json!(finalized.output_ref), ); - outcome.timing = Some(StageTiming::active_only(0, result.duration_ms)); + outcome.timing = Some(StageTiming::active_only(0, result.duration_ms())); if let Some((schema, Ok(validated))) = validation { structured_output::apply_validated_output(node, schema, &validated, &mut outcome); } @@ -199,7 +210,7 @@ impl Handler for CommandHandler { } else { let mut reason = format!( "Script failed with exit code: {}", - result.exit_code.unwrap_or(-1) + result.program_exit_code().unwrap_or(-1) ); append_output_tail(&mut reason, &finalized.output_text); let mut outcome = Outcome::fail_classify(reason); @@ -207,7 +218,7 @@ impl Handler for CommandHandler { keys::COMMAND_OUTPUT.to_string(), serde_json::json!(finalized.output_ref), ); - outcome.timing = Some(StageTiming::active_only(0, result.duration_ms)); + outcome.timing = Some(StageTiming::active_only(0, result.duration_ms())); Ok(outcome) } } @@ -321,7 +332,8 @@ mod tests { use bytes::Bytes; use fabro_graphviz::graph::AttrValue; - use fabro_sandbox::test_support::MockSandbox; + use fabro_sandbox::Termination; + use fabro_sandbox::test_support::{MockSandbox, exec_result}; use fabro_store::{Database, RunDatabase, StageId}; use fabro_types::{Graph, RunProjection, RunSpec, WorkflowSettings, fixtures, test_support}; use object_store::memory::InMemory; @@ -842,13 +854,7 @@ mod tests { #[tokio::test] async fn command_invalid_output_schema_fails_before_execution() { let spy = MockSandbox { - exec_result: fabro_sandbox::sandbox::ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: Some(0), - termination: CommandTermination::Exited, - duration_ms: 1, - }, + exec_result: exec_result("", "", Some(0), Termination::Exited, 1), ..Default::default() }; let handler = CommandHandler; @@ -1460,7 +1466,7 @@ mod tests { assert_eq!(outcome.status, StageOutcome::Succeeded); assert_eq!( - mock.captured_stdin(), + mock.driver().scripted_exec().captured_stdin().pop(), Some(serde_json::to_vec(¶llel_results).unwrap()) ); assert!( @@ -1496,7 +1502,11 @@ mod tests { assert_eq!(outcome.status, StageOutcome::Succeeded); assert_eq!( - mock.captured_stdin().as_deref(), + mock.driver() + .scripted_exec() + .captured_stdin() + .pop() + .as_deref(), Some(b"first\nlast".as_slice()) ); } @@ -1590,13 +1600,7 @@ mod tests { #[tokio::test] async fn executes_script_via_sandbox() { let spy = MockSandbox { - exec_result: fabro_sandbox::sandbox::ExecResult { - stdout: "SANDBOX_MARKER\n".into(), - stderr: String::new(), - exit_code: Some(0), - termination: CommandTermination::Exited, - duration_ms: 5, - }, + exec_result: exec_result("SANDBOX_MARKER\n", "", Some(0), Termination::Exited, 5), ..Default::default() }; @@ -1633,13 +1637,7 @@ mod tests { #[tokio::test] async fn executes_python_script_via_sandbox() { let spy = MockSandbox { - exec_result: fabro_sandbox::sandbox::ExecResult { - stdout: "PYTHON_SANDBOX\n".into(), - stderr: String::new(), - exit_code: Some(0), - termination: CommandTermination::Exited, - duration_ms: 5, - }, + exec_result: exec_result("PYTHON_SANDBOX\n", "", Some(0), Termination::Exited, 5), ..Default::default() }; @@ -1679,13 +1677,7 @@ mod tests { #[tokio::test] async fn passes_env_vars_to_sandbox() { let spy = MockSandbox { - exec_result: fabro_sandbox::sandbox::ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: Some(0), - termination: CommandTermination::Exited, - duration_ms: 5, - }, + exec_result: exec_result("", "", Some(0), Termination::Exited, 5), ..Default::default() }; @@ -1717,13 +1709,7 @@ mod tests { #[tokio::test] async fn refreshes_github_token_for_each_command_stage_when_near_expiry() { let spy = MockSandbox { - exec_result: fabro_sandbox::sandbox::ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: Some(0), - termination: CommandTermination::Exited, - duration_ms: 5, - }, + exec_result: exec_result("", "", Some(0), Termination::Exited, 5), ..Default::default() }; let minter = std::sync::Arc::new(RefreshingMinter { @@ -1772,13 +1758,7 @@ mod tests { #[tokio::test] async fn passes_run_cancellation_to_sandbox() { let spy = MockSandbox { - exec_result: fabro_sandbox::sandbox::ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: Some(0), - termination: CommandTermination::Exited, - duration_ms: 5, - }, + exec_result: exec_result("", "", Some(0), Termination::Exited, 5), ..Default::default() }; @@ -1800,19 +1780,19 @@ mod tests { .await .unwrap(); - assert_eq!(spy.captured_term_stops(), vec![true]); + assert_eq!(spy.driver().scripted_exec().term_stops(), vec![true]); } #[tokio::test] async fn script_handler_timeout_error_includes_output_tails() { let spy = MockSandbox { - exec_result: fabro_sandbox::sandbox::ExecResult { - stdout: "partial stdout\n".into(), - stderr: "partial stderr\n".into(), - exit_code: None, - termination: CommandTermination::TimedOut, - duration_ms: 50, - }, + exec_result: exec_result( + "partial stdout\n", + "partial stderr\n", + None, + Termination::TimedOut, + 50, + ), ..Default::default() }; diff --git a/lib/components/fabro-workflow/src/handler/llm/acp.rs b/lib/components/fabro-workflow/src/handler/llm/acp.rs index f1462ff94..04983a443 100644 --- a/lib/components/fabro-workflow/src/handler/llm/acp.rs +++ b/lib/components/fabro-workflow/src/handler/llm/acp.rs @@ -13,7 +13,7 @@ use fabro_acp::{ }; use fabro_github::token_source::REFRESH_MARGIN; use fabro_graphviz::graph::Node; -use fabro_sandbox::{RefreshOutcome, RunSandbox}; +use fabro_sandbox::{RunSandbox, TokenSnapshot}; use fabro_static::EnvVars; use fabro_types::{AgentBackend, SessionCapability, StageId, StageTiming}; use fabro_util::time::elapsed_ms; @@ -41,8 +41,9 @@ const REFRESH_INTERVAL_DEFAULT: Duration = Duration::from_mins(45); /// Floor for expiry-driven rescheduling, so a token already inside the cache /// margin cannot pin the loop in a hot cycle. const REFRESH_RESCHEDULE_FLOOR: Duration = Duration::from_secs(30); -/// Upper bound on a single push-credential refresh (token mint + `git remote -/// set-url` exec). The turn-entry refresh runs before the ACP process spawns +/// Upper bound on a single credential refresh (token mint + rewriting the +/// checkout's credential store). The turn-entry refresh runs before the ACP +/// process spawns /// and the ACP node uses `NodeTimeoutPolicy::HandlerManaged`, so without this /// bound a stalled GitHub API call would hang node entry indefinitely. const REFRESH_MINT_TIMEOUT: Duration = Duration::from_secs(30); @@ -77,9 +78,8 @@ fn parse_refresh_enabled(raw: Option<&str>) -> bool { ) } -/// Parse the refresh-ahead loop interval. `None` disables the loop (explicit -/// `0`, mirroring the codebase's `set_autostop_interval` "0 to disable" -/// convention). Unset/empty or an unparsable value falls back to the default. +/// Parse the refresh-ahead loop interval. `None` disables the loop (an +/// explicit `0`). Unset/empty or an unparsable value falls back to the default. fn parse_refresh_interval(raw: Option<&str>) -> Option { match raw.map(str::trim) { None | Some("") => Some(REFRESH_INTERVAL_DEFAULT), @@ -114,10 +114,10 @@ fn push_cred_refresh_interval() -> Option { /// 45-minute sleep would leave the embedded token expired until the next /// tick. Schedule from the token's own `expires_at` instead: wake when the /// cache margin opens, so that tick re-mints. `None` disables the loop — -/// static credentials cannot be re-minted by waiting. -fn next_refresh_delay(outcome: &RefreshOutcome) -> Option { - let token = outcome.token()?; - let expires_at = token.expires_at()?; +/// static credentials cannot be re-minted by waiting, and a sandbox without +/// managed credentials has nothing to renew. +fn next_refresh_delay(token: Option<&TokenSnapshot>) -> Option { + let expires_at = token?.expires_at()?; let margin = chrono::Duration::from_std(REFRESH_MARGIN).unwrap_or(chrono::Duration::MAX); let until_margin = ((expires_at - margin) - chrono::Utc::now()) .to_std() @@ -125,11 +125,11 @@ fn next_refresh_delay(outcome: &RefreshOutcome) -> Option { Some(until_margin.max(REFRESH_RESCHEDULE_FLOOR)) } -/// Background loop that keeps the sandbox's push credentials fresh for the +/// Background loop that keeps the checkout's git credentials fresh for the /// duration of one ACP turn, so a single turn that outlives the /// installation-token TTL still pushes with a fresh token. Bounded by /// `cancel` (the drop-guard cancels it at turn end). Each successful tick -/// reschedules from the embedded token's expiry ([`next_refresh_delay`]); a +/// reschedules from the installed token's expiry ([`next_refresh_delay`]); a /// failed or timed-out tick retries after a shorter delay so a transient /// error does not leave a longer-than-interval window with an expired token. async fn refresh_ahead_loop( @@ -138,7 +138,7 @@ async fn refresh_ahead_loop( interval: Duration, initial_delay: Duration, ) where - Fut: Future> + Send, + Fut: Future>> + Send, { let retry_delay = interval.min(Duration::from_mins(1)); let mut delay = initial_delay; @@ -147,27 +147,21 @@ async fn refresh_ahead_loop( () = cancel.cancelled() => break, () = sleep(delay) => { match timeout(REFRESH_MINT_TIMEOUT, refresh()).await { - Ok(Ok(outcome)) => { - match outcome { - RefreshOutcome::Embedded(token) => { + Ok(Ok(token)) => { + match &token { + Some(token) => { tracing::info!( generation = token.generation, - "refresh-ahead re-embedded push credentials mid-turn" + "refresh-ahead renewed the checkout's git credentials mid-turn" ); } - RefreshOutcome::Unchanged(token) => { + None => { tracing::debug!( - generation = token.generation, - "refresh-ahead tick: embedded push credentials still fresh" - ); - } - RefreshOutcome::None => { - tracing::debug!( - "refresh-ahead tick: no managed push credentials to refresh" + "refresh-ahead tick: no managed git credentials to renew" ); } } - if let Some(next) = next_refresh_delay(&outcome) { + if let Some(next) = next_refresh_delay(token.as_ref()) { delay = next; } else { tracing::debug!( @@ -318,24 +312,15 @@ impl AgentAcpBackend { let refresh_enabled = push_cred_refresh_enabled(); let refresh_interval = refresh_enabled.then(push_cred_refresh_interval).flatten(); let refresh_schedule = if refresh_enabled { - match timeout(REFRESH_MINT_TIMEOUT, sandbox.refresh_push_credentials()).await { - Ok(Ok(outcome)) => { - match outcome { - RefreshOutcome::Embedded(token) => { - tracing::debug!( - generation = token.generation, - "refreshed sandbox push credentials at ACP turn entry" - ); - } - RefreshOutcome::Unchanged(token) => { - tracing::debug!( - generation = token.generation, - "sandbox push credentials already fresh at ACP turn entry" - ); - } - RefreshOutcome::None => {} + match timeout(REFRESH_MINT_TIMEOUT, sandbox.refresh_ambient_credentials()).await { + Ok(Ok(token)) => { + if let Some(token) = &token { + tracing::debug!( + generation = token.generation, + "refreshed the checkout's git credentials at ACP turn entry" + ); } - refresh_interval.zip(next_refresh_delay(&outcome)) + refresh_interval.zip(next_refresh_delay(token.as_ref())) } Ok(Err(e)) => { tracing::warn!( @@ -363,7 +348,7 @@ impl AgentAcpBackend { AbortOnDrop(tokio::spawn(refresh_ahead_loop( move || { let sandbox = Arc::clone(&sandbox); - async move { sandbox.refresh_push_credentials().await } + async move { sandbox.refresh_ambient_credentials().await } }, cancel_token.child_token(), interval, @@ -659,11 +644,9 @@ mod tests { use fabro_acp::{AcpError, AcpProcessExit}; use fabro_graphviz::graph::{AttrValue, Node}; use fabro_sandbox::test_support::MockSandbox; - use fabro_sandbox::{ - RefreshOutcome, RemoteCredentialAction, RunSandbox, TokenProvenance, TokenSnapshot, - local_sandbox, shell_quote, - }; + use fabro_sandbox::{RunSandbox, TokenProvenance, TokenSnapshot, local_sandbox}; use fabro_types::{CommandTermination, EventBody, ExecOutputTail}; + use fabro_util::shell; use tokio_util::sync::CancellationToken; use super::{ @@ -721,25 +704,21 @@ mod tests { } #[tokio::test] - async fn refresh_reports_no_action_without_managed_credentials() { - // A mock sandbox has no cloned workspace and so no managed push - // credentials: refresh is a no-op that must report no remote action - // and no token — the signal the refresh-ahead loop relies on to log - // at debug rather than falsely claim a re-embed. + async fn refresh_reports_no_token_without_managed_credentials() { + // A mock sandbox has no cloned workspace and so no managed + // credentials: refresh is a no-op that must report no token — the + // signal the refresh-ahead loop relies on to stop rather than claim + // a renewal. let sandbox = MockSandbox::linux().sandbox(); - assert_eq!( - sandbox.refresh_push_credentials().await.unwrap(), - RefreshOutcome::none() - ); + assert_eq!(sandbox.refresh_ambient_credentials().await.unwrap(), None); } - fn minted_outcome( - action: RemoteCredentialAction, + fn minted_token( generation: u64, minted_ago: chrono::Duration, expires_in: chrono::Duration, reused: bool, - ) -> RefreshOutcome { + ) -> TokenSnapshot { let now = chrono::Utc::now(); let minted_at = now - minted_ago; let expires_at = now + expires_in; @@ -754,34 +733,28 @@ mod tests { expires_at, } }; - let token = TokenSnapshot { + TokenSnapshot { generation, provenance, - }; - match action { - RemoteCredentialAction::Embedded => RefreshOutcome::embedded(token), - RemoteCredentialAction::Unchanged => RefreshOutcome::unchanged(token), - RemoteCredentialAction::None => RefreshOutcome::none(), } } - fn static_outcome() -> RefreshOutcome { - RefreshOutcome::unchanged(TokenSnapshot { + fn static_token() -> TokenSnapshot { + TokenSnapshot { generation: 0, provenance: TokenProvenance::Static, - }) + } } #[test] fn next_refresh_delay_schedules_from_token_expiry_minus_margin() { - let outcome = minted_outcome( - RemoteCredentialAction::Embedded, + let outcome = minted_token( 1, chrono::Duration::zero(), chrono::Duration::minutes(60), false, ); - let delay = next_refresh_delay(&outcome).unwrap(); + let delay = next_refresh_delay(Some(&outcome)).unwrap(); // Expiry minus the 10-minute refresh margin: ~50 minutes out. assert!(delay > Duration::from_mins(49), "{delay:?}"); assert!(delay <= Duration::from_mins(50), "{delay:?}"); @@ -789,35 +762,37 @@ mod tests { #[test] fn next_refresh_delay_floors_when_the_margin_is_already_open() { - let outcome = minted_outcome( - RemoteCredentialAction::Unchanged, + let outcome = minted_token( 1, chrono::Duration::minutes(55), chrono::Duration::minutes(5), true, ); - assert_eq!(next_refresh_delay(&outcome), Some(REFRESH_RESCHEDULE_FLOOR)); + assert_eq!( + next_refresh_delay(Some(&outcome)), + Some(REFRESH_RESCHEDULE_FLOOR) + ); } #[test] fn next_refresh_delay_disables_the_loop_for_static_credentials() { - assert_eq!(next_refresh_delay(&static_outcome()), None); + assert_eq!(next_refresh_delay(Some(&static_token())), None); } #[test] fn next_refresh_delay_disables_the_loop_without_managed_credentials() { - assert_eq!(next_refresh_delay(&RefreshOutcome::none()), None); + assert_eq!(next_refresh_delay(None), None); } /// Scripted refresh outcomes, recording when each refresh tick lands on /// the (paused) tokio clock. struct ScriptedRefresh { - script: Mutex>, + script: Mutex>, ticks: Mutex>, } impl ScriptedRefresh { - fn new(script: Vec) -> Arc { + fn new(script: Vec) -> Arc { Arc::new(Self { script: Mutex::new(script.into()), ticks: Mutex::new(Vec::new()), @@ -831,19 +806,21 @@ mod tests { /// The refresh the loop calls: answers the next scripted outcome. fn refresher( self: &Arc, - ) -> impl Fn() -> std::future::Ready> + Send { + ) -> impl Fn() -> std::future::Ready>> + Send + { let this = Arc::clone(self); move || { this.ticks .lock() .expect("ticks lock") .push(tokio::time::Instant::now()); - std::future::ready(Ok(this - .script - .lock() - .expect("script lock") - .pop_front() - .expect("refresh script exhausted"))) + std::future::ready(Ok(Some( + this.script + .lock() + .expect("script lock") + .pop_front() + .expect("refresh script exhausted"), + ))) } } } @@ -860,24 +837,21 @@ mod tests { let sandbox = ScriptedRefresh::new(vec![ // Minute 45: cache still fresh (expires minute 60, margin opens // minute 50). - minted_outcome( - RemoteCredentialAction::Unchanged, + minted_token( 1, chrono::Duration::minutes(45), chrono::Duration::minutes(15), true, ), // Minute ~50: margin open → the source minted generation 2. - minted_outcome( - RemoteCredentialAction::Embedded, + minted_token( 2, chrono::Duration::zero(), chrono::Duration::minutes(60), false, ), // Minute ~100: generation 2 still fresh. - minted_outcome( - RemoteCredentialAction::Unchanged, + minted_token( 2, chrono::Duration::minutes(50), chrono::Duration::minutes(10), @@ -915,16 +889,14 @@ mod tests { #[tokio::test(start_paused = true)] async fn refresh_ahead_honors_the_expiry_based_initial_delay() { let interval = Duration::from_mins(45); - let entry_outcome = minted_outcome( - RemoteCredentialAction::Unchanged, + let entry_outcome = minted_token( 1, chrono::Duration::minutes(45), chrono::Duration::minutes(15), true, ); - let initial_delay = next_refresh_delay(&entry_outcome).unwrap(); - let sandbox = ScriptedRefresh::new(vec![minted_outcome( - RemoteCredentialAction::Embedded, + let initial_delay = next_refresh_delay(Some(&entry_outcome)).unwrap(); + let sandbox = ScriptedRefresh::new(vec![minted_token( 2, chrono::Duration::zero(), chrono::Duration::minutes(60), @@ -966,7 +938,7 @@ mod tests { "acp.command".to_string(), AttrValue::String(format!( "python3 {}", - shell_quote(&script_path.to_string_lossy()) + shell::shell_quote(&script_path.to_string_lossy()) )), ); @@ -1072,7 +1044,7 @@ mod tests { "acp.command".to_string(), AttrValue::String(format!( "python3 {}", - shell_quote(&script_path.to_string_lossy()) + shell::shell_quote(&script_path.to_string_lossy()) )), ); @@ -1135,7 +1107,7 @@ mod tests { "acp.command".to_string(), AttrValue::String(format!( "python3 {}", - shell_quote(&script_path.to_string_lossy()) + shell::shell_quote(&script_path.to_string_lossy()) )), ); @@ -1219,7 +1191,7 @@ mod tests { "acp.command".to_string(), AttrValue::String(format!( "python3 {}", - shell_quote(&script_path.to_string_lossy()) + shell::shell_quote(&script_path.to_string_lossy()) )), ); diff --git a/lib/components/fabro-workflow/src/handler/llm/changed_files.rs b/lib/components/fabro-workflow/src/handler/llm/changed_files.rs index adfd54d9c..b1d41ec8f 100644 --- a/lib/components/fabro-workflow/src/handler/llm/changed_files.rs +++ b/lib/components/fabro-workflow/src/handler/llm/changed_files.rs @@ -1,26 +1,28 @@ use std::collections::HashSet; use std::sync::Arc; -use fabro_sandbox::{RunSandbox, shell_quote}; - -const DIFF_MARKER: &str = "__FABRO_CHANGED_FILES_DIFF__"; -const UNTRACKED_MARKER: &str = "__FABRO_CHANGED_FILES_UNTRACKED__"; +use fabro_sandbox::RunSandbox; +use fabro_util::shell; +use sandbox_driver::{Git as _, GitDiffOptions, GitRevisionRange}; +/// The paths the working tree changed against `HEAD`, plus the untracked +/// files git does not ignore, sorted and deduplicated. A sandbox without +/// git, or a working directory that is not a repository, has no changed +/// files. pub async fn detect_changed_files(sandbox: &Arc) -> Vec { + let Ok(git) = sandbox.git() else { + return Vec::new(); + }; + let repo = sandbox.working_directory(); let mut files: Vec = Vec::new(); - let command = format!( - "printf '%s\\n' {diff}; git diff --name-only || true; \ - printf '%s\\n' {untracked}; git ls-files --others --exclude-standard || true", - diff = shell_quote(DIFF_MARKER), - untracked = shell_quote(UNTRACKED_MARKER), - ); - if let Ok(result) = sandbox - .exec_command(&command, 30_000, None, None, None) + if let Ok(entries) = git + .diff_entries(repo, &GitDiffOptions::new(GitRevisionRange::new("HEAD"))) .await { - if result.is_success() { - files.extend(parse_changed_files(&result.stdout)); - } + files.extend(entries.into_iter().map(|entry| entry.path)); + } + if let Ok(untracked) = git.untracked_files(repo).await { + files.extend(untracked); } files.sort(); @@ -42,42 +44,20 @@ pub async fn files_touched_since( let last_file_touched = if files_touched.is_empty() { None } else { - let quoted_files: Vec = - files_touched.iter().map(|file| shell_quote(file)).collect(); + let quoted_files: Vec = files_touched + .iter() + .map(|file| shell::shell_quote(file)) + .collect(); let cmd = format!("ls -t {} | head -1", quoted_files.join(" ")); sandbox .exec_command(&cmd, 5_000, None, None, None) .await .ok() .and_then(|result| { - let trimmed = result.stdout.trim().to_string(); - (result.is_success() && !trimmed.is_empty()).then_some(trimmed) + let trimmed = result.stdout_lossy().trim().to_string(); + (result.success() && !trimmed.is_empty()).then_some(trimmed) }) }; (files_touched, last_file_touched) } - -fn parse_changed_files(stdout: &str) -> impl Iterator + '_ { - stdout.lines().filter_map(|line| { - let trimmed = line.trim(); - (!trimmed.is_empty() && trimmed != DIFF_MARKER && trimmed != UNTRACKED_MARKER) - .then(|| trimmed.to_string()) - }) -} - -#[cfg(test)] -mod tests { - use super::parse_changed_files; - - #[test] - fn parse_changed_files_ignores_section_markers() { - let files = parse_changed_files( - "__FABRO_CHANGED_FILES_DIFF__\nsrc/main.rs\n\ - __FABRO_CHANGED_FILES_UNTRACKED__\nREADME.md\n", - ) - .collect::>(); - - assert_eq!(files, vec!["src/main.rs", "README.md"]); - } -} diff --git a/lib/components/fabro-workflow/src/lifecycle/git.rs b/lib/components/fabro-workflow/src/lifecycle/git.rs index d1e871757..61095ae90 100644 --- a/lib/components/fabro-workflow/src/lifecycle/git.rs +++ b/lib/components/fabro-workflow/src/lifecycle/git.rs @@ -87,10 +87,10 @@ pub(crate) struct PushResult { pub(crate) async fn push_run_branch( sandbox: &fabro_sandbox::RunSandbox, branch: &str, - plan: &fabro_sandbox::RetryPlan, + policy: &fabro_sandbox::GitRetryPolicy, ) -> Result { sandbox - .git_push_ref(&format!("refs/heads/{branch}:refs/heads/{branch}"), plan) + .git_push_ref(&format!("refs/heads/{branch}:refs/heads/{branch}"), policy) .await } @@ -333,9 +333,9 @@ impl RunLifecycle for GitLifecycle { .as_ref() .and_then(|g| g.run_branch.as_ref()) { - let plan = fabro_sandbox::RetryPlan::checkpoint_push(); + let policy = fabro_sandbox::checkpoint_push_policy(); let (push_ok, exec_output_tail, attempts) = - match push_run_branch(self.sandbox.as_ref(), branch, &plan).await { + match push_run_branch(self.sandbox.as_ref(), branch, &policy).await { Ok(report) => { self.sandbox_git.record_successful_push(); (true, None, report.attempts) diff --git a/lib/components/fabro-workflow/src/operations/start.rs b/lib/components/fabro-workflow/src/operations/start.rs index 0d2050d61..8395d765f 100644 --- a/lib/components/fabro-workflow/src/operations/start.rs +++ b/lib/components/fabro-workflow/src/operations/start.rs @@ -10,8 +10,7 @@ use fabro_llm::credentials::readiness; use fabro_llm::lithos_catalog::Catalog; use fabro_mcp::config::McpServerSettings; use fabro_sandbox::{ - DaytonaCredentials, ProviderAccess, ProviderSandboxSpec, SandboxOptions, SandboxSpec, - local_working_directory_from_environment, options_from_environment, + CloneRequest, DaytonaCredentials, ProviderAccess, SandboxSpec, sandbox_spec_for_environment, }; use fabro_static::EnvVars; #[cfg(test)] @@ -506,10 +505,17 @@ impl RunSession { ))); } } + let daytona = vault_guard + .get(EnvVars::DAYTONA_API_KEY) + .map(|api_key| DaytonaCredentials::from_api_key(api_key.to_string(), process_env_var)); + let access = ProviderAccess { + providers: services.sandbox_providers.clone(), + daytona, + }; let sandbox = match sandbox_provider.bundled() { - Some(BundledProvider::Local) if dry_run_clone_target => SandboxSpec::Local { - working_directory: dry_run_workspace_for_target(persisted).await?, - }, + Some(BundledProvider::Local) if dry_run_clone_target => { + SandboxSpec::local(dry_run_workspace_for_target(persisted).await?, access) + } Some(BundledProvider::Local) => match record.target.as_ref() { Some(target @ (RunTarget::Git(_) | RunTarget::None {})) => { return Err(Error::engine(format!( @@ -517,44 +523,39 @@ impl RunSession { target.kind_name() ))); } - Some(RunTarget::Folder { path }) => SandboxSpec::Local { - working_directory: folder_working_directory_from_record(record, path).await?, - }, + Some(RunTarget::Folder { path }) => SandboxSpec::local( + folder_working_directory_from_record(record, path).await?, + access, + ), None => { - let working_directory = local_working_directory_from_environment( - &resolved.environment, - record.source_directory.as_deref().map(Path::new), - ) - .map_err(|err| { - Error::engine_with_source( - "Failed to resolve local environment working directory", - err, - ) - })?; - SandboxSpec::Local { working_directory } + let working_directory = resolved + .environment + .local_working_directory(record.source_directory.as_deref().map(Path::new)) + .map_err(|err| { + Error::engine_with_source( + "Failed to resolve local environment working directory", + err, + ) + })?; + SandboxSpec::local(working_directory, access) } }, _ => { - let daytona = vault_guard.get(EnvVars::DAYTONA_API_KEY).map(|api_key| { - DaytonaCredentials::from_api_key(api_key.to_string(), process_env_var) - }); - let access = ProviderAccess { - providers: services.sandbox_providers.clone(), - daytona, - }; - let mut options = resolve_sandbox_options(resolved, secret_lookup)?; - options.skip_clone |= clone_source.skip_clone; - SandboxSpec::Provider(Box::new(ProviderSandboxSpec { + let spec = resolve_sandbox_spec(resolved, secret_lookup)?; + let mut clone = CloneRequest::from_settings(&resolved.clone); + clone.skip |= clone_source.skip_clone; + clone.origin_url = clone_source.origin_url; + clone.branch = clone_source.branch; + clone.tag = clone_source.tag; + clone.commit_sha = clone_source.commit_sha; + SandboxSpec { kind: sandbox_provider.clone(), access, - options, + spec, + clone, github_app: services.github_app.clone(), run_id: Some(record.run_id), - clone_origin_url: clone_source.origin_url, - clone_branch: clone_source.branch, - clone_tag: clone_source.tag, - clone_commit_sha: clone_source.commit_sha, - })) + } } }; @@ -802,20 +803,20 @@ fn resolve_sandbox_provider(settings: &ResolvedRunSettings) -> SandboxProviderKi settings.environment.provider.clone() } -/// The environment's sandbox options with its variables resolved through -/// the vault. -fn resolve_sandbox_options( +/// The environment's sandbox spec with its variables resolved through the +/// vault. +fn resolve_sandbox_spec( settings: &ResolvedRunSettings, secrets_lookup: impl FnMut(&str) -> Option, -) -> Result { +) -> Result { let env = settings .environment .resolve_env(secrets_lookup) .map_err(|err| Error::engine_with_source("failed to resolve environment variables", err))? .into_iter() .collect(); - options_from_environment(&settings.environment, &settings.clone, env) - .map_err(|err| Error::engine_with_source("failed to resolve sandbox options", err)) + sandbox_spec_for_environment(&settings.environment, env) + .map_err(|err| Error::engine_with_source("failed to resolve sandbox spec", err)) } fn resolve_start_llm( @@ -1525,9 +1526,9 @@ mod tests { ..RunLayer::default() }); - let options = resolve_sandbox_options(&settings.run, |_| None).unwrap(); - assert!(options.skip_clone); - assert_eq!(options.clone_depth, Some(1)); + let clone = CloneRequest::from_settings(&settings.run.clone); + assert!(clone.skip); + assert_eq!(clone.depth, Some(1)); } #[test] @@ -1540,16 +1541,16 @@ mod tests { ..RunLayer::default() }); - let options = resolve_sandbox_options(&settings.run, |_| None).unwrap(); - assert_eq!(options.clone_depth, None); + let clone = CloneRequest::from_settings(&settings.run.clone); + assert_eq!(clone.depth, None); } #[test] fn clone_providers_default_to_depth_100() { let settings = settings_from_run_layer(RunLayer::default()); - let options = resolve_sandbox_options(&settings.run, |_| None).unwrap(); - assert_eq!(options.clone_depth, Some(100)); + let clone = CloneRequest::from_settings(&settings.run.clone); + assert_eq!(clone.depth, Some(100)); } #[test] @@ -1867,29 +1868,19 @@ mod tests { .. } = session; let runtime = sandbox - .to_run_sandbox_instance(&MockSandbox::linux().sandbox(), fixtures::RUN_1) + .to_run_sandbox_instance(&MockSandbox::linux().sandbox()) .runtime; assert_eq!(runtime.repo_cloned, Some(false)); assert_eq!(runtime.clone_origin_url, None); assert_eq!(runtime.clone_branch, None); assert_eq!(runtime.primary_repo_path, None); assert_eq!(runtime.primary_repo_link, None); - let SandboxSpec::Provider(spec) = sandbox else { - panic!("none target should retain the selected Docker provider"); - }; - let ProviderSandboxSpec { - kind, - options, - clone_origin_url, - clone_branch, - clone_commit_sha, - .. - } = *spec; + let SandboxSpec { kind, clone, .. } = sandbox; assert_eq!(kind, SandboxProviderKind::DOCKER); - assert!(options.skip_clone); - assert_eq!(clone_origin_url, None); - assert_eq!(clone_branch, None); - assert_eq!(clone_commit_sha, None); + assert!(clone.skip); + assert_eq!(clone.origin_url, None); + assert_eq!(clone.branch, None); + assert_eq!(clone.commit_sha, None); assert_eq!(sandbox_env.origin_url, None); assert_eq!(pr_origin_url, None); } @@ -1936,31 +1927,25 @@ mod tests { .. } = session; let runtime = sandbox - .to_run_sandbox_instance(&MockSandbox::linux().sandbox(), fixtures::RUN_1) + .to_run_sandbox_instance(&MockSandbox::linux().sandbox()) .runtime; assert_eq!(runtime.repo_cloned, Some(false)); assert_eq!(runtime.clone_origin_url, None); assert_eq!(runtime.clone_branch, None); assert_eq!(runtime.primary_repo_path, None); assert_eq!(runtime.primary_repo_link, None); - let SandboxSpec::Provider(spec) = sandbox else { - panic!("none target should retain the selected Daytona provider"); - }; - let ProviderSandboxSpec { + let SandboxSpec { kind, access, - options, - clone_origin_url, - clone_branch, - clone_commit_sha, + clone, .. - } = *spec; + } = sandbox; assert_eq!(kind, SandboxProviderKind::DAYTONA); assert!(access.daytona.is_some(), "the vault key reaches the spec"); - assert!(options.skip_clone); - assert_eq!(clone_origin_url, None); - assert_eq!(clone_branch, None); - assert_eq!(clone_commit_sha, None); + assert!(clone.skip); + assert_eq!(clone.origin_url, None); + assert_eq!(clone.branch, None); + assert_eq!(clone.commit_sha, None); assert_eq!(sandbox_env.origin_url, None); assert_eq!(pr_origin_url, None); } @@ -2033,12 +2018,20 @@ mod tests { .await .unwrap(); - let SandboxSpec::Local { working_directory } = session.sandbox else { - panic!("clone target dry-run should execute in a Local scratch sandbox"); - }; assert_eq!( - working_directory, - run_dir.join("dry-run-workspace").canonicalize().unwrap() + session.sandbox.kind, + SandboxProviderKind::LOCAL, + "clone target dry-run should execute in a Local scratch sandbox" + ); + assert_eq!( + session.sandbox.working_directory().map(Path::new), + Some( + run_dir + .join("dry-run-workspace") + .canonicalize() + .unwrap() + .as_path() + ) ); assert_eq!(session.sandbox_env.origin_url, None); assert_eq!(session.pr_origin_url, None); @@ -2147,11 +2140,14 @@ mod tests { .await .unwrap(); - let SandboxSpec::Local { working_directory } = session.sandbox else { - panic!("folder target should retain the selected Local provider"); - }; - assert_eq!(working_directory, canonical_folder); - assert_ne!(working_directory, environment_cwd); + assert_eq!( + session.sandbox.kind, + SandboxProviderKind::LOCAL, + "folder target should retain the selected Local provider" + ); + let working_directory = session.sandbox.working_directory().map(Path::new); + assert_eq!(working_directory, Some(canonical_folder.as_path())); + assert_ne!(working_directory, Some(environment_cwd.as_path())); assert_eq!(session.sandbox_env.origin_url.as_deref(), Some(origin_url)); assert_eq!(session.pr_origin_url.as_deref(), Some(origin_url)); } @@ -2216,10 +2212,15 @@ mod tests { .await .unwrap(); - let SandboxSpec::Local { working_directory } = session.sandbox else { - panic!("legacy Local run should retain the selected Local provider"); - }; - assert_eq!(working_directory, environment_cwd); + assert_eq!( + session.sandbox.kind, + SandboxProviderKind::LOCAL, + "legacy Local run should retain the selected Local provider" + ); + assert_eq!( + session.sandbox.working_directory().map(Path::new), + Some(environment_cwd.as_path()) + ); } #[tokio::test] @@ -2338,17 +2339,21 @@ mod tests { ..RunLayer::default() }); - let options = resolve_sandbox_options(&settings.run, |_| None).unwrap(); + let spec = resolve_sandbox_spec(&settings.run, |_| None).unwrap(); - assert_eq!(options.image.as_deref(), Some("ubuntu:24.04")); - assert_eq!(options.cpu, Some(4)); - assert_eq!(options.memory_bytes, Some(2_000_000_000)); assert!(matches!( - options.network, - fabro_sandbox::NetworkPolicy::Block + &spec.source, + fabro_sandbox::SandboxSource::Image { reference } if reference == "ubuntu:24.04" )); + assert_eq!(spec.resources.cpu_cores, Some(4)); assert_eq!( - options.env, + spec.resources.memory_mb, + Some(1908), + "2 GB rounds up to whole mebibytes" + ); + assert!(matches!(spec.network, fabro_sandbox::NetworkPolicy::Block)); + assert_eq!( + spec.env, std::collections::BTreeMap::from([("NODE_ENV".to_string(), "test".to_string())]) ); } diff --git a/lib/components/fabro-workflow/src/pipeline/execute/tests.rs b/lib/components/fabro-workflow/src/pipeline/execute/tests.rs index 907e2b46a..5622d8304 100644 --- a/lib/components/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/components/fabro-workflow/src/pipeline/execute/tests.rs @@ -15,8 +15,8 @@ use fabro_auth::test_support as auth_test_support; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_hooks::HookSettings; use fabro_interview::AutoApproveInterviewer; -use fabro_sandbox::test_support::MockSandbox; -use fabro_sandbox::{RunSandbox, SandboxSpec}; +use fabro_sandbox::test_support::{MockSandbox, local_sandbox_id}; +use fabro_sandbox::{ProviderAccess, RunSandbox, SandboxSpec}; use fabro_store::Database; use fabro_types::settings::run::RunModelControls; use fabro_types::{ @@ -263,9 +263,10 @@ async fn execute_test_run_with_options( run_store: run_store.into(), dry_run: false, emitter: emitter.clone(), - sandbox: SandboxSpec::Local { - working_directory: std::env::current_dir().unwrap(), - }, + sandbox: SandboxSpec::local( + std::env::current_dir().unwrap(), + ProviderAccess::default(), + ), llm: LlmSpec { model: "test-model".to_string(), provider_id: lithos_llm::catalog::builtin::anthropic(), @@ -325,9 +326,10 @@ async fn execute_runs_start_to_exit_and_returns_final_context() { run_store: run_store.into(), dry_run: false, emitter: test_emitter_arc("run-test"), - sandbox: SandboxSpec::Local { - working_directory: std::env::current_dir().unwrap(), - }, + sandbox: SandboxSpec::local( + std::env::current_dir().unwrap(), + ProviderAccess::default(), + ), llm: LlmSpec { model: "test-model".to_string(), provider_id: lithos_llm::catalog::builtin::anthropic(), @@ -412,10 +414,11 @@ async fn resumed_in_flight_node_starts_a_new_stage_execution() { let run_store = test_run_store(&run_id).await; seed_created_and_starting(&run_store, &run_options, &graph).await; // Resume reconnects to the previously recorded sandbox. + let working_directory = std::env::current_dir().unwrap(); append_event(&run_store, &run_id, &Event::SandboxInitialized { - working_directory: std::env::current_dir().unwrap().display().to_string(), + working_directory: working_directory.display().to_string(), provider: fabro_types::SandboxProviderKind::LOCAL, - id: "local".to_string(), + id: local_sandbox_id(&working_directory).await, image: None, snapshot: None, repo_cloned: None, @@ -468,9 +471,10 @@ async fn resumed_in_flight_node_starts_a_new_stage_execution() { run_store: run_store.into(), dry_run: false, emitter: emitter.clone(), - sandbox: SandboxSpec::Local { - working_directory: std::env::current_dir().unwrap(), - }, + sandbox: SandboxSpec::local( + std::env::current_dir().unwrap(), + ProviderAccess::default(), + ), llm: LlmSpec { model: "test-model".to_string(), provider_id: lithos_llm::catalog::builtin::anthropic(), @@ -584,9 +588,7 @@ async fn run_with_lifecycle( run_store: run_store.into(), dry_run: false, emitter: emitter.clone(), - sandbox: SandboxSpec::Local { - working_directory: PathBuf::from(sandbox.working_directory()), - }, + sandbox: SandboxSpec::local(sandbox.working_directory(), ProviderAccess::default()), llm: LlmSpec { model: "test-model".to_string(), provider_id: lithos_llm::catalog::builtin::anthropic(), @@ -943,10 +945,10 @@ async fn execute_reactivates_sandbox_after_a_stage_can_leave_it_stopped() { .unwrap(); assert_eq!(outcome.status, StageOutcome::Succeeded); - assert_eq!(sandbox.stop_count(), 1); - assert!(sandbox.walk_files_was_called()); + assert_eq!(sandbox.driver().stop_count(), 1); + assert!(sandbox.driver().scripted_search().walk_calls() > 0); assert_eq!( - sandbox.start_count(), + sandbox.driver().start_count(), 1, "the stopped sandbox is started again before the walk" ); diff --git a/lib/components/fabro-workflow/src/pipeline/finalize.rs b/lib/components/fabro-workflow/src/pipeline/finalize.rs index 8d82f5f1e..ad87e3a8e 100644 --- a/lib/components/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/components/fabro-workflow/src/pipeline/finalize.rs @@ -1644,8 +1644,8 @@ mod tests { .await .unwrap(); - assert_eq!(sandbox.stop_count(), 1); - assert_eq!(sandbox.delete_count(), 0); + assert_eq!(sandbox.driver().stop_count(), 1); + assert_eq!(sandbox.driver().delete_count(), 0); } #[tokio::test] @@ -1678,8 +1678,8 @@ mod tests { .await .unwrap(); - assert_eq!(sandbox.stop_count(), 0); - assert_eq!(sandbox.delete_count(), 0); + assert_eq!(sandbox.driver().stop_count(), 0); + assert_eq!(sandbox.driver().delete_count(), 0); } #[tokio::test] diff --git a/lib/components/fabro-workflow/src/pipeline/initialize.rs b/lib/components/fabro-workflow/src/pipeline/initialize.rs index c23aecf82..39fc772df 100644 --- a/lib/components/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/components/fabro-workflow/src/pipeline/initialize.rs @@ -10,20 +10,20 @@ use fabro_hooks::{HookContext, HookDecision, HookEvent, HookExecutionContext, Ho use fabro_llm::credentials::{CredentialProvider, readiness}; use fabro_llm::lithos_catalog::Catalog; use fabro_sandbox::{ - DaytonaCredentials, GitSetupIntent, ProviderAccess, RunSandbox, SandboxSpec, - reconnect_for_run_with_events, shell_quote, + DaytonaCredentials, ExecResultExt, GitSetupIntent, ProviderAccess, RunSandbox, + reconnect_for_run, }; use fabro_static::EnvVars; use fabro_types::RunSandboxKind; use fabro_util::time::elapsed_ms; use fabro_vault::Vault; -use sandbox_driver::{CorrelationId, EventContext}; +use sandbox_driver::{CorrelationId, EventContext, Git as _}; use tokio::runtime::Handle; use tokio::sync::RwLock as AsyncRwLock; use super::types::{InitOptions, Initialized, LlmSpec, Persisted, SandboxEnvSpec}; use crate::error::Error; -use crate::event::{Event, RunNoticeCode, RunNoticeLevel, SandboxEventBridge, SandboxLifecycle}; +use crate::event::{DriverEventRecorder, Event, RunNoticeCode, RunNoticeLevel, SandboxLifecycle}; use crate::git::GitAuthor; use crate::git_bridge; use crate::handler::llm::{AgentAcpBackend, BackendRouter, PebbleBackend, routing}; @@ -79,18 +79,15 @@ async fn configure_sandbox_git_identity( sandbox: &RunSandbox, author: &GitAuthor, ) -> Result<(), Error> { - let command = format!( - "git config --local user.name {} && git config --local user.email {}", - shell_quote(&author.name), - shell_quote(&author.email) - ); - sandbox - .exec_command(&command, 10_000, None, None, None) - .await - .map_err(|err| Error::engine_with_source("Sandbox git identity setup failed", err))? - .into_result("git config user identity") + let git = sandbox + .git() .map_err(|err| Error::engine_with_source("Sandbox git identity setup failed", err))?; - + let repo = sandbox.working_directory(); + for (key, value) in [("user.name", &author.name), ("user.email", &author.email)] { + git.config_set(repo, key, value) + .await + .map_err(|err| Error::engine_with_source("Sandbox git identity setup failed", err))?; + } Ok(()) } @@ -371,7 +368,7 @@ pub async fn initialize( .as_ref() .and_then(|git| git.sha.clone()); if !is_resume - && !matches!(options.sandbox, SandboxSpec::Local { .. }) + && !options.sandbox.kind.is_local() && matches!( options .run_options @@ -388,14 +385,12 @@ pub async fn initialize( ); } - // The driver reports what it does to the run's sandbox; the bridge - // records the operations fabro keeps as run events. + // The driver reports what it does to the run's sandbox; every event is + // kept as a run event. let provider_name = options.sandbox.provider_name(); - let sandbox_events = EventContext::new(Arc::new(SandboxEventBridge::new( - Arc::clone(&options.emitter), - provider_name.clone(), - options.sandbox.image(), - ))) + let sandbox_events = EventContext::new(Arc::new(DriverEventRecorder::new(Arc::clone( + &options.emitter, + )))) .correlation_id(CorrelationId::new(options.run_options.run_id.to_string())); let attach_instance = if is_resume { let record = options @@ -438,7 +433,7 @@ pub async fn initialize( DaytonaCredentials::from_api_key(api_key.to_string(), process_env_var) }), }; - let sandbox = reconnect_for_run_with_events( + let sandbox = reconnect_for_run( &instance, &access, Some(options.run_options.run_id), @@ -465,10 +460,8 @@ pub async fn initialize( }); if attach_existing { - // Resume needs the full provider health check. `activate()` is the - // lighter access-time operation used after a run is already active. sandbox - .start() + .activate() .await .map_err(|e| Error::engine_with_source("Failed to start sandbox", e))?; } else { @@ -492,12 +485,16 @@ pub async fn initialize( error, )); } + // A local sandbox's id is derived from its directory, which the + // record already names; it is not a name worth showing. + let name = Some(sandbox.sandbox_info()) + .filter(|name| !name.is_empty() && !sandbox.kind().is_local()); options.emitter.emit(&Event::Sandbox { event: SandboxLifecycle::Ready { - provider: provider_name.clone(), + provider: provider_name.clone(), duration_ms: elapsed_ms(started), - name: Some(sandbox.sandbox_info()).filter(|name| !name.is_empty()), - url: sandbox.console_url().await, + name, + url: sandbox.console_url().await, }, }); } @@ -522,9 +519,7 @@ pub async fn initialize( } if !attach_existing { - let run_sandbox = options - .sandbox - .to_run_sandbox_instance(&sandbox, options.run_options.run_id); + let run_sandbox = options.sandbox.to_run_sandbox_instance(&sandbox); let runtime = &run_sandbox.runtime; options.emitter.emit(&Event::SandboxInitialized { working_directory: runtime.working_directory.clone(), @@ -672,19 +667,19 @@ pub async fn initialize( } cancel_token.cancel(); let duration_ms = crate::millis_u64(cmd_start.elapsed()); - if !result.is_success() { - let exit_code = result.display_exit_code(); + if !result.success() { + let exit_code = result.program_exit_code().unwrap_or(-1); let exec_output_tail = result.default_redacted_output_tail(); + let stderr = result.stderr_lossy(); options.emitter.emit(&Event::SetupFailed { command: command.clone(), index, exit_code, - stderr: result.stderr.clone(), + stderr: stderr.clone(), exec_output_tail, }); return Err(Error::engine(format!( - "Setup command failed (exit code {}): {command}\n{}", - exit_code, result.stderr, + "Setup command failed (exit code {exit_code}): {command}\n{stderr}", ))); } let exit_code = result.exit_code.unwrap_or(0); @@ -941,7 +936,7 @@ mod tests { run_store, dry_run: false, emitter: Arc::clone(&emitter), - sandbox: SandboxSpec::Local { working_directory }, + sandbox: SandboxSpec::local(working_directory, ProviderAccess::default()), llm: LlmSpec { model: "test-model".to_string(), provider_id: lithos_llm::catalog::builtin::anthropic(), @@ -1072,11 +1067,18 @@ mod tests { .await .expect("git identity should configure"); - let commands = sandbox.captured_commands(); - assert_eq!(commands, vec![ - "git config --local user.name 'Fabro Bot' && git config --local user.email \ - fabro-bot@example.com" - ]); + let commands = sandbox.driver().scripted_exec().commands(); + assert_eq!(commands.len(), 2, "{commands:#?}"); + assert!( + commands[0].contains("'config' '--local' '--' 'user.name' 'Fabro Bot'"), + "{}", + commands[0] + ); + assert!( + commands[1].contains("'config' '--local' '--' 'user.email' 'fabro-bot@example.com'"), + "{}", + commands[1] + ); } #[tokio::test] @@ -1353,7 +1355,7 @@ mod tests { "acp.command".to_string(), AttrValue::String(format!( "python3 {}", - fabro_sandbox::shell_quote(&script_path.to_string_lossy()) + fabro_util::shell::shell_quote(&script_path.to_string_lossy()) )), ); let mut exit = Node::new("exit"); @@ -1385,9 +1387,7 @@ mod tests { run_store: run_store.into(), dry_run: false, emitter: emitter.clone(), - sandbox: SandboxSpec::Local { - working_directory: temp.path().to_path_buf(), - }, + sandbox: SandboxSpec::local(temp.path(), ProviderAccess::default()), llm: LlmSpec { model: "fake-acp".to_string(), provider_id: lithos_llm::catalog::builtin::openai(), @@ -1490,9 +1490,10 @@ mod tests { run_store: run_store.into(), dry_run: false, emitter: emitter.clone(), - sandbox: SandboxSpec::Local { - working_directory: std::env::current_dir().unwrap(), - }, + sandbox: SandboxSpec::local( + std::env::current_dir().unwrap(), + ProviderAccess::default(), + ), llm: LlmSpec { model: "test-model".to_string(), provider_id: lithos_llm::catalog::builtin::anthropic(), @@ -1634,9 +1635,10 @@ mod tests { }, dry_run: false, emitter: emitter.clone(), - sandbox: SandboxSpec::Local { - working_directory: std::env::current_dir().unwrap(), - }, + sandbox: SandboxSpec::local( + std::env::current_dir().unwrap(), + ProviderAccess::default(), + ), llm: LlmSpec { model: "test-model".to_string(), provider_id: lithos_llm::catalog::builtin::anthropic(), diff --git a/lib/components/fabro-workflow/src/pipeline/publish.rs b/lib/components/fabro-workflow/src/pipeline/publish.rs index 6455ec3f1..44f16835a 100644 --- a/lib/components/fabro-workflow/src/pipeline/publish.rs +++ b/lib/components/fabro-workflow/src/pipeline/publish.rs @@ -100,9 +100,6 @@ fn push_attempt_cause(attempt: &fabro_sandbox::PushAttempt) -> String { { let _ = write!(line, " (token age {age_ms}ms)"); } - if let Some(refresh_error) = attempt.refresh_error { - let _ = write!(line, ", refresh error: {refresh_error}"); - } line } @@ -232,8 +229,8 @@ impl Concluded { async fn push_final_commit(&self, run_branch: &str) -> Result<(), Error> { // The terminal push guards the whole run's value, so it gets a real // retry budget; attempts are nearly free at this point. - let plan = fabro_sandbox::RetryPlan::publish_push(); - match push_run_branch(self.services.sandbox.as_ref(), run_branch, &plan).await { + let policy = fabro_sandbox::publish_push_policy(); + match push_run_branch(self.services.sandbox.as_ref(), run_branch, &policy).await { Ok(report) => { self.services.sandbox_git.record_successful_push(); self.services.emitter.emit(&Event::GitPush { @@ -285,7 +282,6 @@ mod tests { attempt: u32, retry_reason: Option, token_age_ms: Option, - refresh_error: Option, ) -> fabro_sandbox::PushAttempt { let started_at = Utc::now(); fabro_sandbox::PushAttempt { @@ -302,8 +298,6 @@ mod tests { expires_at: started_at + chrono::Duration::hours(1), }, }), - credential_action: Some(fabro_sandbox::RemoteCredentialAction::Unchanged), - refresh_error, } } @@ -314,14 +308,12 @@ mod tests { .iter() .enumerate() .map(|(index, reason)| fabro_sandbox::PushAttempt { - attempt: u32::try_from(index).unwrap() + 1, - started_at: Utc::now(), - success: false, - retry_reason: *reason, - exec_output_tail: None, - token: None, - credential_action: None, - refresh_error: None, + attempt: u32::try_from(index).unwrap() + 1, + started_at: Utc::now(), + success: false, + retry_reason: *reason, + exec_output_tail: None, + token: None, }) .collect() } @@ -362,13 +354,11 @@ mod tests { 1, Some(fabro_sandbox::GitRetryReason::TokenReplication), Some(180), - None, ), push_attempt( 2, Some(fabro_sandbox::GitRetryReason::TokenReplication), Some(3320), - Some(fabro_sandbox::RefreshErrorKind::SetUrl), ), ]; let last_push = Utc::now() - chrono::Duration::seconds(67); @@ -401,7 +391,7 @@ mod tests { "{attempt_lines:?}" ); assert!( - attempt_lines[1].contains("refresh error: set_url"), + attempt_lines[1].contains("(token age 3320ms)"), "{attempt_lines:?}" ); assert_eq!( diff --git a/lib/components/fabro-workflow/src/run_metadata.rs b/lib/components/fabro-workflow/src/run_metadata.rs index 73d03c634..d058cbaa7 100644 --- a/lib/components/fabro-workflow/src/run_metadata.rs +++ b/lib/components/fabro-workflow/src/run_metadata.rs @@ -24,8 +24,7 @@ pub(crate) fn metadata_push_failure_is_transient( detail: &str, token: Option<&TokenSnapshot>, ) -> bool { - let credentials = fabro_sandbox::CredentialContext::from_snapshot(token); - fabro_sandbox::classify_failure(detail, credentials).is_some() + fabro_sandbox::transient_git_failure(detail, token).is_some() } #[derive(Debug, thiserror::Error)] diff --git a/lib/components/fabro-workflow/src/sandbox_git.rs b/lib/components/fabro-workflow/src/sandbox_git.rs index 249874659..d100ab7dd 100644 --- a/lib/components/fabro-workflow/src/sandbox_git.rs +++ b/lib/components/fabro-workflow/src/sandbox_git.rs @@ -1,10 +1,23 @@ +//! Fabro's git operations on a run's sandbox, over the driver's git facet. +//! +//! The driver runs every command hardened (no auto maintenance or gc, no +//! repository hooks, no fsmonitor, unquoted paths, no signing; read verbs +//! refuse the file transport and external diff drivers) and returns typed +//! results. Fabro decides what to stage, what to say in a checkpoint +//! commit, and which ranges the Run Files endpoint reads. + use std::collections::{HashMap, HashSet}; +use std::time::Duration; use fabro_checkpoint::trailer as trailerlink; use fabro_checkpoint::trailer::Trailer; -use fabro_sandbox::{RunSandbox, shell_quote}; +use fabro_sandbox::RunSandbox; use fabro_types::settings::run::RunCheckpointSettings; use fabro_util::error::SharedError; +use sandbox_driver::{ + Git as _, GitChange, GitCommitOptions, GitDiffEntry, GitDiffOptions, GitFacet, GitFailureKind, + GitRevisionRange, +}; use crate::artifact_snapshot; use crate::git::GitAuthor; @@ -18,35 +31,33 @@ pub struct GitCommandError { pub source: fabro_sandbox::Error, } -pub const GIT_REMOTE: &str = - "git -c maintenance.auto=0 -c gc.auto=0 -c commit.gpgsign=false -c tag.gpgsign=false"; +/// Rename detection threshold for the diffs the Run Files endpoint and the +/// checkpoint summaries read. +const FIND_RENAMES_PERCENT: u8 = 50; -pub(crate) fn exec_err(label: &str, r: fabro_sandbox::ExecResult) -> GitCommandError { - if r.is_timed_out() { - return GitCommandError { - message: format!("{label} timed out after {}ms", r.duration_ms), - source: fabro_sandbox::Error::exec(label, r), - }; - } - if r.is_cancelled() { - return GitCommandError { - message: format!("{label} cancelled after {}ms", r.duration_ms), - source: fabro_sandbox::Error::exec(label, r), - }; - } +/// Budget for the machine-readable diffs behind the Run Files endpoint. +const RUN_FILES_TIMEOUT: Duration = Duration::from_secs(10); - let exit = r.display_exit_code(); +/// The sandbox's git facet, or the error a git operation reports when the +/// provider has none. +fn facet<'a>(sandbox: &'a RunSandbox, label: &str) -> Result, GitCommandError> { + sandbox.git().map_err(|source| GitCommandError { + message: format!("{label} failed"), + source, + }) +} + +fn git_error(label: &str, error: sandbox_driver::Error) -> GitCommandError { GitCommandError { - message: format!("{label} failed (exit {exit})"), - source: fabro_sandbox::Error::exec(label, r), + message: format!("{label} failed"), + source: fabro_sandbox::Error::from(error), } } -/// Run a git checkpoint commit via the sandbox. -#[allow( - clippy::too_many_arguments, - reason = "Checkpointing needs explicit run metadata, checkpoint settings, and author inputs." -)] +/// Commit the run's checkpoint: everything under the working directory +/// except the built-in and configured excludes, as an allow-empty commit +/// carrying fabro's trailers. Repository hooks never run: the driver +/// disables them on every command it issues. pub async fn git_checkpoint( sandbox: &RunSandbox, run_id: &str, @@ -57,30 +68,24 @@ pub async fn git_checkpoint( checkpoint: &RunCheckpointSettings, author: &GitAuthor, ) -> std::result::Result { - let mut all_excludes: Vec = artifact_snapshot::EXCLUDE_DIRS - .iter() - .map(|d| format!("**/{d}/**")) - .collect(); - all_excludes.extend(checkpoint.exclude_globs.iter().cloned()); + let git = facet(sandbox, "git add")?; + let repo = sandbox.working_directory(); - let pathspecs: Vec = all_excludes - .iter() - .map(|g| format!("':(glob,exclude){g}'")) - .collect(); - let add_cmd = format!("{GIT_REMOTE} add -A -- . {}", pathspecs.join(" ")); - let add_result = sandbox - .exec_command(&add_cmd, checkpoint.commit_timeout_ms, None, None, None) - .await; - match add_result { - Ok(r) if r.is_success() => {} - Ok(r) => return Err(exec_err("git add", r)), - Err(e) => { - return Err(GitCommandError { - message: "git add failed".to_string(), - source: e, - }); - } - } + let mut pathspecs = vec![".".to_owned()]; + pathspecs.extend( + artifact_snapshot::EXCLUDE_DIRS + .iter() + .map(|dir| format!(":(glob,exclude)**/{dir}/**")), + ); + pathspecs.extend( + checkpoint + .exclude_globs + .iter() + .map(|glob| format!(":(glob,exclude){glob}")), + ); + git.add_all(repo, &pathspecs) + .await + .map_err(|error| git_error("git add", error))?; let subject = format!("fabro({run_id}): {node_id} ({status})"); let completed_str = completed_count.to_string(); @@ -104,52 +109,11 @@ pub async fn git_checkpoint( let mut message = trailerlink::format_message(&subject, "", &trailers); author.append_footer(&mut message); - let msg_path = format!("/tmp/fabro-commit-msg-{}", uuid::Uuid::new_v4()); - if let Err(e) = sandbox.write_file(&msg_path, &message).await { - return Err(GitCommandError { - message: "failed to write commit message file".to_string(), - source: e, - }); - } - - let msg_path_q = shell_quote(&msg_path); - let no_verify = if checkpoint.skip_git_hooks { - " --no-verify" - } else { - "" - }; - let commit_cmd = format!( - "{GIT_REMOTE} -c user.name={name} -c user.email={email} commit --allow-empty{no_verify} -F {msg_path_q}", - name = shell_quote(&author.name), - email = shell_quote(&author.email), - ); - let commit_result = sandbox - .exec_command(&commit_cmd, checkpoint.commit_timeout_ms, None, None, None) - .await; - let _ = sandbox.delete_file(&msg_path).await; - match commit_result { - Ok(r) if r.is_success() => {} - Ok(r) => return Err(exec_err("git commit", r)), - Err(e) => { - return Err(GitCommandError { - message: "git commit failed".to_string(), - source: e, - }); - } - } - - let sha_cmd = format!("{GIT_REMOTE} rev-parse HEAD"); - let sha_result = sandbox - .exec_command(&sha_cmd, 10_000, None, None, None) - .await; - match sha_result { - Ok(r) if r.is_success() => Ok(r.stdout.trim().to_string()), - Ok(r) => Err(exec_err("git rev-parse HEAD", r)), - Err(e) => Err(GitCommandError { - message: "git rev-parse HEAD failed".to_string(), - source: e, - }), - } + let mut options = GitCommitOptions::new(message, &author.name, &author.email); + options.allow_empty = true; + git.commit(repo, &options) + .await + .map_err(|error| git_error("git commit", error)) } /// Run a git checkpoint after the per-run sandbox git capability probe. @@ -186,7 +150,7 @@ pub(crate) async fn checked_git_checkpoint( .map_err(|err| SharedError::new(anyhow::Error::new(err))) } -/// Run a git diff via the sandbox (30 s default timeout). +/// The unified diff from `base` to `HEAD` (30 s default timeout). pub(crate) async fn git_diff( sandbox: &RunSandbox, base: &str, @@ -194,67 +158,33 @@ pub(crate) async fn git_diff( git_diff_with_timeout(sandbox, base, 30_000).await } -/// Run a git diff via the sandbox with a caller-supplied timeout in -/// milliseconds. +/// The unified diff from `base` to `HEAD` under a caller-supplied timeout +/// in milliseconds. /// /// Failure-path capture uses a shorter timeout than the checkpoint path so a /// pathological workspace (FS locks, corrupted index) doesn't stall terminal -/// event emission downstream (Slack notifier, SSE, CI hooks). +/// event emission downstream (Slack notifier, SSE, CI hooks). Paths come +/// back unquoted, which the Run Files denylist parser relies on. pub(crate) async fn git_diff_with_timeout( sandbox: &RunSandbox, base: &str, timeout_ms: u64, ) -> std::result::Result { - // `-c core.quotePath=false` forces paths with non-ASCII, tabs, quotes, - // or backslashes to emit unquoted. The Run Files Changed endpoint's - // `strip_denylisted_sections` parser only recognizes unquoted - // `diff --git a/ b/` headers; without this flag git would - // wrap such paths in `"a/…"` / `"b/…"` and evade the denylist (see - // docs/agent/reviews/2026-04-19-run-files-security-review.md). - let cmd = format!("{GIT_REMOTE} -c core.quotePath=false diff {base} HEAD"); - match sandbox - .exec_command(&cmd, timeout_ms, None, None, None) + let git = facet(sandbox, "git diff")?; + let options = GitDiffOptions::new(GitRevisionRange::new(base).to("HEAD")) + .timeout(Duration::from_millis(timeout_ms)); + git.diff_patch(sandbox.working_directory(), &options) .await - { - Ok(r) if r.is_success() => Ok(r.stdout), - Ok(r) => Err(exec_err("git diff", r)), - Err(e) => Err(GitCommandError { - message: "git diff failed".to_string(), - source: e, - }), - } + .map_err(|error| git_error("git diff", error)) } // ── Machine-readable diff enumeration (Run Files endpoint) ───────────────── -/// Hardened git-command prefix for the Run Files endpoint. +/// A single changed-file entry of a range, as the Run Files endpoint reads +/// it. /// -/// Layers on top of [`GIT_REMOTE`]: -/// - `core.hooksPath=/dev/null`: repo-supplied hooks do not run. -/// - `core.fsmonitor=false`: no fsmonitor daemon interactions. -/// - `protocol.file.allow=never`: blocks local-protocol fetches. -/// -/// These invocations use [`sandbox_git_hardening_env`] via `exec_command` to -/// disable terminal prompts and external diff drivers. -const GIT_HARDENED: &str = "git -c maintenance.auto=0 -c gc.auto=0 -c core.hooksPath=/dev/null -c core.fsmonitor=false -c protocol.file.allow=never -c core.quotePath=false"; - -/// Environment additions applied to every hardened sandbox-side git invocation. -/// -/// `GIT_TERMINAL_PROMPT=0` prevents git from stalling on credential prompts -/// when a remote or subprocess triggers one. Clearing `GIT_EXTERNAL_DIFF` -/// neutralizes any inherited custom diff driver. -fn sandbox_git_hardening_env() -> std::collections::HashMap { - std::collections::HashMap::from([ - ("GIT_TERMINAL_PROMPT".to_string(), "0".to_string()), - ("GIT_EXTERNAL_DIFF".to_string(), String::new()), - ]) -} - -/// A single changed-file entry from `git diff --raw -z --find-renames=50%`. -/// -/// Paths are repo-relative, UTF-8; non-UTF-8 filenames are rejected by the -/// parser. Blob SHAs are lowercase hex. Modes are octal integers (`100644`, -/// `100755`, `120000`, `160000`, …). +/// Paths are repo-relative, UTF-8. Blob SHAs are lowercase hex. Modes are +/// octal strings (`100644`, `100755`, `120000`, `160000`, …). #[derive(Debug, Clone, PartialEq, Eq)] pub enum RawDiffEntry { Added { @@ -281,107 +211,111 @@ pub enum RawDiffEntry { new_mode: String, similarity: u8, }, + /// Symlink creation, deletion, or target change. No blob contents are + /// fetched for these: the "content" is the link target, which is + /// not meaningful to diff as file text. Symlink { path: String, change_kind: SymlinkChange, old_blob: Option, new_blob: Option, }, + /// Submodule (gitlink) pointer change. No blob contents exist for + /// these in the parent repo. Submodule { path: String, change_kind: SubmoduleChange, }, } -/// Lifecycle of a symlink entry (mode `120000`). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SymlinkChange { Added, - Modified, Deleted, + Modified, } -/// Lifecycle of a submodule entry (mode `160000`). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SubmoduleChange { Added, - Modified, Deleted, + Modified, } -/// Error produced by the sandbox-git helpers. -/// -/// Callers discriminate between transient (retry-safe) and permanent -/// conditions: a 503 can be returned to the client on `Transient`, while -/// `Permanent` errors should fall through to the patch-only fallback. -#[derive(Debug, Clone, PartialEq, Eq)] +/// Errors from the machine-readable diff paths, classified so the server +/// can fall back or retry. +#[derive(Debug, thiserror::Error)] pub enum DiffError { - /// Retry-safe failure: timeout, process kill, transient I/O. - Transient { message: String }, - /// Non-retryable failure: unknown revision, malformed output, etc. + /// Unknown revision, missing object, or a repository the driver could + /// not read: retrying will not help. + #[error("permanent git error: {message}")] Permanent { message: String }, + /// A timeout, a transport failure, or any other failure worth retrying. + #[error("transient git error: {message}")] + Transient { message: String }, } -impl std::fmt::Display for DiffError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Transient { message } => write!(f, "transient: {message}"), - Self::Permanent { message } => write!(f, "permanent: {message}"), - } - } -} - -impl std::error::Error for DiffError {} - -/// Size metadata for a single blob, as reported by `git cat-file -/// --batch-check`. +/// Blob metadata from a batch lookup. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BlobMeta { pub sha: String, - /// `None` if the blob is missing (git reports `missing`). + /// `None` when git reports the blob as missing. pub size: Option, } /// Enumerate files changed between `base_sha` and `to_sha` via the sandbox. /// -/// Uses `git diff --raw -z --find-renames=50%` to get a machine-readable, -/// null-separated, SHA-addressed listing. Paths from this output are treated -/// as metadata only — blob reads use the SHAs, not the paths. -/// -/// The `--numstat` side-call classifies text vs binary so callers can skip -/// binary contents without ever invoking `git cat-file --batch` on them. +/// Paths from this listing are treated as metadata only; blob reads use +/// the SHAs, not the paths. The `--numstat` companion classifies text vs +/// binary so callers can skip binary contents without ever fetching them. pub async fn list_changed_files_raw( sandbox: &RunSandbox, base_sha: &str, to_sha: &str, ) -> std::result::Result, DiffError> { - let base_q = shell_quote(base_sha); - let to_q = shell_quote(to_sha); - let env = sandbox_git_hardening_env(); - let cmd = format!("{GIT_HARDENED} diff --raw -z --find-renames=50% {base_q}..{to_q}"); - let res = sandbox - .exec_command(&cmd, 10_000, None, Some(&env), None) + let git = diff_facet(sandbox)?; + let options = GitDiffOptions::new(GitRevisionRange::new(base_sha).to(to_sha)) + .find_renames(FIND_RENAMES_PERCENT) + .timeout(RUN_FILES_TIMEOUT); + let entries = git + .diff_entries(sandbox.working_directory(), &options) .await - .map_err(|e| DiffError::Transient { - message: e.display_with_causes(), - })?; + .map_err(|error| diff_error(&error))?; + entries + .into_iter() + .map(raw_diff_entry) + .collect::, String>>() + .map_err(|message| DiffError::Permanent { message }) +} - if res.is_timed_out() { - return Err(DiffError::Transient { - message: "git diff --raw timed out".to_string(), - }); - } - if !res.is_success() { - // An unknown-object / bad-revision error is permanent; everything - // else we treat as transient so the server can retry safely. - let stderr = res.stderr.trim().to_string(); - if is_permanent_git_error(&stderr) { - return Err(DiffError::Permanent { message: stderr }); +fn diff_facet(sandbox: &RunSandbox) -> std::result::Result, DiffError> { + sandbox.git().map_err(|error| DiffError::Permanent { + message: fabro_sandbox::display_for_log(&error), + }) +} + +/// What a driver failure means for the Run Files endpoint: an unknown +/// revision or missing object is permanent (the handler falls through to +/// the stored patch), and so is output the driver could not read, since a +/// retry reads the same object; a timeout, a transport failure, or anything +/// else is transient and surfaces as a 503 for the client to retry. +fn diff_error(error: &sandbox_driver::Error) -> DiffError { + let message = fabro_sandbox::display_for_log(error); + match error { + sandbox_driver::Error::Io { .. } => DiffError::Permanent { message }, + sandbox_driver::Error::Git(failure) => { + let stderr = failure + .output() + .map(|output| String::from_utf8_lossy(output.stderr()).into_owned()) + .unwrap_or_default(); + if failure.kind() == GitFailureKind::RefNotFound || is_permanent_git_error(&stderr) { + DiffError::Permanent { message } + } else { + DiffError::Transient { message } + } } - return Err(DiffError::Transient { message: stderr }); + _ => DiffError::Transient { message }, } - - parse_raw_z(&res.stdout).map_err(|message| DiffError::Permanent { message }) } fn is_permanent_git_error(stderr: &str) -> bool { @@ -390,137 +324,89 @@ fn is_permanent_git_error(stderr: &str) -> bool { let lower = stderr.to_lowercase(); lower.contains("unknown revision") || lower.contains("bad revision") + || lower.contains("bad object") || lower.contains("invalid revision") || lower.contains("no such path") || lower.contains("not a valid object name") } -fn parse_raw_z(stdout: &str) -> std::result::Result, String> { - // git diff --raw -z format: - // ": \0\0" - // For renames/copies: - // ": R\0\0\0" - // - // Multiple entries are concatenated with no separator between them. - let mut entries = Vec::new(); - let mut tokens = stdout.split('\0').peekable(); - while let Some(header) = tokens.next() { - if header.is_empty() { - continue; - } - if !header.starts_with(':') { - return Err(format!("unexpected token in diff --raw: {header:?}")); - } - let fields: Vec<&str> = header[1..].split(' ').collect(); - if fields.len() < 5 { - return Err(format!("short raw-diff header: {header:?}")); - } - let src_mode = fields[0]; - let dst_mode = fields[1]; - let src_sha = fields[2]; - let dst_sha = fields[3]; - let status = fields[4]; +/// The Run Files entry for one path of the driver's diff. Mode 120000 is a +/// symlink, 160000 a submodule. +fn raw_diff_entry(entry: GitDiffEntry) -> std::result::Result { + let is_mode = |mode: &Option, expected: &str| mode.as_deref() == Some(expected); + let is_symlink = is_mode(&entry.old_mode, "120000") || is_mode(&entry.new_mode, "120000"); + let is_submodule = is_mode(&entry.old_mode, "160000") || is_mode(&entry.new_mode, "160000"); + let path = entry.path; + let old_blob = entry.old_blob.unwrap_or_default(); + let new_blob = entry.new_blob.unwrap_or_default(); + let old_mode = entry.old_mode.unwrap_or_default(); + let new_mode = entry.new_mode.unwrap_or_default(); - let entry = if status.starts_with('R') || status.starts_with('C') { - let score: u8 = status[1..].parse().unwrap_or(0); - let old_path = tokens - .next() - .ok_or_else(|| "missing old_path for rename".to_string())? - .to_string(); - let new_path = tokens - .next() - .ok_or_else(|| "missing new_path for rename".to_string())? - .to_string(); - RawDiffEntry::Renamed { - old_path, - new_path, - old_blob: src_sha.to_string(), - new_blob: dst_sha.to_string(), - new_mode: dst_mode.to_string(), - similarity: score, - } - } else { - let path = tokens - .next() - .ok_or_else(|| "missing path for diff entry".to_string())? - .to_string(); - classify_entry(status, src_mode, dst_mode, src_sha, dst_sha, &path)? - }; - entries.push(entry); - } - Ok(entries) -} - -fn classify_entry( - status: &str, - src_mode: &str, - dst_mode: &str, - src_sha: &str, - dst_sha: &str, - path: &str, -) -> std::result::Result { - // Mode 120000 = symlink, 160000 = submodule (gitlink). - let is_symlink_change = src_mode == "120000" || dst_mode == "120000"; - let is_submodule_change = src_mode == "160000" || dst_mode == "160000"; - - Ok(match (status, is_symlink_change, is_submodule_change) { - ("A", true, _) => RawDiffEntry::Symlink { - path: path.to_string(), - change_kind: SymlinkChange::Added, - old_blob: None, - new_blob: Some(dst_sha.to_string()), + Ok(match (entry.change, is_symlink, is_submodule) { + (GitChange::Renamed | GitChange::Copied, _, _) => RawDiffEntry::Renamed { + old_path: entry.old_path.unwrap_or_default(), + new_path: path, + old_blob, + new_blob, + new_mode, + similarity: entry.similarity.unwrap_or(0), }, - ("A", _, true) => RawDiffEntry::Submodule { - path: path.to_string(), + (GitChange::Added, true, _) => RawDiffEntry::Symlink { + path, + change_kind: SymlinkChange::Added, + old_blob: None, + new_blob: Some(new_blob), + }, + (GitChange::Added, _, true) => RawDiffEntry::Submodule { + path, change_kind: SubmoduleChange::Added, }, - ("A", _, _) => RawDiffEntry::Added { - path: path.to_string(), - new_blob: dst_sha.to_string(), - new_mode: dst_mode.to_string(), + (GitChange::Added, _, _) => RawDiffEntry::Added { + path, + new_blob, + new_mode, }, - ("D", true, _) => RawDiffEntry::Symlink { - path: path.to_string(), + (GitChange::Deleted, true, _) => RawDiffEntry::Symlink { + path, change_kind: SymlinkChange::Deleted, - old_blob: Some(src_sha.to_string()), - new_blob: None, + old_blob: Some(old_blob), + new_blob: None, }, - ("D", _, true) => RawDiffEntry::Submodule { - path: path.to_string(), + (GitChange::Deleted, _, true) => RawDiffEntry::Submodule { + path, change_kind: SubmoduleChange::Deleted, }, - ("D", _, _) => RawDiffEntry::Deleted { - path: path.to_string(), - old_blob: src_sha.to_string(), - old_mode: src_mode.to_string(), + (GitChange::Deleted, _, _) => RawDiffEntry::Deleted { + path, + old_blob, + old_mode, }, - ("M" | "T", true, _) => RawDiffEntry::Symlink { - path: path.to_string(), + (GitChange::Modified | GitChange::TypeChanged, true, _) => RawDiffEntry::Symlink { + path, change_kind: SymlinkChange::Modified, - old_blob: Some(src_sha.to_string()), - new_blob: Some(dst_sha.to_string()), + old_blob: Some(old_blob), + new_blob: Some(new_blob), }, - ("M" | "T", _, true) => RawDiffEntry::Submodule { - path: path.to_string(), + (GitChange::Modified | GitChange::TypeChanged, _, true) => RawDiffEntry::Submodule { + path, change_kind: SubmoduleChange::Modified, }, - ("M" | "T", _, _) => RawDiffEntry::Modified { - path: path.to_string(), - old_blob: src_sha.to_string(), - new_blob: dst_sha.to_string(), - new_mode: dst_mode.to_string(), + (GitChange::Modified | GitChange::TypeChanged, _, _) => RawDiffEntry::Modified { + path, + old_blob, + new_blob, + new_mode, }, (other, _, _) => { - return Err(format!("unknown raw-diff status {other:?} for {path:?}")); + return Err(format!("unknown diff status {other:?} for {path:?}")); } }) } pub use fabro_types::{DiffStats, DiffSummary}; -/// Output of `git diff --numstat`: which paths are binary, plus per-path -/// `+/-` line totals for text files in the range. Both pieces come from a -/// single git invocation so callers don't need to run two diffs. +/// What `git diff --numstat` says about a range: which paths are binary, +/// plus per-path `+/-` line totals for text files. #[derive(Debug, Default)] pub struct DiffNumstat { /// Repo-relative paths (post-rename) that git classifies as binary. @@ -550,96 +436,41 @@ pub fn summarize_diff_numstat(numstat: &DiffNumstat) -> DiffSummary { } } -/// Run `git diff --numstat` once and return both the set of binary paths and -/// text-file `+/-` totals. The single call replaces the previous binary-only -/// helper. +/// The numstat of `base_sha..to_sha`: the set of binary paths and the +/// text-file `+/-` totals, from one driver call. pub async fn list_diff_numstat( sandbox: &RunSandbox, base_sha: &str, to_sha: &str, ) -> std::result::Result { - let base_q = shell_quote(base_sha); - let to_q = shell_quote(to_sha); - let env = sandbox_git_hardening_env(); - let cmd = format!("{GIT_HARDENED} diff --numstat --find-renames=50% {base_q}..{to_q}"); - let res = sandbox - .exec_command(&cmd, 10_000, None, Some(&env), None) + let git = diff_facet(sandbox)?; + let options = GitDiffOptions::new(GitRevisionRange::new(base_sha).to(to_sha)) + .find_renames(FIND_RENAMES_PERCENT) + .timeout(RUN_FILES_TIMEOUT); + let rows = git + .diff_numstat(sandbox.working_directory(), &options) .await - .map_err(|e| DiffError::Transient { - message: e.display_with_causes(), - })?; - - if res.is_timed_out() { - return Err(DiffError::Transient { - message: "git diff --numstat timed out".to_string(), - }); - } - if !res.is_success() { - let stderr = res.stderr.trim().to_string(); - if is_permanent_git_error(&stderr) { - return Err(DiffError::Permanent { message: stderr }); - } - return Err(DiffError::Transient { message: stderr }); - } + .map_err(|error| diff_error(&error))?; let mut out = DiffNumstat::default(); - for line in res.stdout.lines() { - // `-\t-\t` marks binary. Rename lines read `<+>\t<->\t => - // ` or `<+>\t<->\t{ => }`. - if let Some(rest) = line.strip_prefix("-\t-\t") { - out.binary_paths.insert(extract_new_path_from_numstat(rest)); - continue; + for row in rows { + match (row.additions, row.deletions) { + (Some(additions), Some(deletions)) => { + out.line_stats_by_path.insert(row.path, DiffStats { + additions: i64::try_from(additions).unwrap_or(i64::MAX), + deletions: i64::try_from(deletions).unwrap_or(i64::MAX), + }); + } + _ => { + out.binary_paths.insert(row.path); + } } - // Text rows: `\t\t`. Tolerate malformed lines - // (e.g. trailing whitespace) by skipping rather than failing the - // whole diff — the rest of the response stays usable. - let mut parts = line.splitn(3, '\t'); - let adds_s = parts.next().unwrap_or(""); - let dels_s = parts.next().unwrap_or(""); - let Some(path_s) = parts.next() else { - continue; - }; - let Ok(adds) = adds_s.parse::() else { - continue; - }; - let Ok(dels) = dels_s.parse::() else { - continue; - }; - let path = extract_new_path_from_numstat(path_s); - out.line_stats_by_path.insert(path, DiffStats { - additions: adds, - deletions: dels, - }); } Ok(out) } -fn extract_new_path_from_numstat(rest: &str) -> String { - // Forms seen: - // "simple/path" - // "old => new" - // "prefix/{old => new}/suffix" - if let Some(open_idx) = rest.find('{') { - if let Some(close_idx) = rest[open_idx..].find('}') { - let before = &rest[..open_idx]; - let after = &rest[open_idx + close_idx + 1..]; - let inside = &rest[open_idx + 1..open_idx + close_idx]; - if let Some((_, new)) = inside.split_once(" => ") { - return format!("{before}{new}{after}"); - } - } - } - if let Some((_, new)) = rest.split_once(" => ") { - return new.to_string(); - } - rest.to_string() -} - -/// Fetch blob metadata (size) for many SHAs in one sandbox invocation via -/// `git cat-file --batch-check`. -/// -/// The order of returned `BlobMeta` entries matches the input `shas` order. -/// SHAs reported as `missing` by git yield `BlobMeta { size: None, .. }`. +/// Blob sizes for many SHAs in one driver call, in the order of `shas`. +/// A blob git does not have yields `BlobMeta { size: None, .. }`. pub async fn stream_blob_metadata( sandbox: &RunSandbox, shas: &[String], @@ -647,65 +478,27 @@ pub async fn stream_blob_metadata( if shas.is_empty() { return Ok(Vec::new()); } - let env = sandbox_git_hardening_env(); - let quoted_shas: Vec = shas.iter().map(|s| shell_quote(s)).collect(); - let cmd = format!( - "printf '%s\\n' {} | {GIT_HARDENED} cat-file --batch-check", - quoted_shas.join(" ") - ); - let res = sandbox - .exec_command(&cmd, 10_000, None, Some(&env), None) + let git = diff_facet(sandbox)?; + let sizes = git + .blob_sizes(sandbox.working_directory(), shas) .await - .map_err(|e| DiffError::Transient { - message: e.display_with_causes(), - })?; - - if res.is_timed_out() { - return Err(DiffError::Transient { - message: "git cat-file --batch-check timed out".to_string(), - }); - } - if !res.is_success() { - return Err(DiffError::Transient { - message: format!("git cat-file --batch-check failed: {}", res.stderr.trim()), - }); - } - - let mut metas = Vec::with_capacity(shas.len()); - for line in res.stdout.lines() { - // Lines: " " OR " missing" - let mut parts = line.split(' '); - let sha = parts - .next() - .ok_or_else(|| DiffError::Permanent { - message: format!("empty cat-file line: {line:?}"), - })? - .to_string(); - let second = parts.next().unwrap_or(""); - if second == "missing" { - metas.push(BlobMeta { sha, size: None }); - continue; - } - let size_str = parts.next().unwrap_or(""); - let size = size_str.parse::().map_err(|e| DiffError::Permanent { - message: format!("unparseable size {size_str:?} for {sha}: {e}"), - })?; - metas.push(BlobMeta { - sha, - size: Some(size), - }); - } - Ok(metas) + .map_err(|error| diff_error(&error))?; + Ok(shas + .iter() + .zip(sizes) + .map(|(sha, size)| BlobMeta { + sha: sha.clone(), + size, + }) + .collect()) } -/// Fetch blob contents for many SHAs in one sandbox invocation via -/// `git cat-file --batch`. +/// Blob contents for many SHAs in one driver call, in the order of `shas`. /// /// Contents are size-capped per blob: any blob exceeding `size_cap_bytes` -/// returns `None` in its slot (the caller should flag that entry as -/// truncated). Callers are expected to have pre-filtered binary blobs via -/// [`list_diff_numstat`] — `--batch` output stream is text-oriented and -/// non-UTF-8 bytes are lossy through the sandbox `String` channel. +/// returns `None` in its slot (the caller flags that entry as truncated), +/// as does a blob git does not have or one that is not UTF-8. Callers are +/// expected to have pre-filtered binary blobs via [`list_diff_numstat`]. pub async fn stream_blobs( sandbox: &RunSandbox, shas: &[String], @@ -714,94 +507,15 @@ pub async fn stream_blobs( if shas.is_empty() { return Ok(Vec::new()); } - let env = sandbox_git_hardening_env(); - let quoted_shas: Vec = shas.iter().map(|s| shell_quote(s)).collect(); - let cmd = format!( - "printf '%s\\n' {} | {GIT_HARDENED} cat-file --batch", - quoted_shas.join(" ") - ); - let res = sandbox - .exec_command(&cmd, 10_000, None, Some(&env), None) + let git = diff_facet(sandbox)?; + let blobs = git + .blobs(sandbox.working_directory(), shas, size_cap_bytes) .await - .map_err(|e| DiffError::Transient { - message: e.display_with_causes(), - })?; - - if res.is_timed_out() { - return Err(DiffError::Transient { - message: "git cat-file --batch timed out".to_string(), - }); - } - if !res.is_success() { - return Err(DiffError::Transient { - message: format!("git cat-file --batch failed: {}", res.stderr.trim()), - }); - } - - parse_batch_output(&res.stdout, shas, size_cap_bytes) - .map_err(|message| DiffError::Permanent { message }) -} - -fn parse_batch_output( - stdout: &str, - shas: &[String], - size_cap_bytes: u64, -) -> std::result::Result>, String> { - // `git cat-file --batch` output per blob: - // " \n\n" - // `missing` blob: " missing\n" (no content). - let mut results: Vec> = Vec::with_capacity(shas.len()); - let bytes = stdout.as_bytes(); - let mut pos = 0; - - while pos < bytes.len() { - // Find end of header line. - let Some(nl_rel) = bytes[pos..].iter().position(|&b| b == b'\n') else { - break; - }; - let header = std::str::from_utf8(&bytes[pos..pos + nl_rel]) - .map_err(|e| format!("non-utf8 header in cat-file output: {e}"))?; - pos += nl_rel + 1; - - let mut parts = header.split(' '); - let _sha = parts.next().unwrap_or(""); - let second = parts.next().unwrap_or(""); - if second == "missing" { - results.push(None); - continue; - } - let size_str = parts.next().unwrap_or(""); - let size: usize = size_str - .parse() - .map_err(|e| format!("unparseable size {size_str:?}: {e}"))?; - - let end = pos + size; - if end > bytes.len() { - return Err(format!( - "cat-file stream truncated: expected {size} bytes, have {}", - bytes.len() - pos - )); - } - if (size as u64) > size_cap_bytes { - results.push(None); - } else { - let content = std::str::from_utf8(&bytes[pos..end]) - .map_err(|e| format!("non-utf8 blob contents: {e}"))?; - results.push(Some(content.to_string())); - } - pos = end; - // Trailing newline that delimits the next entry. - if pos < bytes.len() && bytes[pos] == b'\n' { - pos += 1; - } - } - - // Pad with None if the stream didn't cover every requested SHA (e.g. - // duplicate-sha deduping by git). - while results.len() < shas.len() { - results.push(None); - } - Ok(results) + .map_err(|error| diff_error(&error))?; + Ok(blobs + .into_iter() + .map(|blob| blob.and_then(|bytes| String::from_utf8(bytes).ok())) + .collect()) } #[cfg(test)] @@ -811,9 +525,8 @@ mod tests { reason = "These unit tests use the real git CLI to construct sandbox-git fixture repositories and sync-write fixtures to disk." )] - use fabro_sandbox::sandbox::ExecResult; - use fabro_sandbox::test_support::MockSandbox; - use fabro_types::CommandTermination; + use fabro_sandbox::test_support::{MockSandbox, exec_result}; + use fabro_sandbox::{ExecResult, Termination}; use super::*; @@ -821,45 +534,21 @@ mod tests { fn scripted(exec_results: &[ExecResult]) -> MockSandbox { let sandbox = MockSandbox::default(); for result in exec_results { - sandbox.push_exec_result(result); + sandbox.driver().scripted_exec().push_result(result.clone()); } sandbox } fn exec_ok() -> ExecResult { - ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: Some(0), - termination: CommandTermination::Exited, - duration_ms: 1, - } + exec_result("", "", Some(0), Termination::Exited, 1) } fn exec_timed_out(duration_ms: u64) -> ExecResult { - ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: None, - termination: CommandTermination::TimedOut, - duration_ms, - } + exec_result("", "", None, Termination::TimedOut, duration_ms) } fn exec_failed(exit_code: i32, stdout: &str, stderr: &str) -> ExecResult { - ExecResult { - stdout: stdout.to_string(), - stderr: stderr.to_string(), - exit_code: Some(exit_code), - termination: CommandTermination::Exited, - duration_ms: 1, - } - } - - #[test] - fn git_remote_disables_commit_and_tag_signing() { - assert!(GIT_REMOTE.contains("-c commit.gpgsign=false")); - assert!(GIT_REMOTE.contains("-c tag.gpgsign=false")); + exec_result(stdout, stderr, Some(exit_code), Termination::Exited, 1) } #[tokio::test] @@ -878,7 +567,13 @@ mod tests { .await .unwrap_err(); - assert_eq!(err.to_string(), "git add timed out after 77ms"); + assert_eq!(err.to_string(), "git add failed"); + let timed_out = matches!( + err.source.driver(), + Some(sandbox_driver::Error::Git(failure)) + if failure.output().is_some_and(|output| output.termination() == Termination::TimedOut) + ); + assert!(timed_out, "{}", fabro_sandbox::display_for_log(&err)); assert!( fabro_sandbox::default_redacted_output_tail(&err).is_none(), "empty exec streams should not produce a tail" @@ -934,11 +629,12 @@ mod tests { .await .unwrap_err(); - assert_eq!(err.to_string(), "git commit timed out after 88ms"); + assert_eq!(err.to_string(), "git commit failed"); } #[tokio::test] - async fn git_checkpoint_reports_rev_parse_killed_without_output() { + async fn git_checkpoint_reports_a_failed_sha_read_as_the_commit_failing() { + // add, commit, then the driver's own rev-parse of the new HEAD. let sandbox = scripted(&[exec_ok(), exec_ok(), exec_failed(-1, "", "")]); let err = git_checkpoint( &sandbox.sandbox(), @@ -953,103 +649,66 @@ mod tests { .await .unwrap_err(); - assert_eq!(err.to_string(), "git rev-parse HEAD failed (exit -1)"); + assert_eq!(err.to_string(), "git commit failed"); } + /// The commit message and author travel in the driver's own commit + /// command, and repository hooks never run: the driver disables them + /// whatever the checkpoint settings say. #[tokio::test] - async fn git_checkpoint_uses_unique_commit_message_paths_for_same_run_and_node() { - let sandbox = scripted(&[ - exec_ok(), - exec_ok(), - exec_ok(), - exec_ok(), - exec_ok(), - exec_ok(), - ]); - let author = crate::git::GitAuthor::default(); - - let first = git_checkpoint( - &sandbox.sandbox(), - "run1", - "work", - "success", - 1, - None, - &RunCheckpointSettings::default(), - &author, - ) - .await; - let second = git_checkpoint( - &sandbox.sandbox(), - "run1", - "work", - "success", - 1, - None, - &RunCheckpointSettings::default(), - &author, - ) - .await; - - assert!(first.is_ok(), "first checkpoint failed: {:?}", first.err()); - assert!( - second.is_ok(), - "second checkpoint failed: {:?}", - second.err() - ); - - let write_paths: Vec = sandbox - .written_files() - .into_iter() - .map(|(path, _)| path) - .collect(); - assert_eq!(write_paths.len(), 2); - assert!( - write_paths - .iter() - .all(|path| path.starts_with("/tmp/fabro-commit-msg-")), - "unexpected commit message paths: {write_paths:?}" - ); - assert_ne!(write_paths[0], write_paths[1]); - - let delete_paths = sandbox.deleted_files(); - assert_eq!(delete_paths, write_paths); - - let commands = sandbox.captured_commands(); - let commit_commands = commands - .iter() - .filter(|command| command.contains(" commit ")) - .collect::>(); - assert_eq!(commit_commands.len(), 2); - for (command, path) in commit_commands.iter().zip(write_paths.iter()) { - assert!( - command.contains(&format!("-F {}", shell_quote(path))), - "expected commit command to use {path:?}, got {command:?}" - ); - } - } - - #[tokio::test] - async fn git_checkpoint_uses_configured_timeout_for_add_and_commit() { - let sandbox = scripted(&[exec_ok(), exec_ok(), exec_ok()]); + async fn git_checkpoint_commits_through_the_hardened_driver_command() { + let mut sha = exec_ok(); + sha.stdout = b"abc123\n".to_vec(); + let sandbox = scripted(&[exec_ok(), exec_ok(), sha]); let checkpoint = RunCheckpointSettings { - commit_timeout_ms: 600_000, + skip_git_hooks: false, ..RunCheckpointSettings::default() }; - git_checkpoint( + let author = crate::git::GitAuthor::default(); + + let sha = git_checkpoint( &sandbox.sandbox(), "run1", "work", "success", 1, - None, + Some("feedface".to_owned()), &checkpoint, - &crate::git::GitAuthor::default(), + &author, ) .await - .expect("checkpoint should succeed"); + .expect("checkpoint succeeds"); + assert_eq!(sha, "abc123"); - assert_eq!(sandbox.captured_timeouts(), vec![600_000, 600_000, 10_000]); + let commands = sandbox.driver().scripted_exec().commands(); + let add = commands + .iter() + .find(|command| command.contains("'add' '-A'")) + .expect("the add ran"); + assert!( + add.contains(":(glob,exclude)**/node_modules/**"), + "built-in excludes are pathspecs: {add}" + ); + let commit = commands + .iter() + .find(|command| command.contains("'commit'")) + .expect("the commit ran"); + assert!(commit.contains("core.hooksPath=/dev/null"), "{commit}"); + assert!(commit.contains("commit.gpgsign=false"), "{commit}"); + assert!(commit.contains("'--allow-empty'"), "{commit}"); + assert!( + commit.contains("fabro(run1): work (success)") + && commit.contains("Fabro-Checkpoint: feedface"), + "{commit}" + ); + assert!( + commit.contains(&format!("user.name={}", author.name)), + "{commit}" + ); + assert!( + sandbox.written_files().is_empty(), + "no message file is written" + ); } #[tokio::test] @@ -1059,7 +718,13 @@ mod tests { .await .unwrap_err(); - assert_eq!(err.to_string(), "git diff timed out after 99ms"); + assert_eq!(err.to_string(), "git diff failed"); + let timed_out = matches!( + err.source.driver(), + Some(sandbox_driver::Error::Git(failure)) + if failure.output().is_some_and(|output| output.termination() == Termination::TimedOut) + ); + assert!(timed_out, "{}", fabro_sandbox::display_for_log(&err)); } #[tokio::test] @@ -1069,7 +734,7 @@ mod tests { .await .unwrap_err(); - assert_eq!(err.to_string(), "git diff failed (exit 128)"); + assert_eq!(err.to_string(), "git diff failed"); assert!(!err.to_string().contains("fatal: bad revision")); let tail = fabro_sandbox::default_redacted_output_tail(&err).expect("tail present"); @@ -1077,62 +742,21 @@ mod tests { } #[tokio::test] - async fn git_checkpoint_appends_no_verify_when_skip_hooks_enabled() { - // add, commit, rev-parse - let sandbox = scripted(&[exec_ok(), exec_ok(), exec_ok()]); - let checkpoint = RunCheckpointSettings { - skip_git_hooks: true, - ..RunCheckpointSettings::default() - }; - git_checkpoint( - &sandbox.sandbox(), - "run1", - "work", - "success", - 1, - None, - &checkpoint, - &crate::git::GitAuthor::default(), - ) - .await - .expect("checkpoint should succeed"); - - let commands = sandbox.captured_commands(); - let commit_cmd = commands - .iter() - .find(|c| c.contains(" commit ")) - .expect("commit command should be issued"); + async fn git_diff_passes_the_range_and_timeout_to_the_driver() { + let mut patch = exec_ok(); + patch.stdout = b"diff --git a/x b/x\n".to_vec(); + let sandbox = scripted(&[patch]); + let diff = git_diff_with_timeout(&sandbox.sandbox(), "base-sha", 5_000) + .await + .expect("diff succeeds"); + assert_eq!(diff, "diff --git a/x b/x\n"); + let commands = sandbox.driver().scripted_exec().commands(); assert!( - commit_cmd.contains("--no-verify"), - "commit command should include --no-verify when skip_git_hooks=true; got {commit_cmd:?}" - ); - } - - #[tokio::test] - async fn git_checkpoint_omits_no_verify_when_skip_hooks_disabled() { - let sandbox = scripted(&[exec_ok(), exec_ok(), exec_ok()]); - git_checkpoint( - &sandbox.sandbox(), - "run1", - "work", - "success", - 1, - None, - &RunCheckpointSettings::default(), - &crate::git::GitAuthor::default(), - ) - .await - .expect("checkpoint should succeed"); - - let commands = sandbox.captured_commands(); - let commit_cmd = commands - .iter() - .find(|c| c.contains(" commit ")) - .expect("commit command should be issued"); - assert!( - !commit_cmd.contains("--no-verify"), - "commit command should omit --no-verify when skip_git_hooks=false; got {commit_cmd:?}" + commands[0].contains("'diff'") && commands[0].contains("'base-sha..HEAD'"), + "{}", + commands[0] ); + assert_eq!(sandbox.captured_timeouts(), vec![5_000]); } #[tokio::test] @@ -1198,7 +822,8 @@ mod tests { ) .await .unwrap(); - let staged_files: Vec<&str> = status.stdout.lines().collect(); + let status_stdout = status.stdout_lossy(); + let staged_files: Vec<&str> = status_stdout.lines().collect(); assert!( staged_files.contains(&"hello.txt"), "expected hello.txt to be staged, got: {staged_files:?}" @@ -1468,17 +1093,4 @@ mod tests { .expect_err("expected error for unknown base sha"); assert!(matches!(err, DiffError::Permanent { .. }), "err: {err:?}"); } - - #[test] - fn extract_new_path_from_numstat_handles_brace_renames() { - assert_eq!(extract_new_path_from_numstat("simple/path"), "simple/path"); - assert_eq!( - extract_new_path_from_numstat("old.txt => new.txt"), - "new.txt" - ); - assert_eq!( - extract_new_path_from_numstat("src/{old => new}/file.rs"), - "src/new/file.rs" - ); - } } diff --git a/lib/components/fabro-workflow/src/sandbox_git_runtime.rs b/lib/components/fabro-workflow/src/sandbox_git_runtime.rs index c273e18d7..0877e5373 100644 --- a/lib/components/fabro-workflow/src/sandbox_git_runtime.rs +++ b/lib/components/fabro-workflow/src/sandbox_git_runtime.rs @@ -1,8 +1,9 @@ -use fabro_sandbox::{RunSandbox, shell_quote}; +use fabro_sandbox::{ExecResult, ExecResultExt, RunSandbox, Termination}; use fabro_util::error::SharedError; +use fabro_util::shell; use tokio::sync::OnceCell; -use crate::sandbox_git::{GIT_REMOTE, exec_err}; +use crate::sandbox_git::GitCommandError; pub(crate) struct SandboxGitRuntime { probe: OnceCell>, @@ -65,10 +66,10 @@ async fn probe_sandbox_git(sandbox: &RunSandbox) -> Result<(), SharedError> { GIT_INDEX_FILE={index_q} {git} update-index --add --cacheinfo 100644,$blob,probe.txt\n\ GIT_INDEX_FILE={index_q} {git} write-tree >/dev/null\n\ rm -rf {temp_q}", - temp_q = shell_quote(&temp), - probe_file_q = shell_quote(&probe_file), - index_q = shell_quote(&index), - git = GIT_REMOTE, + temp_q = shell::shell_quote(&temp), + probe_file_q = shell::shell_quote(&probe_file), + index_q = shell::shell_quote(&index), + git = "git -c maintenance.auto=0 -c gc.auto=0", ); exec_ok(sandbox, &command).await } @@ -86,7 +87,7 @@ async fn exec_ok(sandbox: &RunSandbox, command: &str) -> Result<(), SharedError> .map_err(|err| { SharedError::new(anyhow::Error::new(err).context("sandbox git probe command failed")) })?; - if result.is_success() { + if result.success() { Ok(()) } else { Err(SharedError::new(anyhow::Error::new(exec_err( @@ -94,3 +95,23 @@ async fn exec_ok(sandbox: &RunSandbox, command: &str) -> Result<(), SharedError> )))) } } + +/// The probe's failure, named by how the command ended; the output tail +/// travels in the source. +fn exec_err(label: &str, result: ExecResult) -> GitCommandError { + let duration_ms = result.duration_ms(); + let message = match result.termination { + Termination::TimedOut => format!("{label} timed out after {duration_ms}ms"), + Termination::Cancelled | Termination::Killed => { + format!("{label} cancelled after {duration_ms}ms") + } + _ => format!( + "{label} failed (exit {})", + result.program_exit_code().unwrap_or(-1) + ), + }; + GitCommandError { + message, + source: result.into_exec_error(label), + } +} diff --git a/lib/components/fabro-workflow/tests/it/cp_integration.rs b/lib/components/fabro-workflow/tests/it/cp_integration.rs index f78200a21..6cdb19b43 100644 --- a/lib/components/fabro-workflow/tests/it/cp_integration.rs +++ b/lib/components/fabro-workflow/tests/it/cp_integration.rs @@ -14,9 +14,11 @@ reason = "This integration test stages sandbox fixtures with sync std::fs." )] -use fabro_sandbox::reconnect::reconnect; -use fabro_sandbox::{ProviderAccess, SandboxOptions, provider_sandbox}; +use fabro_sandbox::reconnect::reconnect_for_run; +use fabro_sandbox::test_support::local_sandbox_id; +use fabro_sandbox::{CloneRequest, ProviderAccess, provider_sandbox}; use fabro_types::{RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind}; +use sandbox_driver::{SandboxSource, SandboxSpec}; const DOCKER_CP_IMAGE: &str = "buildpack-deps:noble"; @@ -24,13 +26,13 @@ const DOCKER_CP_IMAGE: &str = "buildpack-deps:noble"; // Local sandbox // --------------------------------------------------------------------------- -fn local_record(working_directory: &std::path::Path) -> RunSandboxInstance { +async fn local_record(working_directory: &std::path::Path) -> RunSandboxInstance { RunSandboxInstance { provider: SandboxProviderKind::LOCAL, image: None, snapshot: None, runtime: RunSandboxRuntime { - id: "local:test".to_string(), + id: local_sandbox_id(working_directory).await, working_directory: working_directory.to_string_lossy().to_string(), repo_cloned: None, clone_origin_url: None, @@ -48,8 +50,8 @@ async fn local_cp_upload_download_round_trip() { let sandbox_dir = tempfile::tempdir().unwrap(); let scratch = tempfile::tempdir().unwrap(); - let record = local_record(sandbox_dir.path()); - let sandbox = reconnect(&record, &ProviderAccess::default()) + let record = local_record(sandbox_dir.path()).await; + let sandbox = reconnect_for_run(&record, &ProviderAccess::default(), None, None) .await .expect("reconnect local"); @@ -81,8 +83,8 @@ async fn local_cp_binary_round_trip() { let sandbox_dir = tempfile::tempdir().unwrap(); let scratch = tempfile::tempdir().unwrap(); - let record = local_record(sandbox_dir.path()); - let sandbox = reconnect(&record, &ProviderAccess::default()) + let record = local_record(sandbox_dir.path()).await; + let sandbox = reconnect_for_run(&record, &ProviderAccess::default(), None, None) .await .expect("reconnect local"); @@ -110,8 +112,8 @@ async fn local_cp_creates_parent_dirs() { let sandbox_dir = tempfile::tempdir().unwrap(); let scratch = tempfile::tempdir().unwrap(); - let record = local_record(sandbox_dir.path()); - let sandbox = reconnect(&record, &ProviderAccess::default()) + let record = local_record(sandbox_dir.path()).await; + let sandbox = reconnect_for_run(&record, &ProviderAccess::default(), None, None) .await .expect("reconnect local"); @@ -187,15 +189,10 @@ async fn docker_cp_container() -> DockerCpContainer { let sandbox = provider_sandbox( SandboxProviderKind::DOCKER, &ProviderAccess::default(), - SandboxOptions { - image: Some(DOCKER_CP_IMAGE.to_string()), - skip_clone: true, - ..SandboxOptions::default() - }, - None, - None, - None, - None, + SandboxSpec::new(SandboxSource::Image { + reference: DOCKER_CP_IMAGE.to_string(), + }), + &CloneRequest::none(), None, None, ) @@ -241,7 +238,7 @@ async fn docker_cp_upload_download_round_trip() { let scratch = tempfile::tempdir().unwrap(); let record = docker_record(&container.id); - let sandbox = reconnect(&record, &ProviderAccess::default()) + let sandbox = reconnect_for_run(&record, &ProviderAccess::default(), None, None) .await .expect("reconnect docker"); @@ -272,7 +269,7 @@ async fn docker_cp_binary_round_trip() { let scratch = tempfile::tempdir().unwrap(); let record = docker_record(&container.id); - let sandbox = reconnect(&record, &ProviderAccess::default()) + let sandbox = reconnect_for_run(&record, &ProviderAccess::default(), None, None) .await .expect("reconnect docker"); @@ -301,7 +298,7 @@ async fn docker_cp_creates_parent_dirs() { let scratch = tempfile::tempdir().unwrap(); let record = docker_record(&container.id); - let sandbox = reconnect(&record, &ProviderAccess::default()) + let sandbox = reconnect_for_run(&record, &ProviderAccess::default(), None, None) .await .expect("reconnect docker"); diff --git a/lib/components/fabro-workflow/tests/it/daytona_integration.rs b/lib/components/fabro-workflow/tests/it/daytona_integration.rs index 50c9a48d9..ed5cec4e6 100644 --- a/lib/components/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/components/fabro-workflow/tests/it/daytona_integration.rs @@ -24,7 +24,7 @@ use std::sync::Arc; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_sandbox::{ - DaytonaCredentials, ProviderAccess, RunSandbox, SandboxOptions, SandboxProviderKind, + CloneRequest, DaytonaCredentials, ProviderAccess, RunSandbox, SandboxProviderKind, provider_sandbox, }; use fabro_static::EnvVars; @@ -43,6 +43,7 @@ use fabro_workflow::run_options::{GitCheckpointOptions, RunOptions}; use fabro_workflow::runtime_store::RunStoreHandle; use fabro_workflow::test_support::{WorkflowRunner, test_store_dir}; use object_store::local::LocalFileSystem; +use sandbox_driver::{LifecycleTimers, Resources, SandboxSource, SandboxSpec}; use tokio_util::sync::CancellationToken; use ulid::Ulid; @@ -192,16 +193,8 @@ fn daytona_access(credentials: DaytonaCredentials) -> ProviderAccess { } fn live_daytona_credentials() -> DaytonaCredentials { - DaytonaCredentials { - api_key: std::env::var(EnvVars::DAYTONA_API_KEY) - .expect("DAYTONA_API_KEY must be set"), - api_url: std::env::var(EnvVars::DAYTONA_API_URL) - .or_else(|_| std::env::var(EnvVars::DAYTONA_SERVER_URL)) - .ok(), - organization_id: std::env::var(EnvVars::DAYTONA_ORGANIZATION_ID).ok(), - target: None, - http_client: None, - } + let api_key = std::env::var(EnvVars::DAYTONA_API_KEY).expect("DAYTONA_API_KEY must be set"); + DaytonaCredentials::from_api_key(api_key, |name| std::env::var(name).ok()) } async fn create_env() -> RunSandbox { @@ -223,13 +216,10 @@ async fn create_env_with_github_app( provider_sandbox( SandboxProviderKind::DAYTONA, &daytona_access(live_daytona_credentials()), - SandboxOptions::default(), + SandboxSpec::new(SandboxSource::HostDirectory), + &CloneRequest::default(), github_app.as_ref(), None, - None, - None, - None, - None, ) .await .expect("Failed to create Daytona client — is DAYTONA_API_KEY set?") @@ -285,9 +275,9 @@ async fn daytona_exec_command() { .await .unwrap(); assert_eq!(result.exit_code, Some(0)); - assert!(result.stdout.contains("hello")); + assert!(result.stdout_lossy().contains("hello")); - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] @@ -301,9 +291,9 @@ async fn daytona_exec_command_with_pipe() { .await .unwrap(); assert_eq!(result.exit_code, Some(0)); - assert!(result.stdout.trim().contains('2')); + assert!(result.stdout_lossy().trim().contains('2')); - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] @@ -328,10 +318,13 @@ async fn daytona_exec_command_cancelled() { .unwrap(); assert_eq!(result.exit_code, None); - assert!(result.is_cancelled()); - assert_eq!(result.stderr, "Command cancelled"); + assert!(matches!( + result.termination, + fabro_sandbox::Termination::Cancelled | fabro_sandbox::Termination::Killed + )); + assert_eq!(result.stderr_lossy(), "Command cancelled"); - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] @@ -361,10 +354,10 @@ async fn daytona_exec_command_local_timeout() { "Command stalled for longer than the local timeout mechanism" ); assert_eq!(result.exit_code, None); - assert!(result.is_timed_out()); - assert_eq!(result.stderr, "Command timed out locally"); + assert_eq!(result.termination, fabro_sandbox::Termination::TimedOut); + assert_eq!(result.stderr_lossy(), "Command timed out locally"); - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] @@ -389,7 +382,7 @@ async fn daytona_file_round_trip() { env.delete_file(test_path).await.unwrap(); assert!(!env.file_exists(test_path).await.unwrap()); - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] @@ -414,33 +407,31 @@ async fn daytona_full_lifecycle() { assert!(!entries.is_empty()); // Cleanup (deletes sandbox) - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] async fn daytona_snapshot_sandbox() { - let options = SandboxOptions { - auto_stop: Some(std::time::Duration::from_hours(1)), - dockerfile: Some( - "FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y ripgrep".to_string(), - ), - cpu: Some(2), - memory_bytes: Some(4_000_000_000), - disk_bytes: Some(10_000_000_000), - ..SandboxOptions::default() - }; + let mut resources = Resources::default(); + resources.cpu_cores = Some(2); + resources.memory_mb = Some(4096); + resources.disk_mb = Some(10_240); + let mut timers = LifecycleTimers::default(); + timers.auto_stop_after_idle = Some(std::time::Duration::from_hours(1)); + let spec = SandboxSpec::new(SandboxSource::Dockerfile { + content: "FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y ripgrep".to_string(), + }) + .resources(resources) + .timers(timers); let creds = load_github_app_credentials(); let env = provider_sandbox( SandboxProviderKind::DAYTONA, &daytona_access(live_daytona_credentials()), - options, + spec, + &CloneRequest::default(), Some(&creds), None, - None, - None, - None, - None, ) .await .expect("Failed to create Daytona client — is DAYTONA_API_KEY set?"); @@ -452,9 +443,9 @@ async fn daytona_snapshot_sandbox() { .await .unwrap(); assert_eq!(result.exit_code, Some(0)); - assert!(result.stdout.contains("ripgrep")); + assert!(result.stdout_lossy().contains("ripgrep")); - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] @@ -506,7 +497,7 @@ async fn daytona_artifact_sync_uploads_and_rewrites_pointer() { remote_content.len() ); - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } // --------------------------------------------------------------------------- @@ -620,7 +611,7 @@ async fn daytona_pipeline_artifact_offload_and_sync() { "offloaded value should round-trip through the run store" ); - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } // --------------------------------------------------------------------------- @@ -667,9 +658,9 @@ async fn setup_daytona_git(sandbox: &RunSandbox) -> (RunId, String, String) { sha_result.exit_code, Some(0), "git rev-parse HEAD failed: {}", - sha_result.stderr + sha_result.stderr_lossy() ); - let base_sha = sha_result.stdout.trim().to_string(); + let base_sha = sha_result.stdout_lossy().trim().to_string(); let run_id = RunId::from(Ulid::new()); let branch_name = format!("fabro/run/{run_id}"); @@ -684,8 +675,8 @@ async fn setup_daytona_git(sandbox: &RunSandbox) -> (RunId, String, String) { Some(0), "git checkout -b failed (exit {:?}): stdout={} stderr={}", checkout_result.exit_code, - checkout_result.stdout, - checkout_result.stderr + checkout_result.stdout_lossy(), + checkout_result.stderr_lossy() ); (run_id, base_sha, branch_name) @@ -701,7 +692,7 @@ async fn daytona_git_checkpoint_remote_emits_events() { let git_check = env .exec_command("git --version", 10_000, None, None, None) .await; - if git_check.as_ref().map_or(true, |r| !r.is_success()) { + if git_check.as_ref().map_or(true, |r| !r.success()) { let install = env .exec_command( "apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1", @@ -716,7 +707,7 @@ async fn daytona_git_checkpoint_remote_emits_events() { install.exit_code, Some(0), "git install failed: {}", - install.stderr + install.stderr_lossy() ); } @@ -831,7 +822,7 @@ async fn daytona_git_checkpoint_remote_emits_events() { "checkpoint should have git_commit_sha" ); - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } // --------------------------------------------------------------------------- @@ -851,7 +842,7 @@ async fn daytona_git_checkpoint_with_shadow_branch() { let git_check = env .exec_command("git --version", 10_000, None, None, None) .await; - if git_check.as_ref().map_or(true, |r| !r.is_success()) { + if git_check.as_ref().map_or(true, |r| !r.success()) { let install = env .exec_command( "apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1", @@ -866,7 +857,7 @@ async fn daytona_git_checkpoint_with_shadow_branch() { install.exit_code, Some(0), "git install failed: {}", - install.stderr + install.stderr_lossy() ); } @@ -947,9 +938,9 @@ async fn daytona_git_checkpoint_with_shadow_branch() { ) .await .expect("git show should succeed"); - assert_eq!(run_json.exit_code, Some(0), "{}", run_json.stderr); + assert_eq!(run_json.exit_code, Some(0), "{}", run_json.stderr_lossy()); let projection: fabro_store::RunProjection = - serde_json::from_slice(run_json.stdout.as_bytes()).expect("run.json should parse"); + serde_json::from_slice(run_json.stdout_lossy().as_bytes()).expect("run.json should parse"); let checkpoint = projection .current_checkpoint() .cloned() @@ -969,7 +960,7 @@ async fn daytona_git_checkpoint_with_shadow_branch() { .await .expect("git log should succeed"); assert_eq!(log_result.exit_code, Some(0)); - let commit_msg = log_result.stdout.trim().to_string(); + let commit_msg = log_result.stdout_lossy().trim().to_string(); assert!( commit_msg.contains("Fabro-Checkpoint:"), "sandbox commit should have Fabro-Checkpoint trailer, got:\n{commit_msg}" @@ -979,7 +970,7 @@ async fn daytona_git_checkpoint_with_shadow_branch() { "sandbox commit should have Fabro-Run trailer, got:\n{commit_msg}" ); - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } // --------------------------------------------------------------------------- @@ -1112,7 +1103,7 @@ async fn daytona_asset_collection() { "artifact scratch cache should not be created" ); - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] @@ -1131,7 +1122,7 @@ async fn daytona_ssh_access() { "ssh_command should contain 'ssh': {ssh_command}", ); - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] @@ -1173,7 +1164,7 @@ async fn daytona_clone_private_repo_with_github_app_iat() { "CLAUDE.md should exist after clone" ); assert!( - result.stdout.contains("EXISTS"), + result.stdout_lossy().contains("EXISTS"), "clone should have populated the workspace" ); @@ -1181,7 +1172,7 @@ async fn daytona_clone_private_repo_with_github_app_iat() { let git_check = env .exec_command("git --version", 10_000, None, None, None) .await; - if git_check.as_ref().map_or(true, |r| !r.is_success()) { + if git_check.as_ref().map_or(true, |r| !r.success()) { let install = env .exec_command( "apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1", @@ -1196,7 +1187,7 @@ async fn daytona_clone_private_repo_with_github_app_iat() { install.exit_code, Some(0), "git install failed: {}", - install.stderr + install.stderr_lossy() ); } @@ -1207,12 +1198,12 @@ async fn daytona_clone_private_repo_with_github_app_iat() { .unwrap(); assert_eq!(result.exit_code, Some(0)); assert!( - result.stdout.contains("fabro-sh/fabro"), + result.stdout_lossy().contains("fabro-sh/fabro"), "origin should point to fabro-sh/fabro, got: {}", - result.stdout.trim() + result.stdout_lossy().trim() ); - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } /// E2E: Verify that repos in an installed org get credentials (needed for @@ -1284,7 +1275,7 @@ async fn daytona_git_push_run_branch_to_origin() { let git_check = env .exec_command("git --version", 10_000, None, None, None) .await; - if git_check.as_ref().map_or(true, |r| !r.is_success()) { + if git_check.as_ref().map_or(true, |r| !r.success()) { let install = env .exec_command( "apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1", @@ -1299,7 +1290,7 @@ async fn daytona_git_push_run_branch_to_origin() { install.exit_code, Some(0), "git install failed: {}", - install.stderr + install.stderr_lossy() ); } @@ -1376,12 +1367,12 @@ async fn daytona_git_push_run_branch_to_origin() { ls_result.exit_code, Some(0), "git ls-remote failed: {}", - ls_result.stdout + ls_result.stdout_lossy() ); assert!( - ls_result.stdout.contains(&branch_name), + ls_result.stdout_lossy().contains(&branch_name), "run branch should exist on origin after push, got: {}", - ls_result.stdout.trim() + ls_result.stdout_lossy().trim() ); // Clean up the remote branch @@ -1390,15 +1381,15 @@ async fn daytona_git_push_run_branch_to_origin() { .exec_command(&delete_cmd, 30_000, None, None, None) .await; if let Ok(r) = &delete_result { - if !r.is_success() { + if !r.success() { eprintln!( "Warning: failed to delete remote branch {branch_name}: {}", - r.stdout + r.stdout_lossy() ); } } - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } /// Diagnose toolbox proxy staleness after idle time. @@ -1445,7 +1436,7 @@ async fn daytona_toolbox_idle_diagnostic() { eprintln!( "[t=+{sleep_secs}s] OK exit_code={:?} stdout={}", r.exit_code, - r.stdout.trim() + r.stdout_lossy().trim() ); } Err(e) => { @@ -1536,7 +1527,7 @@ async fn daytona_toolbox_idle_diagnostic() { } eprintln!("\n=== PASS: all idle durations survived ==="); - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } /// E2E test for `fabro cp` against a live Daytona sandbox. @@ -1545,7 +1536,7 @@ async fn daytona_toolbox_idle_diagnostic() { /// uploads a file, downloads it back, and verifies the round-trip. #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] async fn daytona_cp_upload_download_round_trip() { - use fabro_sandbox::reconnect::reconnect; + use fabro_sandbox::reconnect::reconnect_for_run; use fabro_types::RunSandboxInstance; // 1. Create and initialize a real Daytona sandbox @@ -1582,7 +1573,7 @@ async fn daytona_cp_upload_download_round_trip() { daytona: Some(live_daytona_credentials()), ..ProviderAccess::default() }; - let reconnected = reconnect(&record, &access) + let reconnected = reconnect_for_run(&record, &access, None, None) .await .expect("reconnect should succeed"); @@ -1640,23 +1631,16 @@ async fn daytona_cp_upload_download_round_trip() { ); // 9. Cleanup - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"))] async fn daytona_computer_use_browser_screenshot() { - let options = SandboxOptions { - skip_clone: true, - ..SandboxOptions::default() - }; let env = provider_sandbox( SandboxProviderKind::DAYTONA, &daytona_access(live_daytona_credentials()), - options, - None, - None, - None, - None, + SandboxSpec::new(SandboxSource::HostDirectory), + &CloneRequest::none(), None, None, ) @@ -1686,9 +1670,9 @@ async fn daytona_computer_use_browser_screenshot() { ) .await .unwrap(); - eprintln!("Browser check: {}", check.stdout.trim()); + eprintln!("Browser check: {}", check.stdout_lossy().trim()); - if check.stdout.trim() == "NONE" { + if check.stdout_lossy().trim() == "NONE" { let install_result = env .exec_command( "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq chromium 2>&1", @@ -1699,7 +1683,7 @@ async fn daytona_computer_use_browser_screenshot() { eprintln!( "Browser install exit_code={:?}, last_line={}", install_result.exit_code, - install_result.stdout.lines().last().unwrap_or("") + install_result.stdout_lossy().lines().last().unwrap_or("") ); assert_eq!(install_result.exit_code, Some(0), "Chromium install failed"); } @@ -1714,7 +1698,7 @@ async fn daytona_computer_use_browser_screenshot() { ) .await .unwrap(); - let browser = browser_bin.stdout.trim().to_string(); + let browser = browser_bin.stdout_lossy().trim().to_string(); eprintln!("Using browser: {browser}"); // 3. Detect the DISPLAY that computer use started @@ -1728,7 +1712,7 @@ async fn daytona_computer_use_browser_screenshot() { ) .await .unwrap(); - eprintln!("Xvfb process: {}", display_check.stdout.trim()); + eprintln!("Xvfb process: {}", display_check.stdout_lossy().trim()); // 4. Launch browser with setsid to fully detach, and log stderr let launch_cmd = format!( @@ -1756,7 +1740,7 @@ async fn daytona_computer_use_browser_screenshot() { ) .await .unwrap(); - eprintln!("Chrome processes:\n{}", ps_check.stdout); + eprintln!("Chrome processes:\n{}", ps_check.stdout_lossy()); let stderr_check = env .exec_command( @@ -1768,7 +1752,7 @@ async fn daytona_computer_use_browser_screenshot() { ) .await .unwrap(); - eprintln!("Chrome stderr:\n{}", stderr_check.stdout); + eprintln!("Chrome stderr:\n{}", stderr_check.stdout_lossy()); // 5. The desktop is serving: noVNC listens on its port. let listening = env @@ -1782,29 +1766,22 @@ async fn daytona_computer_use_browser_screenshot() { .await .unwrap(); assert!( - listening.is_success(), + listening.success(), "noVNC should be reachable inside the sandbox" ); // 7. Cleanup - env.cleanup().await.unwrap(); + env.delete().await.unwrap(); } #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"))] async fn daytona_playwright_mcp_sandbox_transport() { // Create sandbox from daytona-medium (has Node.js + Chromium) - let options = SandboxOptions { - skip_clone: true, - ..SandboxOptions::default() - }; let sandbox = provider_sandbox( SandboxProviderKind::DAYTONA, &daytona_access(live_daytona_credentials()), - options, - None, - None, - None, - None, + SandboxSpec::new(SandboxSource::HostDirectory), + &CloneRequest::none(), None, None, ) @@ -1828,7 +1805,7 @@ async fn daytona_playwright_mcp_sandbox_transport() { "Install exit_code={:?}, last_lines:\n{}", install.exit_code, install - .stdout + .stdout_lossy() .lines() .rev() .take(5) @@ -1965,5 +1942,5 @@ async fn daytona_playwright_mcp_sandbox_transport() { .expect("the agent shuts down"); // 8. Cleanup - sandbox.cleanup().await.unwrap(); + sandbox.delete().await.unwrap(); } diff --git a/lib/components/fabro-workflow/tests/it/integration.rs b/lib/components/fabro-workflow/tests/it/integration.rs index ad06a399d..0109a8edc 100644 --- a/lib/components/fabro-workflow/tests/it/integration.rs +++ b/lib/components/fabro-workflow/tests/it/integration.rs @@ -13625,19 +13625,12 @@ async fn asset_collection_local_sandbox_on_failure() { async fn asset_collection_docker_sandbox() { let run_dir = tempfile::tempdir().unwrap(); - let options = fabro_sandbox::SandboxOptions { - skip_clone: true, - ..Default::default() - }; let sandbox: Arc = Arc::new( fabro_sandbox::provider_sandbox( fabro_sandbox::SandboxProviderKind::DOCKER, &fabro_sandbox::ProviderAccess::default(), - options, - None, - None, - None, - None, + sandbox_driver::SandboxSpec::new(sandbox_driver::SandboxSource::HostDirectory), + &fabro_sandbox::CloneRequest::none(), None, None, ) @@ -13734,7 +13727,7 @@ async fn asset_collection_docker_sandbox() { "artifact scratch cache should not be created" ); - sandbox.cleanup().await.unwrap(); + sandbox.delete().await.unwrap(); } #[tokio::test] diff --git a/lib/components/fabro-workflow/tests/it/pebble_agent.rs b/lib/components/fabro-workflow/tests/it/pebble_agent.rs index f688306c5..78642ab35 100644 --- a/lib/components/fabro-workflow/tests/it/pebble_agent.rs +++ b/lib/components/fabro-workflow/tests/it/pebble_agent.rs @@ -1269,14 +1269,8 @@ async fn docker_sandbox_runs_an_agent_stage() { fabro_sandbox::provider_sandbox( fabro_sandbox::SandboxProviderKind::DOCKER, &fabro_sandbox::ProviderAccess::default(), - fabro_sandbox::SandboxOptions { - skip_clone: true, - ..Default::default() - }, - None, - None, - None, - None, + sandbox_driver::SandboxSpec::new(sandbox_driver::SandboxSource::HostDirectory), + &fabro_sandbox::CloneRequest::none(), None, None, ) @@ -1287,7 +1281,7 @@ async fn docker_sandbox_runs_an_agent_stage() { agent_stage_smoke(Arc::clone(&sandbox), "docker").await; - sandbox.cleanup().await.expect("Docker cleanup failed"); + sandbox.delete().await.expect("Docker cleanup failed"); } #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"))] @@ -1298,31 +1292,20 @@ async fn docker_sandbox_runs_an_agent_stage() { async fn daytona_sandbox_runs_an_agent_stage() { use fabro_static::EnvVars; + let api_key = std::env::var(EnvVars::DAYTONA_API_KEY).expect("DAYTONA_API_KEY must be set"); let access = fabro_sandbox::ProviderAccess { - daytona: Some(fabro_sandbox::DaytonaCredentials { - api_key: std::env::var(EnvVars::DAYTONA_API_KEY) - .expect("DAYTONA_API_KEY must be set"), - api_url: std::env::var(EnvVars::DAYTONA_API_URL) - .or_else(|_| std::env::var(EnvVars::DAYTONA_SERVER_URL)) - .ok(), - organization_id: std::env::var(EnvVars::DAYTONA_ORGANIZATION_ID).ok(), - target: None, - http_client: None, - }), + daytona: Some(fabro_sandbox::DaytonaCredentials::from_api_key( + api_key, + |name| std::env::var(name).ok(), + )), ..fabro_sandbox::ProviderAccess::default() }; let sandbox: Arc = Arc::new( fabro_sandbox::provider_sandbox( fabro_sandbox::SandboxProviderKind::DAYTONA, &access, - fabro_sandbox::SandboxOptions { - skip_clone: true, - ..Default::default() - }, - None, - None, - None, - None, + sandbox_driver::SandboxSpec::new(sandbox_driver::SandboxSource::HostDirectory), + &fabro_sandbox::CloneRequest::none(), None, None, ) @@ -1333,5 +1316,5 @@ async fn daytona_sandbox_runs_an_agent_stage() { agent_stage_smoke(Arc::clone(&sandbox), "daytona").await; - sandbox.cleanup().await.expect("Daytona cleanup failed"); + sandbox.delete().await.expect("Daytona cleanup failed"); } diff --git a/lib/foundation/fabro-api/Cargo.toml b/lib/foundation/fabro-api/Cargo.toml index 02f85c3df..ec0ef3535 100644 --- a/lib/foundation/fabro-api/Cargo.toml +++ b/lib/foundation/fabro-api/Cargo.toml @@ -23,6 +23,7 @@ lithos-llm = { workspace = true, features = ["runtime"] } progenitor-client = "0.13" regress = "0.10" reqwest.workspace = true +sandbox-driver.workspace = true serde.workspace = true serde_json.workspace = true uuid = { workspace = true, features = ["serde"] } diff --git a/lib/foundation/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs index e9db3b485..6630080f7 100644 --- a/lib/foundation/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -658,28 +658,20 @@ fn main() { "fabro_types::SandboxListResponse", &[], ), - ("SandboxNetwork", "fabro_types::SandboxNetwork", &[]), + // A sandbox's status is the sandbox driver's own type: the API reuses + // it and the types it carries rather than projecting them. + ("SandboxStatus", "sandbox_driver::SandboxStatus", &[]), + ("SandboxId", "sandbox_driver::SandboxId", &[]), + ("SandboxState", "sandbox_driver::SandboxState", &[]), + ("SandboxResources", "sandbox_driver::Resources", &[]), + ("SandboxNetworkPolicy", "sandbox_driver::NetworkPolicy", &[]), + ("SandboxKind", "sandbox_driver::SandboxKind", &[]), ( - "SandboxNetworkPolicy", - "fabro_types::SandboxNetworkPolicy", - &[], - ), - ( - "SandboxNetworkPolicyMode", - "fabro_types::SandboxNetworkPolicyMode", + "SandboxWorkspaceOwnership", + "sandbox_driver::WorkspaceOwnership", &[], ), ("SandboxService", "fabro_types::SandboxService", &[]), - ( - "SandboxServiceDiscoverySource", - "fabro_types::SandboxServiceDiscoverySource", - &[], - ), - ( - "SandboxServiceListMeta", - "fabro_types::SandboxServiceListMeta", - &[], - ), ( "SandboxServiceListResponse", "fabro_types::SandboxServiceListResponse", diff --git a/lib/foundation/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs index 21e8a61d7..3ac034c73 100644 --- a/lib/foundation/fabro-api/src/lib.rs +++ b/lib/foundation/fabro-api/src/lib.rs @@ -62,18 +62,16 @@ pub mod types { RunPairStatusResponse, RunProjection, RunProvenance, RunRunnableSource, RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan, RunSandboxRuntime, RunServerProvenance, RunSessionMetadata, RunSize, RunTarget, SandboxDetails, SandboxInfo, - SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy, - SandboxNetworkPolicyMode, SandboxProviderKind, SandboxProviderLookupError, - SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState, - SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId, - SessionStatus, SessionSummary, SessionTurn, SkillActivationSource, SkillSummary, - SkillsProjection, StageCompletion, StageContextWindow, StageContextWindowUnavailableReason, - StageHandler, StageId, StageInferenceProjection, StageModelUsage, StageOutcome, - StageProjection, StageState, StageToolBatchProjection, SubAgentProjection, SubAgentStatus, - SystemActorKind, SystemIntegrationStatus, SystemIntegrationsResponse, TodoListProjection, - ToolCategory, ToolSource, ToolSummary, TurnId, UpdateVariableRequest, UserPrincipal, - Variable, VariableListResponse, WorkflowPath, WorkflowSettings, WorkflowVersion, - WorkflowVersionId, + SandboxListMeta, SandboxListResponse, SandboxProviderKind, SandboxProviderLookupError, + SandboxService, SandboxServiceListResponse, SecretMetadata, SecretType, ServerSettings, + SessionDetail, SessionId, SessionStatus, SessionSummary, SessionTurn, + SkillActivationSource, SkillSummary, SkillsProjection, StageCompletion, StageContextWindow, + StageContextWindowUnavailableReason, StageHandler, StageId, StageInferenceProjection, + StageModelUsage, StageOutcome, StageProjection, StageState, StageToolBatchProjection, + SubAgentProjection, SubAgentStatus, SystemActorKind, SystemIntegrationStatus, + SystemIntegrationsResponse, TodoListProjection, ToolCategory, ToolSource, ToolSummary, + TurnId, UpdateVariableRequest, UserPrincipal, Variable, VariableListResponse, WorkflowPath, + WorkflowSettings, WorkflowVersion, WorkflowVersionId, }; pub use lithos_llm::catalog::{ModelHandle, ProviderId}; pub use lithos_llm::types::{ @@ -83,6 +81,11 @@ pub mod types { ToolDefinition as CompletionToolDefinition, ToolDefinitionKind as CompletionToolDefinitionKind, }; + /// A sandbox's status on the API is the sandbox driver's own type. + pub use sandbox_driver::{ + NetworkPolicy as SandboxNetworkPolicy, Resources as SandboxResources, SandboxId, + SandboxKind, SandboxState, SandboxStatus, WorkspaceOwnership as SandboxWorkspaceOwnership, + }; pub use crate::generated::types::*; } diff --git a/lib/foundation/fabro-api/tests/sandbox_details_round_trip.rs b/lib/foundation/fabro-api/tests/sandbox_details_round_trip.rs index af742232b..bdfd35aae 100644 --- a/lib/foundation/fabro-api/tests/sandbox_details_round_trip.rs +++ b/lib/foundation/fabro-api/tests/sandbox_details_round_trip.rs @@ -1,38 +1,60 @@ use std::any::{TypeId, type_name}; -use std::collections::BTreeMap; +use std::time::SystemTime; -use chrono::{TimeZone, Utc}; +use chrono::DateTime; use fabro_api::types::{ - SandboxDetails as ApiSandboxDetails, SandboxNetwork as ApiSandboxNetwork, - SandboxNetworkPolicy as ApiSandboxNetworkPolicy, - SandboxNetworkPolicyMode as ApiSandboxNetworkPolicyMode, - SandboxProviderKind as ApiSandboxProvider, SandboxResources as ApiSandboxResources, - SandboxState as ApiSandboxState, SandboxTimestamps as ApiSandboxTimestamps, + SandboxDetails as ApiSandboxDetails, SandboxId as ApiSandboxId, SandboxKind as ApiSandboxKind, + SandboxNetworkPolicy as ApiSandboxNetworkPolicy, SandboxProviderKind as ApiSandboxProvider, + SandboxResources as ApiSandboxResources, SandboxState as ApiSandboxState, + SandboxStatus as ApiSandboxStatus, SandboxWorkspaceOwnership as ApiSandboxWorkspaceOwnership, }; -use fabro_types::{ - RunSandboxInstance, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, - SandboxNetworkPolicyMode, SandboxProviderKind, SandboxResources, SandboxState, - SandboxTimestamps, +use fabro_types::{RunSandboxInstance, RunSandboxRuntime, SandboxDetails, SandboxProviderKind}; +use sandbox_driver::{ + NetworkPolicy, Resources, SandboxId, SandboxKind, SandboxState, SandboxStatus, + WorkspaceOwnership, }; use serde_json::json; #[test] -fn sandbox_details_reuses_domain_types() { +fn sandbox_details_reuses_the_domain_and_driver_types() { assert_same_type::(); assert_same_type::(); + assert_same_type::(); + assert_same_type::(); assert_same_type::(); - assert_same_type::(); - assert_same_type::(); - assert_same_type::(); - assert_same_type::(); - assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); } #[test] fn sandbox_details_json_matches_openapi_shape() { - let created_at = Utc.with_ymd_and_hms(2026, 5, 9, 12, 0, 0).unwrap(); + let mut status = SandboxStatus::new( + SandboxId::try_new("container-abc123").unwrap(), + SandboxState::Running, + ); + status.name = Some("fabro-run-abc".to_string()); + status.provider_state = "running".to_string(); + let mut resources = Resources::default(); + resources.cpu_cores = Some(2); + resources.memory_mb = Some(4096); + status.resources = Some(resources); + status.sandbox_kind = Some(SandboxKind::Container); + status.labels.insert("run".to_string(), "abc".to_string()); + status.image = Some("ghcr.io/fabro/sandbox:latest".to_string()); + status.network = Some(NetworkPolicy::CidrAllowList { + cidrs: vec!["10.0.0.0/8".to_string()], + }); + status.web_url = Some( + "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9" + .to_string(), + ); + status.created_at = Some(SystemTime::from( + DateTime::parse_from_rfc3339("2026-05-09T12:00:00Z").unwrap(), + )); let details = SandboxDetails { - sandbox: RunSandboxInstance { + sandbox: RunSandboxInstance { provider: SandboxProviderKind::DOCKER, image: Some("ghcr.io/fabro/sandbox:latest".to_string()), snapshot: None, @@ -48,27 +70,7 @@ fn sandbox_details_json_matches_openapi_shape() { primary_repo_link: Some("/workspace/fabro".to_string()), }, }, - state: SandboxState::Running, - native_state: Some("running".to_string()), - region: None, - web_url: Some( - "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9" - .to_string(), - ), - resources: SandboxResources { - cpu_cores: Some(2.0), - memory_bytes: Some(4 * 1024 * 1024 * 1024), - disk_bytes: None, - }, - network: SandboxNetwork { - egress: SandboxNetworkPolicy::open(), - ingress: SandboxNetworkPolicy::blocked(), - }, - labels: BTreeMap::from([("run".to_string(), "abc".to_string())]), - timestamps: SandboxTimestamps { - created_at: Some(created_at), - last_activity_at: None, - }, + status, }; assert_eq!( @@ -86,75 +88,68 @@ fn sandbox_details_json_matches_openapi_shape() { "primary_repo_link": "/workspace/fabro" } }, - "state": "running", - "native_state": "running", - "web_url": "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9", - "resources": { - "cpu_cores": 2.0, - "memory_bytes": 4_294_967_296_u64, - }, - "network": { - "egress": { - "mode": "open", - "cidrs": [] + "status": { + "id": "container-abc123", + "name": "fabro-run-abc", + "state": "running", + "provider_state": "running", + "error_reason": null, + "resources": { + "cpu_cores": 2, + "memory_mb": 4096, + "disk_mb": null, + "gpus": null }, - "ingress": { - "mode": "blocked", - "cidrs": [] - } - }, - "labels": { - "run": "abc" - }, - "timestamps": { - "created_at": "2026-05-09T12:00:00Z" + "sandbox_kind": "container", + "region": null, + "labels": { "run": "abc" }, + "image": "ghcr.io/fabro/sandbox:latest", + "snapshot": null, + "network": { "cidr_allow_list": { "cidrs": ["10.0.0.0/8"] } }, + "workspace_ownership": null, + "web_url": "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9", + "created_at": "2026-05-09T12:00:00Z", + "updated_at": null } }) ); } #[test] -fn sandbox_details_deserializes_when_optional_fields_are_absent() { +fn sandbox_details_deserializes_a_status_with_only_its_required_fields() { let details: SandboxDetails = serde_json::from_value(json!({ "sandbox": { "provider": "local", "runtime": { - "id": "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z", + "id": "host-dir-2f55736572732f636c69656e742f70726f6a656374", "working_directory": "/Users/client/project" } }, - "state": "unknown", - "resources": {}, - "labels": {}, - "timestamps": {} + "status": { + "id": "host-dir-2f55736572732f636c69656e742f70726f6a656374", + "state": "running", + "workspace_ownership": "designated" + } })) .unwrap(); assert_eq!(details.sandbox.provider, SandboxProviderKind::LOCAL); + assert_eq!(details.status.state, SandboxState::Running); assert_eq!( - details.sandbox.runtime.id.as_str(), - "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z" + details.status.workspace_ownership, + Some(WorkspaceOwnership::Designated) ); - assert_eq!( - details.sandbox.runtime.working_directory.as_str(), - "/Users/client/project" - ); - assert_eq!(details.state, SandboxState::Unknown); - assert!(details.sandbox.image.is_none()); - assert!(details.region.is_none()); - assert!(details.native_state.is_none()); - assert!(details.labels.is_empty()); - assert_eq!(details.resources, SandboxResources::default()); - assert_eq!(details.network, SandboxNetwork::unknown()); - assert_eq!(details.timestamps, SandboxTimestamps::default()); + assert!(details.status.resources.is_none()); + assert!(details.status.network.is_none()); + assert!(details.status.created_at.is_none()); } -fn assert_same_type() { +fn assert_same_type() { assert_eq!( - TypeId::of::(), - TypeId::of::(), - "{} should be the same type as {}", - type_name::(), - type_name::() + TypeId::of::(), + TypeId::of::(), + "{} should be {}", + type_name::(), + type_name::() ); } diff --git a/lib/foundation/fabro-api/tests/sandbox_inventory_round_trip.rs b/lib/foundation/fabro-api/tests/sandbox_inventory_round_trip.rs index 336c2f1e8..a802d8b15 100644 --- a/lib/foundation/fabro-api/tests/sandbox_inventory_round_trip.rs +++ b/lib/foundation/fabro-api/tests/sandbox_inventory_round_trip.rs @@ -1,17 +1,17 @@ use std::any::{TypeId, type_name}; -use std::collections::BTreeMap; +use std::time::SystemTime; -use chrono::{TimeZone, Utc}; +use chrono::DateTime; use fabro_api::types::{ SandboxInfo as ApiSandboxInfo, SandboxListMeta as ApiSandboxListMeta, SandboxListResponse as ApiSandboxListResponse, SandboxProviderKind as ApiSandboxProviderKind, SandboxProviderLookupError as ApiSandboxProviderLookupError, }; use fabro_types::{ - SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy, - SandboxProviderKind, SandboxProviderLookupError, SandboxResources, SandboxState, - SandboxTimestamps, + SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxProviderKind, + SandboxProviderLookupError, }; +use sandbox_driver::{NetworkPolicy, Resources, SandboxId, SandboxState, SandboxStatus}; use serde_json::json; #[test] @@ -25,99 +25,95 @@ fn sandbox_inventory_round_trip_reuses_domain_types() { #[test] fn sandbox_inventory_round_trip_json_matches_openapi_shape() { - let created_at = Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(); + let mut status = SandboxStatus::new( + SandboxId::try_new("sandbox-abc123").unwrap(), + SandboxState::Running, + ); + status.name = Some("fabro-01KSGHGMCFM8W2FHXNMJ7MVY65".to_string()); + status.provider_state = "started".to_string(); + let mut resources = Resources::default(); + resources.cpu_cores = Some(2); + resources.memory_mb = Some(4096); + resources.disk_mb = Some(20 * 1024); + status.resources = Some(resources); + status.region = Some("us".to_string()); + status + .labels + .insert("sh.fabro.managed".to_string(), "true".to_string()); + status.snapshot = Some("daytona-medium".to_string()); + status.network = Some(NetworkPolicy::Block); + status.web_url = + Some("https://app.daytona.io/dashboard/sandboxes?sandboxId=sandbox-abc123".to_string()); + let at = SystemTime::from(DateTime::parse_from_rfc3339("2026-05-25T12:00:00Z").unwrap()); + status.created_at = Some(at); + status.updated_at = Some(at); let response = SandboxListResponse { data: vec![SandboxInfo { - provider: SandboxProviderKind::DAYTONA, - id: "sandbox-abc123".to_string(), - display_name: Some("fabro-01KSGHGMCFM8W2FHXNMJ7MVY65".to_string()), - state: SandboxState::Running, - native_state: Some("started".to_string()), - image: None, - snapshot: Some("daytona-medium".to_string()), - region: Some("us".to_string()), - web_url: Some( - "https://app.daytona.io/dashboard/sandboxes?sandboxId=sandbox-abc123".to_string(), - ), - working_directory: Some("/home/daytona/workspace".to_string()), - resources: SandboxResources { - cpu_cores: Some(2.0), - memory_bytes: Some(4 * 1024 * 1024 * 1024), - disk_bytes: Some(20 * 1024 * 1024 * 1024), - }, - network: SandboxNetwork { - egress: SandboxNetworkPolicy::open(), - ingress: SandboxNetworkPolicy::blocked(), - }, - labels: BTreeMap::from([( - "sh.fabro.managed".to_string(), - "true".to_string(), - )]), - timestamps: SandboxTimestamps { - created_at: Some(created_at), - last_activity_at: Some(created_at), - }, + provider: SandboxProviderKind::DAYTONA, + status, }], meta: SandboxListMeta { provider_errors: vec![SandboxProviderLookupError { provider: SandboxProviderKind::DOCKER, - message: "Failed to connect to Docker daemon".to_string(), + message: "docker daemon unreachable".to_string(), }], }, }; + let value = serde_json::to_value(&response).unwrap(); assert_eq!( - serde_json::to_value(&response).unwrap(), + value, json!({ "data": [{ "provider": "daytona", - "id": "sandbox-abc123", - "display_name": "fabro-01KSGHGMCFM8W2FHXNMJ7MVY65", - "state": "running", - "native_state": "started", - "snapshot": "daytona-medium", - "region": "us", - "web_url": "https://app.daytona.io/dashboard/sandboxes?sandboxId=sandbox-abc123", - "working_directory": "/home/daytona/workspace", - "resources": { - "cpu_cores": 2.0, - "memory_bytes": 4_294_967_296_u64, - "disk_bytes": 21_474_836_480_u64 - }, - "network": { - "egress": { - "mode": "open", - "cidrs": [] + "status": { + "id": "sandbox-abc123", + "name": "fabro-01KSGHGMCFM8W2FHXNMJ7MVY65", + "state": "running", + "provider_state": "started", + "error_reason": null, + "resources": { + "cpu_cores": 2, + "memory_mb": 4096, + "disk_mb": 20480, + "gpus": null }, - "ingress": { - "mode": "blocked", - "cidrs": [] - } - }, - "labels": { - "sh.fabro.managed": "true" - }, - "timestamps": { + "sandbox_kind": null, + "region": "us", + "labels": { "sh.fabro.managed": "true" }, + "image": null, + "snapshot": "daytona-medium", + "network": "block", + "workspace_ownership": null, + "web_url": "https://app.daytona.io/dashboard/sandboxes?sandboxId=sandbox-abc123", "created_at": "2026-05-25T12:00:00Z", - "last_activity_at": "2026-05-25T12:00:00Z" + "updated_at": "2026-05-25T12:00:00Z" } }], "meta": { "provider_errors": [{ "provider": "docker", - "message": "Failed to connect to Docker daemon" + "message": "docker daemon unreachable" }] } }) ); + + let decoded: ApiSandboxListResponse = serde_json::from_value(value).unwrap(); + assert_eq!(decoded.data[0].provider, SandboxProviderKind::DAYTONA); + assert_eq!(decoded.data[0].status.state, SandboxState::Running); + assert!(matches!( + decoded.data[0].status.network, + Some(NetworkPolicy::Block) + )); } -fn assert_same_type() { +fn assert_same_type() { assert_eq!( - TypeId::of::(), - TypeId::of::(), - "{} should be the same type as {}", - type_name::(), - type_name::() + TypeId::of::(), + TypeId::of::(), + "{} should be {}", + type_name::(), + type_name::() ); } diff --git a/lib/foundation/fabro-api/tests/sandbox_services_round_trip.rs b/lib/foundation/fabro-api/tests/sandbox_services_round_trip.rs index 706ef59f2..4c62059ec 100644 --- a/lib/foundation/fabro-api/tests/sandbox_services_round_trip.rs +++ b/lib/foundation/fabro-api/tests/sandbox_services_round_trip.rs @@ -4,10 +4,7 @@ use fabro_api::types::{ SandboxService as ApiSandboxService, SandboxServiceListResponse as ApiSandboxServiceListResponse, }; -use fabro_types::{ - SandboxService, SandboxServiceDiscoverySource, SandboxServiceListMeta, - SandboxServiceListResponse, -}; +use fabro_types::{SandboxService, SandboxServiceListResponse}; use serde_json::json; #[test] @@ -22,15 +19,9 @@ fn sandbox_services_json_matches_openapi_shape() { data: vec![SandboxService { port: 3000, addresses: vec!["127.0.0.1:3000".to_string(), "[::]:3000".to_string()], - processes: vec![ - r#"users:(("node",pid=42,fd=23))"#.to_string(), - r#"users:(("vite",pid=84,fd=19))"#.to_string(), - ], + processes: vec!["node".to_string()], preview_supported: true, }], - meta: SandboxServiceListMeta { - source: SandboxServiceDiscoverySource::Ss, - }, }; assert_eq!( @@ -39,27 +30,19 @@ fn sandbox_services_json_matches_openapi_shape() { "data": [{ "port": 3000, "addresses": ["127.0.0.1:3000", "[::]:3000"], - "processes": [ - r#"users:(("node",pid=42,fd=23))"#, - r#"users:(("vite",pid=84,fd=19))"#, - ], + "processes": ["node"], "preview_supported": true - }], - "meta": { - "source": "ss" - } + }] }) ); } #[test] fn sandbox_services_deserializes_empty_response() { - let response: SandboxServiceListResponse = - serde_json::from_value(json!({ "data": [], "meta": { "source": "procfs" } })) - .expect("empty service response should deserialize"); + let response: SandboxServiceListResponse = serde_json::from_value(json!({ "data": [] })) + .expect("empty service response should deserialize"); assert!(response.data.is_empty()); - assert_eq!(response.meta.source, SandboxServiceDiscoverySource::Procfs); } fn assert_same_type() { diff --git a/lib/foundation/fabro-redact/src/safe_url.rs b/lib/foundation/fabro-redact/src/safe_url.rs index 0661f0667..635e95927 100644 --- a/lib/foundation/fabro-redact/src/safe_url.rs +++ b/lib/foundation/fabro-redact/src/safe_url.rs @@ -89,6 +89,12 @@ impl DisplaySafeUrl { self.0.to_string() } + /// Replace every occurrence of this URL's raw form in `text` with its + /// redacted display form, for output that may echo a credentialed URL. + pub fn redact_in(&self, text: &str) -> String { + text.replace(&self.raw_string(), &self.redacted_string()) + } + /// Remove credentials from this URL, preserving the SSH `git` username. #[inline] pub fn remove_credentials(&mut self) { @@ -511,4 +517,15 @@ mod tests { formatter.debug_struct("CapturedTraceWriter").finish() } } + + #[test] + fn redact_in_replaces_the_raw_url_with_its_display_form() { + let url = DisplaySafeUrl::parse("https://x-access-token:ghs_secret@github.com/o/r.git") + .expect("valid url"); + let text = format!("fatal: unable to access '{}': 403", url.raw_string()); + let redacted = url.redact_in(&text); + assert!(!redacted.contains("ghs_secret"), "{redacted}"); + assert!(redacted.contains("github.com/o/r.git"), "{redacted}"); + assert_eq!(url.redact_in("nothing to see"), "nothing to see"); + } } diff --git a/lib/foundation/fabro-test/src/lib.rs b/lib/foundation/fabro-test/src/lib.rs index 74bf304a2..8fe993421 100644 --- a/lib/foundation/fabro-test/src/lib.rs +++ b/lib/foundation/fabro-test/src/lib.rs @@ -74,6 +74,18 @@ static INSTA_FILTERS: &[(&str, &str)] = &[ "Duration: [DURATION]", ), (r"Base: [^\n]+ \([0-9a-f]{7,40}\)", "Base: [BASE]"), + // The sandbox driver's events: per-process event source ids, operation + // ids, sub-second durations, and a local sandbox's path-derived id. + ( + r#""source_id"(\s*:\s*)"[0-9a-f]{32}""#, + r#""source_id"$1"[HEX]""#, + ), + ( + r#""operation_id"(\s*:\s*)"[0-9a-f]{32}""#, + r#""operation_id"$1"[HEX]""#, + ), + (r#""nanos"(\s*:\s*)\d+"#, r#""nanos"$1"[NANOS]""#), + (r"host-dir-[0-9a-f]+", "host-dir-[HEX]"), (r"\\([\w\d])", "/$1"), ]; diff --git a/lib/foundation/fabro-types/Cargo.toml b/lib/foundation/fabro-types/Cargo.toml index e7e69d13e..2e777317b 100644 --- a/lib/foundation/fabro-types/Cargo.toml +++ b/lib/foundation/fabro-types/Cargo.toml @@ -25,6 +25,7 @@ fabro-util = { path = "../fabro-util" } hex.workspace = true lithos-llm = { workspace = true, features = ["runtime"] } pebble-coding-agent.workspace = true +sandbox-driver.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/lib/foundation/fabro-types/src/lib.rs b/lib/foundation/fabro-types/src/lib.rs index bfb097d41..b4b26fe74 100644 --- a/lib/foundation/fabro-types/src/lib.rs +++ b/lib/foundation/fabro-types/src/lib.rs @@ -132,7 +132,7 @@ pub use run_event::{ AgentEventProps, AgentMcpToolSummary, AgentToolsAvailableProps, CODING_EVENT_NAMES, EventBody, FailoverProps, InterviewOption, MetadataSnapshotFailureKind, MetadataSnapshotPhase, RunEvent, RunNoticeCode, RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, RunRunnableSource, - SessionCapability, coding_event_name, is_coding_event_name, + SessionCapability, coding_event_name, is_coding_event_name, sandbox_driver_event_name, }; pub use run_failure::RunFailure; pub use run_id::{RunId, fixtures}; @@ -158,20 +158,14 @@ pub use run_summary::{ pub use run_title::{ MAX_RUN_TITLE_CHARS, RunTitleError, infer_run_title, normalize_explicit_run_title, }; -pub use sandbox_details::{ - SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode, - SandboxResources, SandboxState, SandboxTimestamps, -}; +pub use sandbox_details::SandboxDetails; pub use sandbox_inventory::{ SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxProviderLookupError, }; pub use sandbox_provider::{ BundledProvider, InvalidSandboxProviderKind, SandboxProviderKind, WorkspacePolicy, }; -pub use sandbox_services::{ - SandboxService, SandboxServiceDiscoverySource, SandboxServiceListMeta, - SandboxServiceListResponse, -}; +pub use sandbox_services::{SandboxService, SandboxServiceListResponse}; pub use secret::{OAuthConfig, OAuthCredential, OAuthTokens, SecretMetadata, SecretType}; pub use session::{ RunSessionMetadata, SessionDetail, SessionId, SessionStatus, SessionSummary, SessionTurn, diff --git a/lib/foundation/fabro-types/src/run_event/infra.rs b/lib/foundation/fabro-types/src/run_event/infra.rs index 7f613f5e2..c8c1e7b96 100644 --- a/lib/foundation/fabro-types/src/run_event/infra.rs +++ b/lib/foundation/fabro-types/src/run_event/infra.rs @@ -142,82 +142,6 @@ pub struct SandboxReadyProps { pub type SandboxFailedProps = RunSandboxFailure; -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SandboxStartStartedProps { - pub provider: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SandboxStartCompletedProps { - pub provider: String, - pub duration_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SandboxStartFailedProps { - pub provider: String, - pub error: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub causes: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SandboxStopStartedProps { - pub provider: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SandboxStopCompletedProps { - pub provider: String, - pub duration_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SandboxStopFailedProps { - pub provider: String, - pub error: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub causes: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SandboxDeleteStartedProps { - pub provider: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SandboxDeleteCompletedProps { - pub provider: String, - pub duration_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SandboxDeleteFailedProps { - pub provider: String, - pub error: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub causes: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SnapshotNameProps { - pub name: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SnapshotCompletedProps { - pub name: String, - pub duration_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SnapshotFailedProps { - pub name: String, - pub error: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub causes: Vec, -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SandboxInitializedProps { pub working_directory: String, diff --git a/lib/foundation/fabro-types/src/run_event/misc.rs b/lib/foundation/fabro-types/src/run_event/misc.rs index bb3116992..19406b57b 100644 --- a/lib/foundation/fabro-types/src/run_event/misc.rs +++ b/lib/foundation/fabro-types/src/run_event/misc.rs @@ -136,30 +136,6 @@ pub enum GitTokenProvenance { Static, } -/// What credential preparation changed before a git push attempt. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum GitCredentialAction { - /// Fabro wrote a token generation into the remote URL. - Embedded, - /// The remote already tracked the selected token generation. - Unchanged, - /// No managed credential was available. - None, -} - -/// Which credential preparation step failed before a git push attempt. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum GitCredentialRefreshError { - /// Token resolution or minting failed. - Mint, - /// Rewriting the remote URL failed. - SetUrl, -} - /// One attempt of a retried git push, nested inside [`GitPushProps`]. /// /// The durable projection of the sandbox layer's runtime attempt record. @@ -188,13 +164,6 @@ pub struct GitPushAttemptProps { /// Token age at the attempt; absent for static credentials. #[serde(default, skip_serializing_if = "Option::is_none")] pub token_age_ms: Option, - /// What the credential refresh did to the remote this attempt: - /// `embedded`, `unchanged`, or `none`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub credential_action: Option, - /// A credential `mint` or `set_url` failure this attempt pushed through. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub refresh_error: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/lib/foundation/fabro-types/src/run_event/mod.rs b/lib/foundation/fabro-types/src/run_event/mod.rs index adcd0ea16..1d4ec2c32 100644 --- a/lib/foundation/fabro-types/src/run_event/mod.rs +++ b/lib/foundation/fabro-types/src/run_event/mod.rs @@ -231,32 +231,17 @@ pub enum EventBody { SandboxReady(SandboxReadyProps), #[serde(rename = "sandbox.failed")] SandboxFailed(SandboxFailedProps), - #[serde(rename = "sandbox.start.started")] - SandboxStartStarted(SandboxStartStartedProps), - #[serde(rename = "sandbox.start.completed")] - SandboxStartCompleted(SandboxStartCompletedProps), - #[serde(rename = "sandbox.start.failed")] - SandboxStartFailed(SandboxStartFailedProps), - #[serde(rename = "sandbox.stop.started")] - SandboxStopStarted(SandboxStopStartedProps), - #[serde(rename = "sandbox.stop.completed")] - SandboxStopCompleted(SandboxStopCompletedProps), - #[serde(rename = "sandbox.stop.failed")] - SandboxStopFailed(SandboxStopFailedProps), - #[serde(rename = "sandbox.delete.started")] - SandboxDeleteStarted(SandboxDeleteStartedProps), - #[serde(rename = "sandbox.delete.completed")] - SandboxDeleteCompleted(SandboxDeleteCompletedProps), - #[serde(rename = "sandbox.delete.failed")] - SandboxDeleteFailed(SandboxDeleteFailedProps), - #[serde(rename = "sandbox.snapshot.pulling")] - SnapshotPulling(SnapshotNameProps), - #[serde(rename = "sandbox.snapshot.creating")] - SnapshotCreating(SnapshotNameProps), - #[serde(rename = "sandbox.snapshot.ready")] - SnapshotReady(SnapshotCompletedProps), - #[serde(rename = "sandbox.snapshot.failed")] - SnapshotFailed(SnapshotFailedProps), + /// An event the sandbox driver reported about the run's sandbox, a + /// snapshot, a volume, or the provider, stored as the driver's own event + /// under a name derived from it (`sandbox.stop.completed`, + /// `snapshot.create.started`, `sandbox.state`); see + /// [`sandbox_driver_event_name`]. The derive never sees this variant: + /// the run event writes the name and the driver's event itself. + #[serde(skip)] + SandboxDriver { + name: String, + event: sandbox_driver::Event, + }, #[serde(rename = "sandbox.initialized")] SandboxInitialized(SandboxInitializedProps), #[serde(rename = "setup.started")] @@ -360,6 +345,88 @@ struct RunEventParts<'a> { properties: &'a Value, } +impl EventBody { + /// The sandbox driver's event as a run event body, named by + /// [`sandbox_driver_event_name`]. + #[must_use] + pub fn sandbox_driver(event: sandbox_driver::Event) -> Self { + Self::SandboxDriver { + name: sandbox_driver_event_name(&event), + event, + } + } + + /// A stored driver event: `name` has the shape the driver's events are + /// stored under and `properties` decode to a driver event that yields + /// that name. Anything else, including an event stored under one of + /// these names before the driver's events were kept whole, is left to + /// the other variants. + fn sandbox_driver_from_stored(name: &str, properties: &Value) -> Option { + if !is_sandbox_driver_event_name(name) { + return None; + } + let event: sandbox_driver::Event = serde_json::from_value(properties.clone()).ok()?; + (sandbox_driver_event_name(&event) == name).then(|| Self::SandboxDriver { + name: name.to_owned(), + event, + }) + } +} + +/// Whether `name` has the shape the sandbox driver's events are stored +/// under: `..`, `.state`, `.notice`, +/// or `.event`, for the subjects the driver reports on. +fn is_sandbox_driver_event_name(name: &str) -> bool { + let Some((subject, rest)) = name.split_once('.') else { + return false; + }; + matches!(subject, "sandbox" | "snapshot" | "volume" | "provider") + && (matches!(rest, "state" | "notice" | "event") + || rest.split_once('.').is_some_and(|(_, phase)| { + matches!(phase, "started" | "progress" | "completed" | "failed") + })) +} + +/// The run event name for a sandbox driver event: the subject kind, the +/// action, and the phase, so a stop on the sandbox is `sandbox.stop.started`, +/// `sandbox.stop.completed`, or `sandbox.stop.failed`, an image pull inside +/// a create is `sandbox.create.progress`, and a snapshot build is +/// `snapshot.create.*`. A state observation is `.state`, a notice +/// `.notice`, and an event kind this build does not know +/// `.event`. +#[must_use] +pub fn sandbox_driver_event_name(event: &sandbox_driver::Event) -> String { + use sandbox_driver::{EventBody as Body, EventSubject}; + + let subject = match &event.subject { + EventSubject::Snapshot { .. } => "snapshot", + EventSubject::Volume { .. } => "volume", + EventSubject::Provider => "provider", + _ => "sandbox", + }; + let (action, phase) = match &event.body { + Body::OperationStarted { action } => (Some(*action), "started"), + Body::OperationProgress { action, .. } => (Some(*action), "progress"), + Body::OperationCompleted { action, .. } => (Some(*action), "completed"), + Body::OperationFailed { action, .. } => (Some(*action), "failed"), + Body::StateObserved { .. } => (None, "state"), + Body::Notice { .. } => (None, "notice"), + _ => (None, "event"), + }; + match action { + Some(action) => format!("{subject}.{}.{phase}", driver_action_name(action)), + None => format!("{subject}.{phase}"), + } +} + +/// The driver action's wire name (`stop`, `refresh_activity`). +fn driver_action_name(action: sandbox_driver::Action) -> String { + match serde_json::to_value(action) { + Ok(Value::String(name)) => name, + _ => "unknown".to_owned(), + } +} + impl EventBody { pub fn event_name(&self) -> &str { match self { @@ -446,19 +513,6 @@ impl EventBody { Self::SandboxInitializing(_) => "sandbox.initializing", Self::SandboxReady(_) => "sandbox.ready", Self::SandboxFailed(_) => "sandbox.failed", - Self::SandboxStartStarted(_) => "sandbox.start.started", - Self::SandboxStartCompleted(_) => "sandbox.start.completed", - Self::SandboxStartFailed(_) => "sandbox.start.failed", - Self::SandboxStopStarted(_) => "sandbox.stop.started", - Self::SandboxStopCompleted(_) => "sandbox.stop.completed", - Self::SandboxStopFailed(_) => "sandbox.stop.failed", - Self::SandboxDeleteStarted(_) => "sandbox.delete.started", - Self::SandboxDeleteCompleted(_) => "sandbox.delete.completed", - Self::SandboxDeleteFailed(_) => "sandbox.delete.failed", - Self::SnapshotPulling(_) => "sandbox.snapshot.pulling", - Self::SnapshotCreating(_) => "sandbox.snapshot.creating", - Self::SnapshotReady(_) => "sandbox.snapshot.ready", - Self::SnapshotFailed(_) => "sandbox.snapshot.failed", Self::SandboxInitialized(_) => "sandbox.initialized", Self::SetupStarted(_) => "setup.started", Self::SetupCommandStarted(_) => "setup.command.started", @@ -483,7 +537,7 @@ impl EventBody { Self::PullRequestLinked(_) => "pull_request.linked", Self::PullRequestUnlinked(_) => "pull_request.unlinked", Self::PullRequestFailed(_) => "pull_request.failed", - Self::Unknown { name, .. } => name.as_str(), + Self::SandboxDriver { name, .. } | Self::Unknown { name, .. } => name.as_str(), } } @@ -497,6 +551,9 @@ impl EventBody { Self::Agent(props) => return serde_json::to_value(props), _ => {} } + if let Self::SandboxDriver { event, .. } = self { + return serde_json::to_value(event); + } match serde_json::to_value(self)? { Value::Object(mut map) => { @@ -591,19 +648,6 @@ fn is_known_event_name(event: &str) -> bool { | "sandbox.cleanup.started" | "sandbox.cleanup.completed" | "sandbox.cleanup.failed" - | "sandbox.start.started" - | "sandbox.start.completed" - | "sandbox.start.failed" - | "sandbox.stop.started" - | "sandbox.stop.completed" - | "sandbox.stop.failed" - | "sandbox.delete.started" - | "sandbox.delete.completed" - | "sandbox.delete.failed" - | "sandbox.snapshot.pulling" - | "sandbox.snapshot.creating" - | "sandbox.snapshot.ready" - | "sandbox.snapshot.failed" | "sandbox.git.started" | "sandbox.git.completed" | "sandbox.git.failed" @@ -630,6 +674,35 @@ fn is_known_event_name(event: &str) -> bool { | "pull_request.linked" | "pull_request.unlinked" | "pull_request.failed" + | "agent.session.started" + | "agent.session.ended" + | "agent.processing.end" + | "agent.input" + | "agent.message" + | "agent.tool.started" + | "agent.tool.completed" + | "agent.tool.process.completed" + | "agent.error" + | "agent.warning" + | "agent.loop.detected" + | "agent.steering.injected" + | "agent.round.interrupted" + | "agent.compaction.started" + | "agent.compaction.completed" + | "agent.llm.started" + | "agent.llm.first_output" + | "agent.llm.retry" + | "agent.sub.spawned" + | "agent.sub.turn.started" + | "agent.sub.completed" + | "agent.sub.failed" + | "agent.sub.closed" + | "agent.memory.loaded" + | "agent.skills.discovered" + | "agent.skill.activated" + | "todo.created" + | "todo.updated" + | "todo.deleted" ) } @@ -715,12 +788,15 @@ impl RunEvent { "event": parts.event, "properties": parts.properties, }); - match serde_json::from_value(body_payload) { - Ok(body) => body, - Err(err) if is_known_event_name(parts.event) => return Err(err), - Err(_) => EventBody::Unknown { - name: parts.event.to_string(), - properties: parts.properties.clone(), + match EventBody::sandbox_driver_from_stored(parts.event, parts.properties) { + Some(body) => body, + None => match serde_json::from_value(body_payload) { + Ok(body) => body, + Err(err) if is_known_event_name(parts.event) => return Err(err), + Err(_) => EventBody::Unknown { + name: parts.event.to_string(), + properties: parts.properties.clone(), + }, }, } }; diff --git a/lib/foundation/fabro-types/src/sandbox_details.rs b/lib/foundation/fabro-types/src/sandbox_details.rs index 7a33ba6ba..362b88168 100644 --- a/lib/foundation/fabro-types/src/sandbox_details.rs +++ b/lib/foundation/fabro-types/src/sandbox_details.rs @@ -1,197 +1,44 @@ -use std::collections::BTreeMap; - -use chrono::{DateTime, Utc}; -use serde::de::Error as _; +use sandbox_driver::SandboxStatus; use serde::{Deserialize, Serialize}; use crate::RunSandboxInstance; -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +/// The sandbox owned by a run: fabro's record of it, and the status the +/// sandbox driver reports for it. +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct SandboxDetails { - pub sandbox: RunSandboxInstance, - pub state: SandboxState, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub native_state: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub region: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub web_url: Option, - pub resources: SandboxResources, - #[serde(default)] - pub network: SandboxNetwork, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub labels: BTreeMap, - pub timestamps: SandboxTimestamps, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SandboxState { - Unknown, - Provisioning, - Starting, - Running, - Stopping, - Stopped, - Paused, - Deleting, - Deleted, - Archived, - Restoring, - Resizing, - Error, -} - -#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)] -pub struct SandboxResources { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cpu_cores: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub memory_bytes: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub disk_bytes: Option, -} - -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] -pub struct SandboxNetwork { - pub egress: SandboxNetworkPolicy, - pub ingress: SandboxNetworkPolicy, -} - -impl SandboxNetwork { - pub fn unknown() -> Self { - Self::default() - } -} - -#[derive(Debug, Clone, PartialEq, Default, Serialize)] -pub struct SandboxNetworkPolicy { - mode: SandboxNetworkPolicyMode, - cidrs: Vec, -} - -impl SandboxNetworkPolicy { - pub fn unknown() -> Self { - Self::default() - } - - pub fn mode(&self) -> SandboxNetworkPolicyMode { - self.mode - } - - pub fn cidrs(&self) -> &[String] { - &self.cidrs - } - - pub fn open() -> Self { - Self { - mode: SandboxNetworkPolicyMode::Open, - cidrs: Vec::new(), - } - } - - pub fn blocked() -> Self { - Self { - mode: SandboxNetworkPolicyMode::Blocked, - cidrs: Vec::new(), - } - } - - pub fn allow_cidrs(cidrs: I) -> Self - where - I: IntoIterator, - S: Into, - { - let cidrs: Vec = cidrs.into_iter().map(Into::into).collect(); - if cidrs.is_empty() { - return Self::unknown(); - } - Self { - mode: SandboxNetworkPolicyMode::CidrAllowList, - cidrs, - } - } - - pub fn essentials_only() -> Self { - Self { - mode: SandboxNetworkPolicyMode::EssentialsOnly, - cidrs: Vec::new(), - } - } -} - -impl<'de> Deserialize<'de> for SandboxNetworkPolicy { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - struct Wire { - #[serde(default)] - mode: SandboxNetworkPolicyMode, - #[serde(default)] - cidrs: Vec, - } - - let wire = Wire::deserialize(deserializer)?; - match wire.mode { - SandboxNetworkPolicyMode::CidrAllowList => { - if wire.cidrs.is_empty() { - return Err(D::Error::custom( - "cidr_allow_list network policy requires at least one CIDR", - )); - } - Ok(Self::allow_cidrs(wire.cidrs)) - } - mode => { - if !wire.cidrs.is_empty() { - return Err(D::Error::custom( - "network policy CIDRs are only valid for cidr_allow_list mode", - )); - } - Ok(Self { - mode, - cidrs: Vec::new(), - }) - } - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SandboxNetworkPolicyMode { - #[default] - Unknown, - Open, - Blocked, - CidrAllowList, - EssentialsOnly, -} - -#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)] -pub struct SandboxTimestamps { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub created_at: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_activity_at: Option>, + pub sandbox: RunSandboxInstance, + pub status: SandboxStatus, } #[cfg(test)] mod tests { - use chrono::TimeZone; + use std::time::SystemTime; + + use chrono::DateTime; + use sandbox_driver::{SandboxId, SandboxState}; use serde_json::json; use super::*; + use crate::{RunSandboxRuntime, SandboxProviderKind}; #[test] - fn serializes_with_snake_case_state() { + fn details_carry_the_record_and_the_drivers_status() { + let mut status = SandboxStatus::new( + SandboxId::try_new("container-abc123").unwrap(), + SandboxState::Running, + ); + status.provider_state = "running".to_string(); + status.image = Some("ghcr.io/fabro/sandbox:latest".to_string()); + status.created_at = Some(SystemTime::from( + DateTime::parse_from_rfc3339("2026-05-09T12:00:00Z").unwrap(), + )); let details = SandboxDetails { - sandbox: RunSandboxInstance { - provider: crate::SandboxProviderKind::DOCKER, + sandbox: RunSandboxInstance { + provider: SandboxProviderKind::DOCKER, image: Some("ghcr.io/fabro/sandbox:latest".to_string()), snapshot: None, - runtime: crate::RunSandboxRuntime { + runtime: RunSandboxRuntime { id: "container-abc123".to_string(), working_directory: "/workspace".to_string(), repo_cloned: None, @@ -203,168 +50,39 @@ mod tests { primary_repo_link: None, }, }, - state: SandboxState::Running, - native_state: Some("running".to_string()), - region: None, - web_url: Some( - "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9" - .to_string(), - ), - resources: SandboxResources { - cpu_cores: Some(2.0), - memory_bytes: Some(4 * 1024 * 1024 * 1024), - disk_bytes: None, - }, - network: SandboxNetwork { - egress: SandboxNetworkPolicy::allow_cidrs(["10.0.0.0/8"]), - ingress: SandboxNetworkPolicy::unknown(), - }, - labels: BTreeMap::from([("run".to_string(), "abc".to_string())]), - timestamps: SandboxTimestamps { - created_at: Some(Utc.with_ymd_and_hms(2026, 5, 9, 12, 0, 0).unwrap()), - last_activity_at: None, - }, + status, }; - assert_eq!( - serde_json::to_value(&details).unwrap(), - json!({ - "sandbox": { - "provider": "docker", - "image": "ghcr.io/fabro/sandbox:latest", - "runtime": { - "id": "container-abc123", - "working_directory": "/workspace" - } - }, - "state": "running", - "native_state": "running", - "web_url": "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9", - "resources": { - "cpu_cores": 2.0, - "memory_bytes": 4_294_967_296_u64, - }, - "network": { - "egress": { - "mode": "cidr_allow_list", - "cidrs": ["10.0.0.0/8"] - }, - "ingress": { - "mode": "unknown", - "cidrs": [] - } - }, - "labels": { - "run": "abc" - }, - "timestamps": { - "created_at": "2026-05-09T12:00:00Z" - } - }) - ); + let value = serde_json::to_value(&details).unwrap(); + assert_eq!(value["sandbox"]["provider"], "docker"); + assert_eq!(value["sandbox"]["runtime"]["id"], "container-abc123"); + assert_eq!(value["status"]["id"], "container-abc123"); + assert_eq!(value["status"]["state"], "running"); + assert_eq!(value["status"]["image"], "ghcr.io/fabro/sandbox:latest"); + assert_eq!(value["status"]["snapshot"], json!(null)); + assert_eq!(value["status"]["created_at"], "2026-05-09T12:00:00Z"); + + let decoded: SandboxDetails = serde_json::from_value(value).unwrap(); + assert_eq!(decoded.status.state, SandboxState::Running); + assert_eq!(decoded.status.provider_state, "running"); } #[test] - fn deserializes_with_minimal_fields() { + fn a_status_with_only_its_required_fields_decodes() { let details: SandboxDetails = serde_json::from_value(json!({ "sandbox": { "provider": "local", - "image": null, - "snapshot": null, - "runtime": { - "id": "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z", - "working_directory": "/Users/client/project" - } + "runtime": { + "id": "host-dir-2f746d70", + "working_directory": "/tmp" + } }, - "state": "unknown", - "resources": {}, - "timestamps": {} + "status": { "id": "host-dir-2f746d70", "state": "running" } })) .unwrap(); - - assert_eq!(details.sandbox.provider, crate::SandboxProviderKind::LOCAL); - assert_eq!( - details.sandbox.runtime.id.as_str(), - "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z" - ); - assert_eq!( - details.sandbox.runtime.working_directory.as_str(), - "/Users/client/project" - ); - assert_eq!(details.state, SandboxState::Unknown); - assert!(details.sandbox.image.is_none()); - assert!(details.labels.is_empty()); - assert_eq!(details.resources, SandboxResources::default()); - assert_eq!(details.network, SandboxNetwork::unknown()); - assert_eq!(details.timestamps, SandboxTimestamps::default()); - } - - #[test] - fn network_policy_helpers_cover_supported_modes() { - assert_eq!( - SandboxNetworkPolicy::unknown().mode(), - SandboxNetworkPolicyMode::Unknown - ); - assert_eq!( - SandboxNetworkPolicy::open().mode(), - SandboxNetworkPolicyMode::Open - ); - assert_eq!( - SandboxNetworkPolicy::blocked().mode(), - SandboxNetworkPolicyMode::Blocked - ); - assert_eq!( - SandboxNetworkPolicy::allow_cidrs(["192.168.0.0/16", "10.0.0.0/8"]).cidrs(), - ["192.168.0.0/16".to_string(), "10.0.0.0/8".to_string()] - ); - assert_eq!( - SandboxNetworkPolicy::essentials_only().mode(), - SandboxNetworkPolicyMode::EssentialsOnly, - ); - } - - #[test] - fn network_policy_deserialization_rejects_empty_cidr_allow_list() { - assert!( - serde_json::from_value::(json!({ - "mode": "cidr_allow_list", - "cidrs": [] - })) - .is_err() - ); - } - - #[test] - fn network_policy_deserialization_rejects_cidrs_for_non_cidr_mode() { - assert!( - serde_json::from_value::(json!({ - "mode": "open", - "cidrs": ["10.0.0.0/8"] - })) - .is_err() - ); - } - - #[test] - fn state_serializes_each_variant_in_snake_case() { - fn check(state: SandboxState, expected: &str) { - assert_eq!( - serde_json::to_value(state).unwrap(), - serde_json::Value::String(expected.to_string()), - ); - } - check(SandboxState::Unknown, "unknown"); - check(SandboxState::Provisioning, "provisioning"); - check(SandboxState::Starting, "starting"); - check(SandboxState::Running, "running"); - check(SandboxState::Stopping, "stopping"); - check(SandboxState::Stopped, "stopped"); - check(SandboxState::Paused, "paused"); - check(SandboxState::Deleting, "deleting"); - check(SandboxState::Deleted, "deleted"); - check(SandboxState::Archived, "archived"); - check(SandboxState::Restoring, "restoring"); - check(SandboxState::Resizing, "resizing"); - check(SandboxState::Error, "error"); + assert_eq!(details.sandbox.provider, SandboxProviderKind::LOCAL); + assert_eq!(details.status.id.as_str(), "host-dir-2f746d70"); + assert!(details.status.labels.is_empty()); + assert!(details.status.created_at.is_none()); } } diff --git a/lib/foundation/fabro-types/src/sandbox_inventory.rs b/lib/foundation/fabro-types/src/sandbox_inventory.rs index 3acef2a78..c22c830e2 100644 --- a/lib/foundation/fabro-types/src/sandbox_inventory.rs +++ b/lib/foundation/fabro-types/src/sandbox_inventory.rs @@ -1,36 +1,14 @@ -use std::collections::BTreeMap; - +use sandbox_driver::SandboxStatus; use serde::{Deserialize, Serialize}; -use crate::{ - SandboxNetwork, SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps, -}; +use crate::SandboxProviderKind; -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +/// One sandbox of fabro's inventory: the provider fabro connected it +/// through, and the status the sandbox driver reports for it. +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct SandboxInfo { - pub provider: SandboxProviderKind, - pub id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub display_name: Option, - pub state: SandboxState, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub native_state: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub image: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub snapshot: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub region: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub web_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_directory: Option, - pub resources: SandboxResources, - #[serde(default)] - pub network: SandboxNetwork, - #[serde(default)] - pub labels: BTreeMap, - pub timestamps: SandboxTimestamps, + pub provider: SandboxProviderKind, + pub status: SandboxStatus, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -45,7 +23,7 @@ pub struct SandboxListMeta { pub provider_errors: Vec, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct SandboxListResponse { pub data: Vec, pub meta: SandboxListMeta, diff --git a/lib/foundation/fabro-types/src/sandbox_services.rs b/lib/foundation/fabro-types/src/sandbox_services.rs index 48f61be32..7212c174b 100644 --- a/lib/foundation/fabro-types/src/sandbox_services.rs +++ b/lib/foundation/fabro-types/src/sandbox_services.rs @@ -1,15 +1,3 @@ -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SandboxServiceDiscoverySource { - Ss, - Procfs, -} - -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct SandboxServiceListMeta { - pub source: SandboxServiceDiscoverySource, -} - #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct SandboxService { pub port: u16, @@ -21,5 +9,4 @@ pub struct SandboxService { #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct SandboxServiceListResponse { pub data: Vec, - pub meta: SandboxServiceListMeta, } diff --git a/lib/foundation/fabro-types/src/settings/run.rs b/lib/foundation/fabro-types/src/settings/run.rs index e9327a147..5dd6143af 100644 --- a/lib/foundation/fabro-types/src/settings/run.rs +++ b/lib/foundation/fabro-types/src/settings/run.rs @@ -7,7 +7,7 @@ //! behavior, and artifact collection. use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration as StdDuration; use fabro_util::shell; @@ -1039,14 +1039,16 @@ impl Default for RunExecutionSettings { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunCheckpointSettings { pub exclude_globs: Vec, - /// When `true`, Fabro-managed run-branch checkpoint commits bypass - /// local Git commit hooks (e.g. `pre-commit`, `commit-msg`). This does - /// not affect Fabro workflow `[[run.hooks]]` or metadata-branch - /// snapshots, which already bypass repository hooks. + /// Accepted for compatibility. Fabro-managed run-branch checkpoint + /// commits never run local Git commit hooks (e.g. `pre-commit`, + /// `commit-msg`): the sandbox driver disables repository hooks on every + /// git command it runs, whatever this field says. Fabro workflow + /// `[[run.hooks]]` are unaffected. #[serde(default)] pub skip_git_hooks: bool, - /// Timeout (ms) for the per-node run-branch checkpoint commit, which runs - /// repository commit hooks unless `skip_git_hooks` is set. Default 30_000. + /// Accepted for compatibility. The per-node run-branch checkpoint commit + /// runs under the sandbox driver's own git command budget now that no + /// repository hook can prolong it. Default 30_000. #[serde(default = "default_checkpoint_commit_timeout_ms")] pub commit_timeout_ms: u64, } @@ -1228,6 +1230,19 @@ impl Default for EnvironmentSettings { } } +/// Why a `local` run has no directory to work in. +#[derive(Debug, thiserror::Error)] +pub enum LocalWorkingDirectoryError { + #[error( + "local environment requires a server-side working directory; configure `environment.cwd = \"/absolute/path\"` on the selected local environment" + )] + MissingCwd, + #[error( + "local environment source_directory does not exist or is not a directory on this server: {0}. Configure `environment.cwd = \"/absolute/path\"` on the selected local environment for remote client/server deployments." + )] + MissingSourceDirectory(PathBuf), +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunEnvironmentSettings { pub id: String, @@ -1258,6 +1273,42 @@ impl RunEnvironmentSettings { } } + /// The environment's variables in source form, for a path with no vault + /// (server preflight): a `{{ secrets.* }}` value keeps its token, and + /// nothing else is left to resolve because `{{ vars.* }}` is substituted + /// at run creation. + #[must_use] + pub fn unresolved_env(&self) -> BTreeMap { + #[expect( + clippy::disallowed_methods, + reason = "preflight has no vault, so an unresolved secret token is carried in source form" + )] + self.env + .iter() + .map(|(key, value)| (key.clone(), value.as_source())) + .collect() + } + + /// The directory a `local` run works in: the environment's `cwd`, or + /// the run's source directory when it exists on this host. + pub fn local_working_directory( + &self, + source_directory: Option<&Path>, + ) -> Result { + if let Some(cwd) = self.cwd.as_deref() { + return Ok(PathBuf::from(cwd)); + } + let Some(source_directory) = source_directory else { + return Err(LocalWorkingDirectoryError::MissingCwd); + }; + if source_directory.is_dir() { + return Ok(source_directory.to_path_buf()); + } + Err(LocalWorkingDirectoryError::MissingSourceDirectory( + source_directory.to_path_buf(), + )) + } + /// Resolve every environment value's `{{ secrets.* }}` tokens via /// `secrets_lookup`. `{{ vars.* }}` is already substituted server-side at /// run creation, so anything still unresolved here fails closed. diff --git a/lib/foundation/fabro-types/src/transcript.rs b/lib/foundation/fabro-types/src/transcript.rs index 4f1a44857..f81384e1e 100644 --- a/lib/foundation/fabro-types/src/transcript.rs +++ b/lib/foundation/fabro-types/src/transcript.rs @@ -156,7 +156,7 @@ pub struct PairMessageRef { /// Canonical durable transcript message. /// /// Named `TranscriptMessage` rather than `Message` to avoid import ambiguity -/// with `fabro_agent::Message` and the lithos request [`Message`]. +/// with pebble's `Message` and the lithos request [`Message`]. /// /// `kind` captures provider/model-role semantics for replay; `source` /// captures audit/UI provenance. Both are required to faithfully reconstruct diff --git a/lib/foundation/fabro-types/tests/sandbox_inventory_serde.rs b/lib/foundation/fabro-types/tests/sandbox_inventory_serde.rs index dc733679d..2dbe0ab23 100644 --- a/lib/foundation/fabro-types/tests/sandbox_inventory_serde.rs +++ b/lib/foundation/fabro-types/tests/sandbox_inventory_serde.rs @@ -1,45 +1,37 @@ -use std::collections::BTreeMap; +use std::time::SystemTime; -use chrono::{TimeZone, Utc}; +use chrono::DateTime; use fabro_types::{ - SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy, - SandboxProviderKind, SandboxProviderLookupError, SandboxResources, SandboxState, - SandboxTimestamps, + SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxProviderKind, + SandboxProviderLookupError, }; +use sandbox_driver::{NetworkPolicy, Resources, SandboxId, SandboxState, SandboxStatus}; use serde_json::json; #[test] -fn sandbox_inventory_serializes_provider_backed_shape() { - let created_at = Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(); +fn sandbox_inventory_serializes_the_provider_and_the_drivers_status() { + let mut status = SandboxStatus::new( + SandboxId::try_new("container-abc123").unwrap(), + SandboxState::Running, + ); + status.name = Some("fabro-run-abc".to_string()); + status.provider_state = "running".to_string(); + status.image = Some("buildpack-deps:noble".to_string()); + let mut resources = Resources::default(); + resources.cpu_cores = Some(2); + resources.memory_mb = Some(4096); + status.resources = Some(resources); + status.network = Some(NetworkPolicy::AllowAll); + status + .labels + .insert("sh.fabro.managed".to_string(), "true".to_string()); + status.created_at = Some(SystemTime::from( + DateTime::parse_from_rfc3339("2026-05-25T12:00:00Z").unwrap(), + )); let response = SandboxListResponse { data: vec![SandboxInfo { - provider: SandboxProviderKind::DOCKER, - id: "container-abc123".to_string(), - display_name: Some("fabro-run-abc".to_string()), - state: SandboxState::Running, - native_state: Some("running".to_string()), - image: Some("buildpack-deps:noble".to_string()), - snapshot: None, - region: None, - web_url: None, - working_directory: Some("/workspace".to_string()), - resources: SandboxResources { - cpu_cores: Some(2.0), - memory_bytes: Some(4 * 1024 * 1024 * 1024), - disk_bytes: None, - }, - network: SandboxNetwork { - egress: SandboxNetworkPolicy::open(), - ingress: SandboxNetworkPolicy::blocked(), - }, - labels: BTreeMap::from([( - "sh.fabro.managed".to_string(), - "true".to_string(), - )]), - timestamps: SandboxTimestamps { - created_at: Some(created_at), - last_activity_at: None, - }, + provider: SandboxProviderKind::DOCKER, + status, }], meta: SandboxListMeta { provider_errors: vec![SandboxProviderLookupError { @@ -54,31 +46,28 @@ fn sandbox_inventory_serializes_provider_backed_shape() { json!({ "data": [{ "provider": "docker", - "id": "container-abc123", - "display_name": "fabro-run-abc", - "state": "running", - "native_state": "running", - "image": "buildpack-deps:noble", - "working_directory": "/workspace", - "resources": { - "cpu_cores": 2.0, - "memory_bytes": 4_294_967_296_u64 - }, - "network": { - "egress": { - "mode": "open", - "cidrs": [] + "status": { + "id": "container-abc123", + "name": "fabro-run-abc", + "state": "running", + "provider_state": "running", + "error_reason": null, + "resources": { + "cpu_cores": 2, + "memory_mb": 4096, + "disk_mb": null, + "gpus": null }, - "ingress": { - "mode": "blocked", - "cidrs": [] - } - }, - "labels": { - "sh.fabro.managed": "true" - }, - "timestamps": { - "created_at": "2026-05-25T12:00:00Z" + "sandbox_kind": null, + "region": null, + "labels": { "sh.fabro.managed": "true" }, + "image": "buildpack-deps:noble", + "snapshot": null, + "network": "allow_all", + "workspace_ownership": null, + "web_url": null, + "created_at": "2026-05-25T12:00:00Z", + "updated_at": null } }], "meta": { @@ -92,28 +81,18 @@ fn sandbox_inventory_serializes_provider_backed_shape() { } #[test] -fn sandbox_inventory_deserializes_when_optional_fields_are_absent() { +fn sandbox_inventory_deserializes_a_status_with_only_its_required_fields() { let info: SandboxInfo = serde_json::from_value(json!({ "provider": "local", - "id": "local:01KSGHGMCFM8W2FHXNMJ7MVY65", - "state": "unknown", - "resources": {}, - "timestamps": {} + "status": { "id": "host-dir-2f746d70", "state": "unknown" } })) .unwrap(); assert_eq!(info.provider, SandboxProviderKind::LOCAL); - assert_eq!(info.id, "local:01KSGHGMCFM8W2FHXNMJ7MVY65"); - assert_eq!(info.state, SandboxState::Unknown); - assert!(info.display_name.is_none()); - assert!(info.native_state.is_none()); - assert!(info.image.is_none()); - assert!(info.snapshot.is_none()); - assert!(info.region.is_none()); - assert!(info.web_url.is_none()); - assert!(info.working_directory.is_none()); - assert_eq!(info.resources, SandboxResources::default()); - assert_eq!(info.network, SandboxNetwork::unknown()); - assert!(info.labels.is_empty()); - assert_eq!(info.timestamps, SandboxTimestamps::default()); + assert_eq!(info.status.id.as_str(), "host-dir-2f746d70"); + assert_eq!(info.status.state, SandboxState::Unknown); + assert!(info.status.name.is_none()); + assert!(info.status.resources.is_none()); + assert!(info.status.network.is_none()); + assert!(info.status.labels.is_empty()); } diff --git a/lib/foundation/fabro-types/tests/sandbox_model_serde.rs b/lib/foundation/fabro-types/tests/sandbox_model_serde.rs index ac10edc13..46648a17e 100644 --- a/lib/foundation/fabro-types/tests/sandbox_model_serde.rs +++ b/lib/foundation/fabro-types/tests/sandbox_model_serde.rs @@ -1,10 +1,8 @@ -use std::collections::BTreeMap; - -use chrono::{TimeZone, Utc}; use fabro_types::{ RunSandbox, RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime, SandboxDetails, - SandboxNetwork, SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps, + SandboxProviderKind, }; +use sandbox_driver::{SandboxId, SandboxState, SandboxStatus}; use serde_json::json; #[test] @@ -72,9 +70,19 @@ fn run_sandbox_ready_requires_instance() { } #[test] -fn sandbox_details_requires_canonical_id_and_working_directory() { +fn sandbox_details_keep_the_record_beside_the_status() { + let mut status = SandboxStatus::new( + SandboxId::try_new("daytona-sandbox-name").unwrap(), + SandboxState::Running, + ); + status.provider_state = "started".to_string(); + status.region = Some("us".to_string()); + status.web_url = Some( + "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9" + .to_string(), + ); let details = SandboxDetails { - sandbox: RunSandboxInstance { + sandbox: RunSandboxInstance { provider: SandboxProviderKind::DAYTONA, image: Some("ubuntu:24.04".to_string()), snapshot: None, @@ -90,24 +98,7 @@ fn sandbox_details_requires_canonical_id_and_working_directory() { primary_repo_link: None, }, }, - state: SandboxState::Running, - native_state: Some("started".to_string()), - region: Some("us".to_string()), - web_url: Some( - "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9" - .to_string(), - ), - resources: SandboxResources { - cpu_cores: Some(2.0), - memory_bytes: Some(4 * 1024 * 1024 * 1024), - disk_bytes: None, - }, - network: SandboxNetwork::unknown(), - labels: BTreeMap::from([("run".to_string(), "abc".to_string())]), - timestamps: SandboxTimestamps { - created_at: Some(Utc.with_ymd_and_hms(2026, 5, 9, 12, 0, 0).unwrap()), - last_activity_at: None, - }, + status, }; let value = serde_json::to_value(&details).unwrap(); @@ -127,12 +118,11 @@ fn sandbox_details_requires_canonical_id_and_working_directory() { "/home/daytona/repos" ); assert_eq!( - value["web_url"], + value["status"]["web_url"], "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9" ); - assert_eq!(value["network"]["egress"]["mode"], "unknown"); - assert_eq!(value["network"]["ingress"]["mode"], "unknown"); - assert!(value.get("name").is_none()); + assert_eq!(value["status"]["provider_state"], "started"); + assert_eq!(value["status"]["network"], serde_json::Value::Null); assert!(value.get("identifier").is_none()); } diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index d299a1219..e57ae9997 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -431,20 +431,22 @@ models/sandbox-details.ts models/sandbox-file-entry.ts models/sandbox-file-list-response.ts models/sandbox-info.ts +models/sandbox-kind.ts models/sandbox-list-meta.ts models/sandbox-list-response.ts -models/sandbox-network-policy-mode.ts +models/sandbox-network-policy-one-of-cidr-allow-list.ts +models/sandbox-network-policy-one-of.ts +models/sandbox-network-policy-one-of1-domain-allow-list.ts +models/sandbox-network-policy-one-of1.ts models/sandbox-network-policy.ts -models/sandbox-network.ts models/sandbox-plugin-settings.ts models/sandbox-provider-lookup-error.ts models/sandbox-resources.ts -models/sandbox-service-discovery-source.ts -models/sandbox-service-list-meta.ts models/sandbox-service-list-response.ts models/sandbox-service.ts models/sandbox-state.ts -models/sandbox-timestamps.ts +models/sandbox-status.ts +models/sandbox-workspace-ownership.ts models/save-query-request.ts models/saved-query.ts models/secret-list-response.ts diff --git a/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts b/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts index 32b365ac2..6ad536c84 100644 --- a/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts +++ b/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts @@ -656,7 +656,7 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf }; }, /** - * Returns provider-neutral details about the sandbox owned by this run, including identity, normalized state, image/snapshot, resources, labels, and timestamps. + * Returns the sandbox owned by this run as fabro\'s record of it plus the sandbox driver\'s status (identity, state, image or snapshot, resources, network policy, labels, and timestamps). * @summary Retrieve Run Sandbox Details * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -1076,7 +1076,7 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Returns provider-neutral details about the sandbox owned by this run, including identity, normalized state, image/snapshot, resources, labels, and timestamps. + * Returns the sandbox owned by this run as fabro\'s record of it plus the sandbox driver\'s status (identity, state, image or snapshot, resources, network policy, labels, and timestamps). * @summary Retrieve Run Sandbox Details * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -1300,7 +1300,7 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration, return localVarFp.putSandboxFile(id, path, body, options).then((request) => request(axios, basePath)); }, /** - * Returns provider-neutral details about the sandbox owned by this run, including identity, normalized state, image/snapshot, resources, labels, and timestamps. + * Returns the sandbox owned by this run as fabro\'s record of it plus the sandbox driver\'s status (identity, state, image or snapshot, resources, network policy, labels, and timestamps). * @summary Retrieve Run Sandbox Details * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -1520,7 +1520,7 @@ export class HumanInTheLoopApi extends BaseAPI { } /** - * Returns provider-neutral details about the sandbox owned by this run, including identity, normalized state, image/snapshot, resources, labels, and timestamps. + * Returns the sandbox owned by this run as fabro\'s record of it plus the sandbox driver\'s status (identity, state, image or snapshot, resources, network policy, labels, and timestamps). * @summary Retrieve Run Sandbox Details * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 0b9aa8449..d199bb683 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -401,20 +401,22 @@ export * from './sandbox-details'; export * from './sandbox-file-entry'; export * from './sandbox-file-list-response'; export * from './sandbox-info'; +export * from './sandbox-kind'; export * from './sandbox-list-meta'; export * from './sandbox-list-response'; -export * from './sandbox-network'; export * from './sandbox-network-policy'; -export * from './sandbox-network-policy-mode'; +export * from './sandbox-network-policy-one-of'; +export * from './sandbox-network-policy-one-of1'; +export * from './sandbox-network-policy-one-of1-domain-allow-list'; +export * from './sandbox-network-policy-one-of-cidr-allow-list'; export * from './sandbox-plugin-settings'; export * from './sandbox-provider-lookup-error'; export * from './sandbox-resources'; export * from './sandbox-service'; -export * from './sandbox-service-discovery-source'; -export * from './sandbox-service-list-meta'; export * from './sandbox-service-list-response'; export * from './sandbox-state'; -export * from './sandbox-timestamps'; +export * from './sandbox-status'; +export * from './sandbox-workspace-ownership'; export * from './save-query-request'; export * from './saved-query'; export * from './secret-list-response'; diff --git a/lib/packages/fabro-api-client/src/models/run-checkpoint-settings.ts b/lib/packages/fabro-api-client/src/models/run-checkpoint-settings.ts index 2342ac774..78ee1b5af 100644 --- a/lib/packages/fabro-api-client/src/models/run-checkpoint-settings.ts +++ b/lib/packages/fabro-api-client/src/models/run-checkpoint-settings.ts @@ -17,7 +17,7 @@ export interface RunCheckpointSettings { 'exclude_globs': Array; /** - * When true, Fabro-managed run-branch checkpoint commits bypass local Git commit hooks. Does not affect Fabro `[[run.hooks]]` or metadata-branch snapshots. Defaults to false. + * Accepted for compatibility. Fabro-managed run-branch checkpoint commits never run local Git commit hooks: the sandbox driver disables repository hooks on every git command it runs. Does not affect Fabro `[[run.hooks]]`. Defaults to false. */ 'skip_git_hooks': boolean; } diff --git a/lib/packages/fabro-api-client/src/models/sandbox-details.ts b/lib/packages/fabro-api-client/src/models/sandbox-details.ts index 63075bcc9..d7e52d9ee 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-details.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-details.ts @@ -18,40 +18,12 @@ import type { RunSandboxInstance } from './run-sandbox-instance'; // May contain unused imports in some cases // @ts-ignore -import type { SandboxNetwork } from './sandbox-network'; -// May contain unused imports in some cases -// @ts-ignore -import type { SandboxResources } from './sandbox-resources'; -// May contain unused imports in some cases -// @ts-ignore -import type { SandboxState } from './sandbox-state'; -// May contain unused imports in some cases -// @ts-ignore -import type { SandboxTimestamps } from './sandbox-timestamps'; +import type { SandboxStatus } from './sandbox-status'; /** - * Provider-neutral details about the sandbox owned by a run. + * The sandbox owned by a run, as fabro\'s record of it and the sandbox driver\'s status. */ export interface SandboxDetails { 'sandbox': RunSandboxInstance; - 'state': SandboxState; - /** - * Original provider state string before normalization. Display/debugging only; UI behavior keys off `state`. - */ - 'native_state'?: string | null; - /** - * Provider region or target. Null for local-style providers. - */ - 'region'?: string | null; - /** - * Provider dashboard URL for this sandbox when available. - */ - 'web_url'?: string | null; - 'resources': SandboxResources; - 'network': SandboxNetwork; - /** - * Provider-reported labels. - */ - 'labels': { [key: string]: string; }; - 'timestamps': SandboxTimestamps; + 'status': SandboxStatus; } diff --git a/lib/packages/fabro-api-client/src/models/sandbox-info.ts b/lib/packages/fabro-api-client/src/models/sandbox-info.ts index 571825e7e..1ef579a63 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-info.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-info.ts @@ -15,63 +15,15 @@ // May contain unused imports in some cases // @ts-ignore -import type { SandboxNetwork } from './sandbox-network'; -// May contain unused imports in some cases -// @ts-ignore -import type { SandboxResources } from './sandbox-resources'; -// May contain unused imports in some cases -// @ts-ignore -import type { SandboxState } from './sandbox-state'; -// May contain unused imports in some cases -// @ts-ignore -import type { SandboxTimestamps } from './sandbox-timestamps'; +import type { SandboxStatus } from './sandbox-status'; /** - * Provider-backed inventory record for a Fabro-managed sandbox. + * One sandbox of fabro\'s provider-backed inventory, as the provider fabro connected it through and the sandbox driver\'s status. */ export interface SandboxInfo { /** * Sandbox provider kind. `local`, `docker`, and `daytona` are bundled with the server; any other value names a sandbox-driver plugin configured under `server.sandbox.providers.`. */ 'provider': string; - /** - * Provider-native sandbox id. - */ - 'id': string; - /** - * Provider display name when distinct from the native id. - */ - 'display_name'?: string | null; - 'state': SandboxState; - /** - * Original provider state string before normalization. Display/debugging only; UI behavior keys off `state`. - */ - 'native_state'?: string | null; - /** - * Provider image when surfaced by the sandbox provider. - */ - 'image'?: string | null; - /** - * Provider snapshot when surfaced by the sandbox provider. - */ - 'snapshot'?: string | null; - /** - * Provider region or target. Null for local-style providers. - */ - 'region'?: string | null; - /** - * Provider dashboard URL for this sandbox when available. - */ - 'web_url'?: string | null; - /** - * Provider-reported or Fabro-default working directory when available. - */ - 'working_directory'?: string | null; - 'resources': SandboxResources; - 'network': SandboxNetwork; - /** - * Provider-reported labels. - */ - 'labels': { [key: string]: string; }; - 'timestamps': SandboxTimestamps; + 'status': SandboxStatus; } diff --git a/lib/packages/fabro-api-client/src/models/sandbox-service-discovery-source.ts b/lib/packages/fabro-api-client/src/models/sandbox-kind.ts similarity index 54% rename from lib/packages/fabro-api-client/src/models/sandbox-service-discovery-source.ts rename to lib/packages/fabro-api-client/src/models/sandbox-kind.ts index 1c6db31fe..eafb2cf53 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-service-discovery-source.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-kind.ts @@ -15,12 +15,13 @@ /** - * Tool or kernel interface used to discover sandbox services. + * The kind of isolation a sandbox was provisioned with, as observed by the driver. Not an isolation guarantee. */ -export const SandboxServiceDiscoverySource = { - SS: 'ss', - PROCFS: 'procfs' +export const SandboxKind = { + CONTAINER: 'container', + VIRTUAL_MACHINE: 'virtual_machine', + UNKNOWN: 'unknown' } as const; -export type SandboxServiceDiscoverySource = typeof SandboxServiceDiscoverySource[keyof typeof SandboxServiceDiscoverySource]; +export type SandboxKind = typeof SandboxKind[keyof typeof SandboxKind]; diff --git a/lib/packages/fabro-api-client/src/models/sandbox-network-policy-mode.ts b/lib/packages/fabro-api-client/src/models/sandbox-network-policy-mode.ts deleted file mode 100644 index 3a208c284..000000000 --- a/lib/packages/fabro-api-client/src/models/sandbox-network-policy-mode.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Provider-neutral public-network policy for one direction. - */ - -export const SandboxNetworkPolicyMode = { - UNKNOWN: 'unknown', - OPEN: 'open', - BLOCKED: 'blocked', - CIDR_ALLOW_LIST: 'cidr_allow_list', - ESSENTIALS_ONLY: 'essentials_only' -} as const; - -export type SandboxNetworkPolicyMode = typeof SandboxNetworkPolicyMode[keyof typeof SandboxNetworkPolicyMode]; diff --git a/lib/packages/fabro-api-client/src/models/sandbox-timestamps.ts b/lib/packages/fabro-api-client/src/models/sandbox-network-policy-one-of-cidr-allow-list.ts similarity index 50% rename from lib/packages/fabro-api-client/src/models/sandbox-timestamps.ts rename to lib/packages/fabro-api-client/src/models/sandbox-network-policy-one-of-cidr-allow-list.ts index da431e4cb..199d64256 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-timestamps.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-network-policy-one-of-cidr-allow-list.ts @@ -14,16 +14,6 @@ -/** - * Lifecycle timestamps for a sandbox. Fields are nullable when the provider does not surface a value. - */ -export interface SandboxTimestamps { - /** - * When the sandbox was created. - */ - 'created_at'?: string; - /** - * Most recent activity timestamp reported by the provider. - */ - 'last_activity_at'?: string; +export interface SandboxNetworkPolicyOneOfCidrAllowList { + 'cidrs': Array; } diff --git a/lib/packages/fabro-api-client/src/models/sandbox-service-list-meta.ts b/lib/packages/fabro-api-client/src/models/sandbox-network-policy-one-of.ts similarity index 63% rename from lib/packages/fabro-api-client/src/models/sandbox-service-list-meta.ts rename to lib/packages/fabro-api-client/src/models/sandbox-network-policy-one-of.ts index c24d6c81d..9e4e0b9f5 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-service-list-meta.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-network-policy-one-of.ts @@ -15,11 +15,8 @@ // May contain unused imports in some cases // @ts-ignore -import type { SandboxServiceDiscoverySource } from './sandbox-service-discovery-source'; +import type { SandboxNetworkPolicyOneOfCidrAllowList } from './sandbox-network-policy-one-of-cidr-allow-list'; -/** - * Metadata about sandbox service discovery. - */ -export interface SandboxServiceListMeta { - 'source': SandboxServiceDiscoverySource; +export interface SandboxNetworkPolicyOneOf { + 'cidr_allow_list': SandboxNetworkPolicyOneOfCidrAllowList; } diff --git a/lib/packages/fabro-api-client/src/models/sandbox-network-policy-one-of1-domain-allow-list.ts b/lib/packages/fabro-api-client/src/models/sandbox-network-policy-one-of1-domain-allow-list.ts new file mode 100644 index 000000000..afd6f7c9b --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-network-policy-one-of1-domain-allow-list.ts @@ -0,0 +1,19 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface SandboxNetworkPolicyOneOf1DomainAllowList { + 'domains': Array; +} diff --git a/lib/packages/fabro-api-client/src/models/sandbox-network.ts b/lib/packages/fabro-api-client/src/models/sandbox-network-policy-one-of1.ts similarity index 60% rename from lib/packages/fabro-api-client/src/models/sandbox-network.ts rename to lib/packages/fabro-api-client/src/models/sandbox-network-policy-one-of1.ts index e378225e2..2ec306f34 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-network.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-network-policy-one-of1.ts @@ -15,12 +15,8 @@ // May contain unused imports in some cases // @ts-ignore -import type { SandboxNetworkPolicy } from './sandbox-network-policy'; +import type { SandboxNetworkPolicyOneOf1DomainAllowList } from './sandbox-network-policy-one-of1-domain-allow-list'; -/** - * Provider-neutral public-network policy for sandbox egress and ingress. - */ -export interface SandboxNetwork { - 'egress': SandboxNetworkPolicy; - 'ingress': SandboxNetworkPolicy; +export interface SandboxNetworkPolicyOneOf1 { + 'domain_allow_list': SandboxNetworkPolicyOneOf1DomainAllowList; } diff --git a/lib/packages/fabro-api-client/src/models/sandbox-network-policy.ts b/lib/packages/fabro-api-client/src/models/sandbox-network-policy.ts index b26016a62..4e3fc59a4 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-network-policy.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-network-policy.ts @@ -15,15 +15,19 @@ // May contain unused imports in some cases // @ts-ignore -import type { SandboxNetworkPolicyMode } from './sandbox-network-policy-mode'; +import type { SandboxNetworkPolicyOneOf } from './sandbox-network-policy-one-of'; +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxNetworkPolicyOneOf1 } from './sandbox-network-policy-one-of1'; +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxNetworkPolicyOneOf1DomainAllowList } from './sandbox-network-policy-one-of1-domain-allow-list'; +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxNetworkPolicyOneOfCidrAllowList } from './sandbox-network-policy-one-of-cidr-allow-list'; /** - * Public-network policy for one direction. + * @type SandboxNetworkPolicy + * The network policy in force for a sandbox. A policy without parameters is its name; an allow list carries its entries. */ -export interface SandboxNetworkPolicy { - 'mode': SandboxNetworkPolicyMode; - /** - * CIDR entries when `mode` is `cidr_allow_list`; empty for other modes. - */ - 'cidrs': Array; -} +export type SandboxNetworkPolicy = SandboxNetworkPolicyOneOf | SandboxNetworkPolicyOneOf1 | string; diff --git a/lib/packages/fabro-api-client/src/models/sandbox-resources.ts b/lib/packages/fabro-api-client/src/models/sandbox-resources.ts index f23c87f44..dd6b5ca24 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-resources.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-resources.ts @@ -15,19 +15,11 @@ /** - * Resource configuration for a sandbox. Fields are nullable when the provider does not surface a value or no limit is configured. + * Compute resources of a sandbox, in the units the field names give. A field is null when the provider does not report a value or applies its default. */ export interface SandboxResources { - /** - * Configured CPU cores. Null when unavailable. - */ - 'cpu_cores'?: number; - /** - * Memory limit in bytes. Null when unavailable or unlimited. - */ - 'memory_bytes'?: number; - /** - * Disk size in bytes. Null when unavailable. - */ - 'disk_bytes'?: number; + 'cpu_cores'?: number | null; + 'memory_mb'?: number | null; + 'disk_mb'?: number | null; + 'gpus'?: number | null; } diff --git a/lib/packages/fabro-api-client/src/models/sandbox-service-list-response.ts b/lib/packages/fabro-api-client/src/models/sandbox-service-list-response.ts index a41676b3b..e68ad21df 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-service-list-response.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-service-list-response.ts @@ -16,14 +16,10 @@ // May contain unused imports in some cases // @ts-ignore import type { SandboxService } from './sandbox-service'; -// May contain unused imports in some cases -// @ts-ignore -import type { SandboxServiceListMeta } from './sandbox-service-list-meta'; /** * Non-paginated list of listening TCP services in a run sandbox. */ export interface SandboxServiceListResponse { 'data': Array; - 'meta': SandboxServiceListMeta; } diff --git a/lib/packages/fabro-api-client/src/models/sandbox-service.ts b/lib/packages/fabro-api-client/src/models/sandbox-service.ts index 9e89797e7..d0089d47c 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-service.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-service.ts @@ -15,7 +15,7 @@ /** - * A listening TCP service discovered inside a run sandbox. + * A TCP port a process inside a run sandbox listens on, as the sandbox driver reports it. */ export interface SandboxService { /** @@ -23,11 +23,11 @@ export interface SandboxService { */ 'port': number; /** - * Local bind addresses discovered from `ss` or `/proc/net/tcp*`. + * Local bind addresses the sandbox reports for the port. */ 'addresses': Array; /** - * Visible process summaries when available. Empty when the sandbox only supports `/proc/net/tcp*` discovery. + * The listening processes, when the sandbox can name them (`node`, or `pid=1234`). Empty when it cannot. */ 'processes': Array; /** diff --git a/lib/packages/fabro-api-client/src/models/sandbox-state.ts b/lib/packages/fabro-api-client/src/models/sandbox-state.ts index c40419d2f..6e683f5db 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-state.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-state.ts @@ -15,23 +15,28 @@ /** - * Normalized sandbox lifecycle state used by the control plane and UI. The original provider-specific state string is preserved in `native_state`. + * The sandbox driver\'s lifecycle state for a sandbox. The provider\'s own state string is preserved in `SandboxStatus.provider_state`. A reader must treat a value it does not know as `unknown`. */ export const SandboxState = { - UNKNOWN: 'unknown', - PROVISIONING: 'provisioning', + CREATING: 'creating', STARTING: 'starting', RUNNING: 'running', STOPPING: 'stopping', STOPPED: 'stopped', + PAUSING: 'pausing', PAUSED: 'paused', - DELETING: 'deleting', - DELETED: 'deleted', + RESUMING: 'resuming', + ARCHIVING: 'archiving', ARCHIVED: 'archived', RESTORING: 'restoring', RESIZING: 'resizing', - ERROR: 'error' + FORKING: 'forking', + SNAPSHOTTING: 'snapshotting', + DELETING: 'deleting', + DELETED: 'deleted', + ERROR: 'error', + UNKNOWN: 'unknown' } as const; export type SandboxState = typeof SandboxState[keyof typeof SandboxState]; diff --git a/lib/packages/fabro-api-client/src/models/sandbox-status.ts b/lib/packages/fabro-api-client/src/models/sandbox-status.ts new file mode 100644 index 000000000..defff9a80 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-status.ts @@ -0,0 +1,79 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxKind } from './sandbox-kind'; +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxNetworkPolicy } from './sandbox-network-policy'; +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxResources } from './sandbox-resources'; +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxState } from './sandbox-state'; +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxWorkspaceOwnership } from './sandbox-workspace-ownership'; + +/** + * What the sandbox driver reports about a sandbox. Only `id` and `state` are always present; every other field is null or empty when the provider does not report it. + */ +export interface SandboxStatus { + /** + * The provider\'s stable identifier for the sandbox. + */ + 'id': string; + /** + * The provider\'s display name, which is not the stable identifier. + */ + 'name'?: string | null; + 'state': SandboxState; + /** + * The provider\'s own state string, for display and debugging. + */ + 'provider_state'?: string; + 'error_reason'?: string | null; + 'resources'?: SandboxResources | null; + 'sandbox_kind'?: SandboxKind | null; + /** + * The provider region or target the sandbox runs in. + */ + 'region'?: string | null; + /** + * Provider-stored labels, including fabro\'s ownership labels. + */ + 'labels'?: { [key: string]: string; }; + /** + * The image the sandbox runs, when the provider knows it (a Docker container\'s image reference). + */ + 'image'?: string | null; + /** + * The snapshot the sandbox was created from, when the provider knows it (a Daytona snapshot name). + */ + 'snapshot'?: string | null; + 'network'?: SandboxNetworkPolicy | null; + 'workspace_ownership'?: SandboxWorkspaceOwnership | null; + /** + * The provider\'s console page for the sandbox, when it has one. + */ + 'web_url'?: string | null; + 'created_at'?: string | null; + /** + * The provider\'s most recent activity or update timestamp for the sandbox. + */ + 'updated_at'?: string | null; +} diff --git a/lib/packages/fabro-api-client/src/models/sandbox-workspace-ownership.ts b/lib/packages/fabro-api-client/src/models/sandbox-workspace-ownership.ts new file mode 100644 index 000000000..ad5814a45 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-workspace-ownership.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Who owns a local sandbox\'s workspace directory. `designated` is a caller-owned directory that deleting the sandbox never touches; `managed` is a directory the driver created and removes. + */ + +export const SandboxWorkspaceOwnership = { + DESIGNATED: 'designated', + MANAGED: 'managed' +} as const; + +export type SandboxWorkspaceOwnership = typeof SandboxWorkspaceOwnership[keyof typeof SandboxWorkspaceOwnership];