Merge pull request #873 from fabro-sh/sandbox-driver-64c14b8

Pin sandbox-driver main 64c14b8 and surface dropped Daytona output
This commit is contained in:
Bryan Helmkamp 2026-09-14 16:31:07 -04:00 • committed by GitHub
commit 7d5e696c18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 340 additions and 37 deletions

16
Cargo.lock generated
View file

@ -6865,7 +6865,7 @@ dependencies = [
[[package]]
name = "sandbox-driver"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=ddb32e19e763299319ecf6aebc8961298db80c0c#ddb32e19e763299319ecf6aebc8961298db80c0c"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=64c14b89d078a4b34d1555092ad01d41541f7a7d#64c14b89d078a4b34d1555092ad01d41541f7a7d"
dependencies = [
"async-trait",
"globset",
@ -6882,7 +6882,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-daytona"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=ddb32e19e763299319ecf6aebc8961298db80c0c#ddb32e19e763299319ecf6aebc8961298db80c0c"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=64c14b89d078a4b34d1555092ad01d41541f7a7d#64c14b89d078a4b34d1555092ad01d41541f7a7d"
dependencies = [
"anyhow",
"async-trait",
@ -6909,7 +6909,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-daytona-config"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=ddb32e19e763299319ecf6aebc8961298db80c0c#ddb32e19e763299319ecf6aebc8961298db80c0c"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=64c14b89d078a4b34d1555092ad01d41541f7a7d#64c14b89d078a4b34d1555092ad01d41541f7a7d"
dependencies = [
"sandbox-driver-docker-config",
"serde",
@ -6919,7 +6919,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-docker"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=ddb32e19e763299319ecf6aebc8961298db80c0c#ddb32e19e763299319ecf6aebc8961298db80c0c"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=64c14b89d078a4b34d1555092ad01d41541f7a7d#64c14b89d078a4b34d1555092ad01d41541f7a7d"
dependencies = [
"anyhow",
"async-trait",
@ -6940,7 +6940,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-docker-config"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=ddb32e19e763299319ecf6aebc8961298db80c0c#ddb32e19e763299319ecf6aebc8961298db80c0c"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=64c14b89d078a4b34d1555092ad01d41541f7a7d#64c14b89d078a4b34d1555092ad01d41541f7a7d"
dependencies = [
"serde",
"serde_json",
@ -6949,7 +6949,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-host"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=ddb32e19e763299319ecf6aebc8961298db80c0c#ddb32e19e763299319ecf6aebc8961298db80c0c"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=64c14b89d078a4b34d1555092ad01d41541f7a7d#64c14b89d078a4b34d1555092ad01d41541f7a7d"
dependencies = [
"anyhow",
"async-trait",
@ -6967,7 +6967,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-protocol"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=ddb32e19e763299319ecf6aebc8961298db80c0c#ddb32e19e763299319ecf6aebc8961298db80c0c"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=64c14b89d078a4b34d1555092ad01d41541f7a7d#64c14b89d078a4b34d1555092ad01d41541f7a7d"
dependencies = [
"async-trait",
"base64",
@ -6984,7 +6984,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-testing"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=ddb32e19e763299319ecf6aebc8961298db80c0c#ddb32e19e763299319ecf6aebc8961298db80c0c"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=64c14b89d078a4b34d1555092ad01d41541f7a7d#64c14b89d078a4b34d1555092ad01d41541f7a7d"
dependencies = [
"async-trait",
"sandbox-driver",

View file

@ -106,16 +106,18 @@ futures-util = "0.3"
# `main` (the `section-4-driver-items` work merged in #20; #21 gave the
# protocol crate's `PluginSupervisor` numbered generations, a health probe
# before a generation serves, and a refusal of a replacement that reports
# another resource namespace). The CI plugin job installs the driver
# another resource namespace; #22 made the Daytona exec decoder resync on a
# torn frame and report what it dropped as `ExecStreamingResult::output_loss`
# instead of failing the command). 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 = "ddb32e19e763299319ecf6aebc8961298db80c0c" }
sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "ddb32e19e763299319ecf6aebc8961298db80c0c" }
sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "ddb32e19e763299319ecf6aebc8961298db80c0c" }
sandbox-driver-docker = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "ddb32e19e763299319ecf6aebc8961298db80c0c" }
sandbox-driver-docker-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "ddb32e19e763299319ecf6aebc8961298db80c0c" }
sandbox-driver-daytona = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "ddb32e19e763299319ecf6aebc8961298db80c0c" }
sandbox-driver-daytona-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "ddb32e19e763299319ecf6aebc8961298db80c0c" }
sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "ddb32e19e763299319ecf6aebc8961298db80c0c" }
sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "64c14b89d078a4b34d1555092ad01d41541f7a7d" }
sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "64c14b89d078a4b34d1555092ad01d41541f7a7d" }
sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "64c14b89d078a4b34d1555092ad01d41541f7a7d" }
sandbox-driver-docker = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "64c14b89d078a4b34d1555092ad01d41541f7a7d" }
sandbox-driver-docker-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "64c14b89d078a4b34d1555092ad01d41541f7a7d" }
sandbox-driver-daytona = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "64c14b89d078a4b34d1555092ad01d41541f7a7d" }
sandbox-driver-daytona-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "64c14b89d078a4b34d1555092ad01d41541f7a7d" }
sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "64c14b89d078a4b34d1555092ad01d41541f7a7d" }
# pebble: the coding agent loop fabro runs its agent stages, Ask Fabro
# sessions, hook evaluators, and `fabro exec` on. Pinned by rev to pebble
# `main`. Pebble owns the `PortRoutes` trait fabro implements over its run

View file

@ -11,6 +11,10 @@
//! 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.
//! Output a provider lost on its own transport
//! ([`ExecStreamingResult::output_loss`]) has no slot in pebble's contract,
//! so it is written where the model already reads: one line at the end of
//! stderr.
use std::sync::Arc;
use std::time::Duration;
@ -21,7 +25,10 @@ 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 sandbox_driver::{
ExecControls, ExecSpec, ExecStreamingResult, FileKind, OutputLoss, OutputSink, OutputStream,
};
use tracing::warn;
use crate::driver_sandbox::RunSandbox;
use crate::exec::{ExecResultExt as _, command_termination, program_exit_code};
@ -214,28 +221,79 @@ impl Environment for RunSandbox {
};
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,
),
})
Ok(exec_outcome(
streaming,
output_bytes_cap,
program_name(command),
))
}
}
/// Pebble's outcome for a finished command: the driver's result read the way
/// fabro reads it, plus the provider's own output loss written where the
/// model reads stderr.
///
/// A provider whose transport tore (Daytona's text-only toolbox) completes
/// the command and reports what it discarded in
/// [`ExecStreamingResult::output_loss`] rather than failing it. The frames
/// are gone, the stream they belonged to is unknown, and the counts are of
/// encoded bytes, so they cannot be folded into either stream's capture
/// accounting without guessing; the loss is one line at the end of stderr,
/// where the model and the run log see it, and one log event for the
/// operator. The driver's `truncated` flags on the captures already say the
/// counts undercount.
fn exec_outcome(
streaming: ExecStreamingResult,
output_bytes_cap: Option<usize>,
program: &str,
) -> ExecOutcome {
let loss = streaming.output_loss;
let result = streaming.result;
let mut stderr = result.stderr_lossy();
if loss.is_lossy() {
warn!(
program = %program,
dropped_frames = loss.dropped_frames,
dropped_bytes = loss.dropped_bytes,
"Sandbox provider dropped command output"
);
if !stderr.is_empty() && !stderr.ends_with('\n') {
stderr.push('\n');
}
stderr.push_str(&output_loss_line(loss));
}
ExecOutcome {
result: ExecResult {
stdout: result.stdout_lossy(),
stderr,
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),
}
}
/// The line stderr ends with when the provider dropped output.
fn output_loss_line(loss: OutputLoss) -> String {
format!(
"[sandbox] {} output frame(s), {} bytes dropped by the provider\n",
loss.dropped_frames, loss.dropped_bytes
)
}
/// Bytes of a command's first word a log event carries.
const PROGRAM_NAME_BYTES: usize = 64;
/// The word a command starts with, bounded, for a log event that must not
/// carry the command itself.
fn program_name(command: &str) -> &str {
let word = command.split_whitespace().next().unwrap_or_default();
&word[..word.floor_char_boundary(PROGRAM_NAME_BYTES)]
}
impl RunSandbox {
/// A caller path as the driver will see it: fabro's working directory
/// applied where fabro applies it, and nothing more.
@ -297,10 +355,19 @@ fn environment_error(message: &str, error: crate::Error) -> EnvironmentError {
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use fabro_types::SandboxProviderKind;
use pebble_coding_agent::test_support::EnvironmentContract;
use sandbox_driver::{
Capabilities, Exec, Filesystem, PlatformInfo, Sandbox, SandboxId, SandboxStatus, Search,
SpawnSpec, StdioProcess, Termination,
};
use sandbox_driver_testing::ScriptedSandbox;
use super::*;
use crate::local_sandbox;
use crate::test_support::{MockSandbox, exec_result};
/// The run sandbox over the driver's Host provider, in a directory that
/// goes away with the test.
@ -370,4 +437,238 @@ mod tests {
assert_eq!(parent_directory("/b.txt"), Some("/"));
assert_eq!(parent_directory("b.txt"), None);
}
fn request(command: &str) -> ExecRequest<'_> {
ExecRequest {
command,
timeout_ms: Some(10_000),
working_dir: None,
env_vars: None,
cancel_token: None,
output_bytes_cap: None,
output_sink: None,
}
}
fn output_loss(dropped_frames: u64, dropped_bytes: u64) -> OutputLoss {
let mut loss = OutputLoss::default();
loss.dropped_frames = dropped_frames;
loss.dropped_bytes = dropped_bytes;
loss
}
#[tokio::test]
async fn a_lossless_command_hands_back_stderr_as_the_provider_wrote_it() {
let mock = MockSandbox {
exec_result: exec_result(
"built\n",
"warning: unused\n",
Some(0),
Termination::Exited,
7,
),
..MockSandbox::linux()
};
let outcome = Environment::exec(&*mock.sandbox(), request("cargo build"))
.await
.expect("a scripted command");
assert_eq!(outcome.result.stdout, "built\n");
assert_eq!(outcome.result.stderr, "warning: unused\n");
assert_eq!(outcome.result.exit_code, Some(0));
assert_eq!(
outcome.stderr_capture.observed_bytes,
"warning: unused\n".len()
);
}
#[test]
fn a_provider_output_loss_ends_stderr_with_one_line() {
let mut streaming = ExecStreamingResult::new(exec_result(
"built\n",
"warning: torn",
Some(1),
Termination::Exited,
7,
));
streaming.output_loss = output_loss(2, 4096);
let outcome = exec_outcome(streaming, Some(1024), "cargo");
assert_eq!(outcome.result.stdout, "built\n");
assert_eq!(
outcome.result.stderr,
"warning: torn\n[sandbox] 2 output frame(s), 4096 bytes dropped by the provider\n"
);
assert_eq!(outcome.result.exit_code, Some(1));
assert_eq!(outcome.result.duration_ms, 7);
// The loss is not folded into either stream's accounting.
assert_eq!(outcome.stdout_capture.observed_bytes, "built\n".len());
assert_eq!(outcome.stderr_capture.observed_bytes, "warning: torn".len());
}
#[test]
fn a_provider_output_loss_with_no_stderr_is_the_line_alone() {
let mut streaming =
ExecStreamingResult::new(exec_result("", "", Some(0), Termination::Exited, 1));
streaming.output_loss = output_loss(1, 80);
let outcome = exec_outcome(streaming, None, "sh");
assert_eq!(
outcome.result.stderr,
"[sandbox] 1 output frame(s), 80 bytes dropped by the provider\n"
);
}
#[test]
fn a_log_event_names_the_first_word_of_a_command_bounded() {
assert_eq!(program_name("cargo build --release"), "cargo");
assert_eq!(program_name(" \n ls"), "ls");
assert_eq!(program_name(""), "");
let long = "x".repeat(PROGRAM_NAME_BYTES + 10);
assert_eq!(program_name(&long).len(), PROGRAM_NAME_BYTES);
let multibyte = "é".repeat(PROGRAM_NAME_BYTES);
assert!(program_name(&multibyte).len() <= PROGRAM_NAME_BYTES);
}
/// The driver's scripted sandbox with an exec facet that reports a
/// provider output loss on every command, as Daytona does after a torn
/// frame. The scripted double itself has no knob for the loss.
struct LossySandbox {
inner: Arc<ScriptedSandbox>,
exec: LossyExec,
}
struct LossyExec {
inner: Arc<ScriptedSandbox>,
loss: OutputLoss,
}
impl LossySandbox {
fn new(inner: Arc<ScriptedSandbox>, loss: OutputLoss) -> Self {
Self {
exec: LossyExec {
inner: Arc::clone(&inner),
loss,
},
inner,
}
}
}
#[async_trait]
impl Exec for LossyExec {
async fn run(&self, spec: &ExecSpec) -> sandbox_driver::Result<sandbox_driver::ExecResult> {
self.inner.scripted_exec().run(spec).await
}
async fn run_streaming(
&self,
spec: &ExecSpec,
controls: ExecControls,
) -> sandbox_driver::Result<ExecStreamingResult> {
let mut streaming = self
.inner
.scripted_exec()
.run_streaming(spec, controls)
.await?;
streaming.output_loss = self.loss;
streaming.stdout_capture.truncated = true;
streaming.stderr_capture.truncated = true;
Ok(streaming)
}
async fn spawn_stdio(&self, spec: &SpawnSpec) -> sandbox_driver::Result<StdioProcess> {
self.inner.scripted_exec().spawn_stdio(spec).await
}
}
#[async_trait]
impl Sandbox for LossySandbox {
fn id(&self) -> &SandboxId {
self.inner.id()
}
fn capabilities(&self) -> &Capabilities {
// The scripted sandbox's builder method of the same name shadows
// the trait's.
Sandbox::capabilities(&*self.inner)
}
async fn describe(&self) -> sandbox_driver::Result<SandboxStatus> {
self.inner.describe().await
}
fn working_directory(&self) -> &str {
self.inner.working_directory()
}
async fn environment(&self) -> sandbox_driver::Result<BTreeMap<String, String>> {
self.inner.environment().await
}
fn runtime_directory(&self) -> Option<&str> {
Sandbox::runtime_directory(&*self.inner)
}
async fn platform_info(&self) -> sandbox_driver::Result<PlatformInfo> {
self.inner.platform_info().await
}
async fn start(&self) -> sandbox_driver::Result<()> {
self.inner.start().await
}
async fn stop(&self) -> sandbox_driver::Result<()> {
self.inner.stop().await
}
async fn delete(&self) -> sandbox_driver::Result<()> {
self.inner.delete().await
}
fn exec(&self) -> &dyn Exec {
&self.exec
}
fn fs(&self) -> &dyn Filesystem {
self.inner.fs()
}
fn provider_search(&self) -> Option<&dyn Search> {
self.inner.provider_search()
}
}
#[tokio::test]
async fn a_lossy_command_tells_the_model_what_the_provider_dropped() {
let scripted =
Arc::new(
ScriptedSandbox::with_id_and_working_dir("lossy", "/work")
.platform(PlatformInfo::new("linux", "x86_64", "Linux 6.1.0")),
);
scripted.scripted_exec().set_default(exec_result(
"built\n",
"warning: torn",
Some(0),
Termination::Exited,
7,
));
let sandbox = RunSandbox::new_with_platform(
SandboxProviderKind::DAYTONA,
Arc::new(LossySandbox::new(scripted, output_loss(3, 512))),
"linux",
"Linux 6.1.0",
);
let outcome = Environment::exec(&sandbox, request("cargo build"))
.await
.expect("a lossy command completes rather than fails");
assert_eq!(outcome.result.stdout, "built\n");
assert_eq!(
outcome.result.stderr,
"warning: torn\n[sandbox] 3 output frame(s), 512 bytes dropped by the provider\n"
);
assert_eq!(outcome.result.exit_code, Some(0));
assert!(outcome.streams_separated);
}
}