From 5e04495740f4c7802c3805f9fe3bbb4cbb3ebddc Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 11 Sep 2026 10:16:24 -0600 Subject: [PATCH] Make RunSandbox pebble's Environment and pin pebble Add pebble-agent and pebble-coding-agent as git dependencies pinned to the pebble branch that carries the live exec output sink and max_turns, and align the shared crate versions with lithos-llm's lockfile. RunSandbox implements pebble_coding_agent::environment::Environment directly: rename_file over the driver's rename with a pre-created destination parent, grep rendered as path:line:text, pebble's glob grammar enforced before the driver sees a pattern, directory listings in tree order, and exec over the streaming path with the output sink mapped onto the driver's OutputSink. Pebble's EnvironmentContract runs against the Host provider in the unit tests and against Docker in the live suite. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 55 ++- Cargo.toml | 8 + lib/components/fabro-sandbox/Cargo.toml | 2 + .../fabro-sandbox/src/driver_sandbox.rs | 5 +- .../fabro-sandbox/src/environment.rs | 431 ++++++++++++++++++ lib/components/fabro-sandbox/src/lib.rs | 1 + .../fabro-sandbox/tests/docker_streaming.rs | 52 +++ .../tests/it/daytona_integration.rs | 9 +- 8 files changed, 548 insertions(+), 15 deletions(-) create mode 100644 lib/components/fabro-sandbox/src/environment.rs diff --git a/Cargo.lock b/Cargo.lock index 749b34e4f..264413f7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2952,6 +2952,7 @@ dependencies = [ "futures", "hex", "hmac 0.12.1", + "pebble-coding-agent", "reqwest 0.13.4", "sandbox-driver", "sandbox-driver-daytona", @@ -5978,6 +5979,43 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "pebble-agent" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/pebble?rev=09b17c1b9e6cf7b2f82657d2080f82c73a0064ef#09b17c1b9e6cf7b2f82657d2080f82c73a0064ef" +dependencies = [ + "async-trait", + "futures-util", + "lithos-llm", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "pebble-coding-agent" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/pebble?rev=09b17c1b9e6cf7b2f82657d2080f82c73a0064ef#09b17c1b9e6cf7b2f82657d2080f82c73a0064ef" +dependencies = [ + "async-trait", + "futures-util", + "lithos-llm", + "pebble-agent", + "rustix", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + [[package]] name = "pem" version = "3.0.6" @@ -7599,7 +7637,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", ] @@ -8186,7 +8224,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.59.0", @@ -8449,9 +8487,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -8459,6 +8497,7 @@ dependencies = [ "futures-sink", "futures-util", "hashbrown 0.15.5", + "libc", "pin-project-lite", "tokio", ] @@ -9011,13 +9050,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.3.4", "js-sys", - "serde_core", + "serde", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index fb11c0e96..da9564d49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,6 +106,14 @@ futures-util = "0.3" # 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. +# 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 that added the live exec output sink and `max_turns` +# (`fabro-exec-sink-max-turns`); 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 = "09b17c1b9e6cf7b2f82657d2080f82c73a0064ef" } +pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "09b17c1b9e6cf7b2f82657d2080f82c73a0064ef" } 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" } diff --git a/lib/components/fabro-sandbox/Cargo.toml b/lib/components/fabro-sandbox/Cargo.toml index 77523695b..7f8524ab2 100644 --- a/lib/components/fabro-sandbox/Cargo.toml +++ b/lib/components/fabro-sandbox/Cargo.toml @@ -26,6 +26,7 @@ sandbox-driver-docker-config.workspace = true sandbox-driver-daytona.workspace = true sandbox-driver-daytona-config.workspace = true sandbox-driver-testing = { workspace = true, optional = true } +pebble-coding-agent.workspace = true anyhow.workspace = true async-trait.workspace = true thiserror.workspace = true @@ -55,6 +56,7 @@ chrono = { workspace = true } [dev-dependencies] fabro-github = { path = "../fabro-github", features = ["test-support"] } +pebble-coding-agent = { workspace = true, features = ["test-util"] } sandbox-driver-testing.workspace = true tokio = { workspace = true, features = ["test-util", "macros"] } tempfile = "3" diff --git a/lib/components/fabro-sandbox/src/driver_sandbox.rs b/lib/components/fabro-sandbox/src/driver_sandbox.rs index ef52b833b..99765f410 100644 --- a/lib/components/fabro-sandbox/src/driver_sandbox.rs +++ b/lib/components/fabro-sandbox/src/driver_sandbox.rs @@ -713,6 +713,9 @@ impl RunSandbox { } /// 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, @@ -1242,7 +1245,7 @@ mod tests { assert!(missing.to_string().contains("does not exist"), "{missing}"); let read = f .sandbox - .read_file("nonexistent.txt", None, None) + .read_file_text("nonexistent.txt") .await .unwrap_err(); assert!(read.is_not_found(), "{read}"); diff --git a/lib/components/fabro-sandbox/src/environment.rs b/lib/components/fabro-sandbox/src/environment.rs new file mode 100644 index 000000000..5b603e9bd --- /dev/null +++ b/lib/components/fabro-sandbox/src/environment.rs @@ -0,0 +1,431 @@ +//! [`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 +//! 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. + +use std::sync::Arc; + +use async_trait::async_trait; +use fabro_types::{CommandOutputStream, CommandTermination}; +use fabro_util::workspace_glob::WorkspaceGlob; +use pebble_coding_agent::environment::{ + DirEntry, EnvResult, Environment, EnvironmentError, EnvironmentErrorKind, ExecOutcome, + ExecOutputSink, ExecOutputStream, ExecRequest, ExecResult, GrepOptions, +}; +use pebble_coding_agent::events::CommandTermination as PebbleTermination; +use pebble_coding_agent::tools::OutputCaptureStats as PebbleCaptureStats; +use sandbox_driver::FileKind; + +use crate::driver_sandbox::RunSandbox; +use crate::sandbox::{self, CommandOutputCallback, ExecStreamingRequest}; + +#[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: by name within each directory. Comparing the paths + // segment by segment is that order. + entries.sort_by(|left, right| left.name.split('/').cmp(right.name.split('/'))); + 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 here rather than by the run sandbox's own glob so the + // reason reaches the model in pebble's words, and so the patterns + // pebble rejects (a trailing `/`, a `/` or wildcard inside `[...]`) + // are rejected even though fabro's glob would accept them. + if let Err(reason) = validate_pebble_glob(pattern) { + return Err(EnvironmentError::new( + EnvironmentErrorKind::InvalidInput, + format!("Invalid glob pattern {pattern:?}: {reason}"), + )); + } + if let Err(error) = WorkspaceGlob::try_new(pattern) { + return Err(EnvironmentError::new( + EnvironmentErrorKind::InvalidInput, + format!("Invalid glob pattern {pattern:?}: {error}"), + )); + } + 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: pebble_termination(streaming.result.termination), + duration_ms: streaming.result.duration_ms, + }, + streams_separated: streaming.streams_separated, + stdout_capture: capture_stats(streaming.stdout_capture), + stderr_capture: capture_stats(streaming.stderr_capture), + }) + } +} + +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 pattern names files, so one that ends with `/` is a mistake rather than a +/// directory; and `/`, `*`, or `?` inside a character class never mean what a +/// model meant by them. The messages are pebble's own, so a model corrects +/// itself the same way wherever pebble runs. +fn validate_pebble_glob(pattern: &str) -> Result<(), &'static str> { + let trimmed = pattern.strip_prefix("./").unwrap_or(pattern); + if trimmed.is_empty() { + return Err("pattern cannot be empty"); + } + if trimmed.ends_with('/') { + return Err( + "pattern ends with \"/\"; glob matches files, drop the trailing slash or add a \ + filename pattern", + ); + } + let mut in_class = false; + for character in trimmed.chars() { + match (in_class, character) { + (false, '[') => in_class = true, + (true, ']') => in_class = false, + (true, '/') => return Err("a \"/\" cannot appear inside a character class"), + (true, '*' | '?') => { + return Err("wildcards are not valid inside a character class"); + } + _ => {} + } + } + if in_class { + return Err("pattern has an unclosed character class"); + } + Ok(()) +} + +/// 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(()) }) + }) +} + +fn pebble_termination(termination: CommandTermination) -> PebbleTermination { + match termination { + CommandTermination::Exited => PebbleTermination::Exited, + CommandTermination::TimedOut => PebbleTermination::TimedOut, + CommandTermination::Cancelled => PebbleTermination::Cancelled, + } +} + +fn capture_stats(stats: sandbox::OutputCaptureStats) -> PebbleCaptureStats { + PebbleCaptureStats { + observed_bytes: stats.observed_bytes, + retained_bytes: stats.retained_bytes, + omitted_bytes: stats.omitted_bytes, + } +} + +/// 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 + }; + 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 pebbles_glob_grammar_is_enforced_before_the_driver() { + for pattern in ["nested/", "[a/]", "[a*]", "[ab"] { + assert!(validate_pebble_glob(pattern).is_err(), "{pattern}"); + } + for pattern in ["**/*.txt", "?.txt", "[ab].txt", "./src/**"] { + assert!(validate_pebble_glob(pattern).is_ok(), "{pattern}"); + } + } + + #[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/lib.rs b/lib/components/fabro-sandbox/src/lib.rs index 22bf75e2f..29a0d3042 100644 --- a/lib/components/fabro-sandbox/src/lib.rs +++ b/lib/components/fabro-sandbox/src/lib.rs @@ -18,6 +18,7 @@ pub mod details; pub mod driver; pub mod driver_sandbox; +pub mod environment; pub mod exec; diff --git a/lib/components/fabro-sandbox/tests/docker_streaming.rs b/lib/components/fabro-sandbox/tests/docker_streaming.rs index 07b0e082a..7c20d39a8 100644 --- a/lib/components/fabro-sandbox/tests/docker_streaming.rs +++ b/lib/components/fabro-sandbox/tests/docker_streaming.rs @@ -484,3 +484,55 @@ async fn docker_runtime_directory_is_private_and_outside_workspace() { ); assert_eq!(readback.expect("runtime blob should be readable"), "{}"); } + +/// The run sandbox over Docker is the `Environment` pebble's coding agent runs +/// in for a Docker run, so it has to pass pebble's own contract there too: +/// the Host proof in `environment.rs` covers the mapping, this covers the +/// provider (derived search over `rg`/`grep`, `mv` for a move, a merged or +/// separated stream pair). +#[tokio::test] +#[ignore = "requires real Docker container lifecycle; run explicitly when changing the pebble Environment mapping"] +async fn docker_sandbox_satisfies_pebbles_environment_contract() { + use pebble_coding_agent::test_support::EnvironmentContract; + + let image = "buildpack-deps:noble"; + if !docker_image_available(image).await { + return; + } + + let sandbox = provider_sandbox( + SandboxProviderKind::DOCKER, + &ProviderAccess::default(), + SandboxOptions { + image: Some(image.to_string()), + skip_clone: true, + ..SandboxOptions::default() + }, + None, + None, + None, + None, + None, + None, + ) + .await + .expect("docker sandbox should construct"); + sandbox + .initialize() + .await + .expect("docker sandbox should initialize"); + + let contract = EnvironmentContract::new(&sandbox, "pebble-contract") + .with_operation_timeout(std::time::Duration::from_secs(60)); + let outcome = async { + contract.verify_files().await?; + contract.verify_search().await?; + contract.verify_commands().await + } + .await; + sandbox + .cleanup() + .await + .expect("docker sandbox should clean up"); + outcome.expect("the Docker sandbox satisfies pebble's environment contract"); +} diff --git a/lib/components/fabro-workflow/tests/it/daytona_integration.rs b/lib/components/fabro-workflow/tests/it/daytona_integration.rs index 16b20d610..0c9889aaa 100644 --- a/lib/components/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/components/fabro-workflow/tests/it/daytona_integration.rs @@ -383,7 +383,7 @@ async fn daytona_file_round_trip() { assert!(env.file_exists(test_path).await.unwrap()); // Read - let read_back = env.read_file(test_path, None, None).await.unwrap(); + let read_back = env.read_file_text(test_path).await.unwrap(); assert!(read_back.contains(content)); // Delete @@ -500,7 +500,7 @@ async fn daytona_artifact_sync_uploads_and_rewrites_pointer() { "artifact file should exist in Daytona sandbox at {remote_path}" ); - let remote_content = env.read_file(remote_path, None, None).await.unwrap(); + let remote_content = env.read_file_text(remote_path).await.unwrap(); assert!( remote_content.len() > 100 * 1024, "remote artifact should be >100KB, got {} bytes", @@ -1602,10 +1602,7 @@ async fn daytona_cp_upload_download_round_trip() { env.file_exists("cp_test_upload.txt").await.unwrap(), "uploaded file should exist in the sandbox" ); - let remote_content = env - .read_file("cp_test_upload.txt", None, None) - .await - .unwrap(); + let remote_content = env.read_file_text("cp_test_upload.txt").await.unwrap(); assert!( remote_content.contains("hello from fabro cp e2e test"), "expected uploaded content in sandbox, got: {remote_content}"