mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-14 23:22:51 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
33cdbf6afc
commit
5e04495740
8 changed files with 548 additions and 15 deletions
55
Cargo.lock
generated
55
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
431
lib/components/fabro-sandbox/src/environment.rs
Normal file
431
lib/components/fabro-sandbox/src/environment.rs
Normal file
|
|
@ -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<Vec<u8>> {
|
||||
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<bool> {
|
||||
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<usize>) -> EnvResult<Vec<DirEntry>> {
|
||||
let mut entries: Vec<DirEntry> = 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<Vec<String>> {
|
||||
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<Vec<String>> {
|
||||
// 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<ExecOutcome> {
|
||||
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::<Vec<_>>()
|
||||
.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<u8>| {
|
||||
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<String> = 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ pub mod details;
|
|||
|
||||
pub mod driver;
|
||||
pub mod driver_sandbox;
|
||||
pub mod environment;
|
||||
|
||||
pub mod exec;
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue