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}"