fabro/lib/components/fabro-sandbox/tests/docker_streaming.rs
Bryan Helmkamp 957fc97c5c
Merge origin/main into pebble-agent-loop
Main merged the sandbox-driver adoption (#849) in a later form than this
branch was stacked on: the driver's own exec types replace fabro-sandbox's,
shell quoting moved to fabro-util, the sandbox lifecycle collapsed, and the
driver's events are stored as run events. This branch had deleted
`fabro-agent` and put the coding agent, the environment adapter, and the
steering hub on pebble.

The resolution takes main's sandbox API and re-applies pebble on top: the
`RunSandbox` `Environment` adapter moves to `pebble_environment.rs` (main's
`environment.rs` is the sandbox spec) and runs commands through `ExecSpec`
and `ExecControls`, feeding pebble's output sink from the driver's; the
driver-era `sandbox.*` names leave the known-event list, as on main, so a
stored event with that name and no driver shape is `Unknown` rather than an
error; `program_exit_code` matches pebble's non-exhaustive termination; the
Docker and Daytona smokes use main's constructor and credentials; the
remaining `fabro_agent` paths point at fabro-sandbox.

Pebble's `mcp` feature pins sandbox-driver, and the preview-url trait
objects only cross when both sides name one revision, so pebble moved to
main's `a92c0db6` (lithoscomputer/pebble#10) and fabro pins that pebble
revision until it lands on pebble main.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 09:40:00 -06:00

513 lines
17 KiB
Rust

//! Docker sandbox behaviour through the sandbox-driver Docker provider.
use std::sync::Arc;
use std::time::Duration;
use fabro_sandbox::{
CloneRequest, ExecControls, ExecSpec, OutputSink, ProviderAccess, SandboxProviderKind,
Termination, provider_sandbox,
};
use sandbox_driver::{SandboxSource, SandboxSpec};
use tokio::process::Command;
use tokio::sync::Mutex;
/// Whether a Docker daemon answers and has `image` locally. The tests are
/// skipped (not failed) otherwise, matching the ignore reason.
async fn docker_image_available(image: &str) -> bool {
Command::new("docker")
.args(["image", "inspect", image])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await
.is_ok_and(|status| status.success())
}
fn capture_bytes(chunks: Arc<Mutex<Vec<u8>>>) -> OutputSink {
Arc::new(move |_stream, bytes| {
let chunks = Arc::clone(&chunks);
Box::pin(async move {
chunks.lock().await.extend(bytes);
Ok(())
})
})
}
#[tokio::test]
#[ignore = "requires real Docker container lifecycle; run explicitly when changing Docker exec integration"]
async fn streaming_timeout_terminates_docker_exec_before_returning() {
let image = "buildpack-deps:noble";
if !docker_image_available(image).await {
return;
}
let sandbox = provider_sandbox(
SandboxProviderKind::DOCKER,
&ProviderAccess::default(),
SandboxSpec::new(SandboxSource::Image {
reference: image.to_string(),
}),
&CloneRequest::none(),
None,
None,
)
.await
.expect("docker sandbox should construct");
sandbox
.initialize()
.await
.expect("docker sandbox should initialize");
let chunks = Arc::new(Mutex::new(Vec::new()));
let marker = "fabro_streaming_timeout_sentinel";
let command = format!("trap '' HUP TERM; echo start; sleep 5 # {marker}");
let result = sandbox
.exec_command_streaming(
ExecSpec::bash(&command).timeout(Duration::from_millis(200)),
ExecControls {
sink: Some(capture_bytes(Arc::clone(&chunks))),
..ExecControls::default()
},
)
.await
.expect("streaming command should return a timeout result");
assert_eq!(result.result.termination, Termination::TimedOut);
assert!(
String::from_utf8_lossy(&chunks.lock().await).contains("start"),
"stream should include output emitted before timeout"
);
let probe = sandbox
.exec_command(
"marker='fabro_streaming_timeout_''sentinel'; \
ps -eo pid,args | awk -v marker=\"$marker\" \
'index($0, marker) && $0 !~ /awk/ && $0 !~ /ps -eo/ { print }'",
1_000,
None,
None,
None,
)
.await
.expect("process probe should run");
sandbox
.delete()
.await
.expect("docker cleanup should succeed");
let probe = probe.stdout_lossy();
assert!(
!probe.contains(marker),
"timed-out docker exec should be terminated before returning, found: {probe}"
);
}
#[tokio::test]
#[ignore = "requires real Docker container lifecycle; run explicitly when changing Docker exec integration"]
async fn streaming_command_receives_exact_stdin_and_eof() {
let image = "buildpack-deps:noble";
if !docker_image_available(image).await {
return;
}
let sandbox = provider_sandbox(
SandboxProviderKind::DOCKER,
&ProviderAccess::default(),
SandboxSpec::new(SandboxSource::Image {
reference: image.to_string(),
}),
&CloneRequest::none(),
None,
None,
)
.await
.expect("docker sandbox should construct");
sandbox
.initialize()
.await
.expect("docker sandbox should initialize");
let stdin = b"first line\n$(touch /tmp/must-not-run)\nlast line".to_vec();
let result = sandbox
.exec_command_streaming(
ExecSpec::bash("cat")
.timeout(Duration::from_secs(10))
.stdin(stdin.clone()),
ExecControls::default(),
)
.await
.expect("streaming command should read stdin and finish at EOF");
let injection_probe = sandbox
.exec_command("test ! -e /tmp/must-not-run", 10_000, None, None, None)
.await
.expect("injection probe should run");
sandbox
.delete()
.await
.expect("docker cleanup should succeed");
assert!(
result.result.success(),
"stdin command failed: stdout={} stderr={}",
result.result.stdout_lossy(),
result.result.stderr_lossy()
);
assert_eq!(result.result.stdout, stdin);
assert!(
injection_probe.success(),
"stdin bytes must not be evaluated as shell source"
);
}
#[tokio::test]
#[ignore = "requires real Docker container lifecycle, image, network, and a public GitHub clone"]
async fn cloned_docker_sandbox_uses_repos_checkout_and_workspace_symlink() {
let image = "buildpack-deps:noble";
if !docker_image_available(image).await {
return;
}
let sandbox = provider_sandbox(
SandboxProviderKind::DOCKER,
&ProviderAccess::default(),
SandboxSpec::new(SandboxSource::Image {
reference: image.to_string(),
}),
&CloneRequest {
origin_url: Some("https://github.com/brynary/rack-test".to_string()),
..CloneRequest::default()
},
None,
None,
)
.await
.expect("docker sandbox should construct");
sandbox
.initialize()
.await
.expect("docker sandbox should initialize");
assert_eq!(sandbox.working_directory(), "/workspace/rack-test");
let result = sandbox
.exec_command(
"test -d /repos/brynary/rack-test/.git && \
test -L /workspace/rack-test && \
test \"$(readlink /workspace/rack-test)\" = /repos/brynary/rack-test && \
test \"$(git -C /repos/brynary/rack-test rev-parse HEAD)\" = \
\"$(git -C /workspace/rack-test rev-parse HEAD)\" && \
git rev-parse --is-inside-work-tree",
10_000,
None,
None,
None,
)
.await
.expect("layout verification command should run");
sandbox
.delete()
.await
.expect("docker cleanup should succeed");
assert!(
result.success(),
"layout verification failed: stdout={} stderr={}",
result.stdout_lossy(),
result.stderr_lossy()
);
assert!(result.stdout_lossy().contains("true"));
}
// Both command paths must evaluate the same interpreter, so Bash-only syntax
// that `sh` rejects has to behave identically through `exec_command` and
// `exec_command_streaming`. Neither path is evidence for the other: they build
// separate exec invocations, and the streaming one wraps the user command in a
// controlled child.
#[tokio::test]
#[ignore = "requires real Docker container lifecycle; run explicitly when changing Docker exec integration"]
async fn docker_runs_clean_bash_through_both_command_paths() {
let image = "buildpack-deps:noble";
if !docker_image_available(image).await {
return;
}
let sandbox = provider_sandbox(
SandboxProviderKind::DOCKER,
&ProviderAccess::default(),
SandboxSpec::new(SandboxSource::Image {
reference: image.to_string(),
})
.env_var("BASH_ENV".to_string(), "/tmp/fabro-bash-env".to_string()),
&CloneRequest::none(),
None,
None,
)
.await
.expect("docker sandbox should construct");
sandbox
.initialize()
.await
.expect("docker sandbox should initialize");
// If the image-level BASH_ENV survives either exec boundary, every
// subsequent Bash process prints this line before the requested command.
let setup = sandbox
.exec_command(
"printf \"printf 'startup-source-loaded\\\\n'\\n\" > /tmp/fabro-bash-env",
10_000,
None,
None,
None,
)
.await
.expect("startup-file fixture should be created");
assert!(setup.success());
// Arrays, `[[ ]]`, and `${arr[@]}` are Bash-only; `shopt -q login_shell`
// proves the command did not run under a login shell. Exact output also
// proves the image's BASH_ENV startup file was not sourced.
let command = "arr=(one two three); [[ ${#arr[@]} -eq 3 ]] || exit 1; \
shopt -q login_shell && exit 2; echo ${arr[1]}";
let non_streaming = sandbox
.exec_command(command, 10_000, None, None, None)
.await
.expect("non-streaming command should run");
let chunks = Arc::new(Mutex::new(Vec::new()));
let streaming = sandbox
.exec_command_streaming(
ExecSpec::bash(command).timeout(Duration::from_secs(10)),
ExecControls {
sink: Some(capture_bytes(Arc::clone(&chunks))),
..ExecControls::default()
},
)
.await
.expect("streaming command should run");
sandbox
.delete()
.await
.expect("docker cleanup should succeed");
assert!(
non_streaming.success(),
"non-streaming Bash-only command failed: stdout={} stderr={}",
non_streaming.stdout_lossy(),
non_streaming.stderr_lossy()
);
assert_eq!(non_streaming.stdout_lossy().trim(), "two");
assert!(
streaming.result.success(),
"streaming Bash-only command failed: stdout={} stderr={}",
streaming.result.stdout_lossy(),
streaming.result.stderr_lossy()
);
assert_eq!(streaming.result.stdout_lossy().trim(), "two");
assert_eq!(String::from_utf8_lossy(&chunks.lock().await).trim(), "two");
}
// Regression test for glob patterns that contain a path separator. Before the
// glob fix, the remote providers ran `find <base> -name <pattern>`, and
// `find -name` matches only the basename and rejects patterns containing `/`.
// So `*/SKILL.md` and `**/SKILL.md` silently returned an empty list inside a
// real container even though the files existed. Both `glob` calls below fail
// against that old implementation and pass once traversal and matching are
// split (find files, then match host-side).
#[tokio::test]
#[ignore = "requires real Docker container lifecycle; run explicitly when changing Sandbox::glob"]
async fn docker_glob_matches_patterns_containing_a_path_separator() {
let image = "buildpack-deps:noble";
if !docker_image_available(image).await {
return;
}
let sandbox = provider_sandbox(
SandboxProviderKind::DOCKER,
&ProviderAccess::default(),
SandboxSpec::new(SandboxSource::Image {
reference: image.to_string(),
}),
&CloneRequest::none(),
None,
None,
)
.await
.expect("docker sandbox should construct");
sandbox
.initialize()
.await
.expect("docker sandbox should initialize");
// Build a skills tree with a SKILL.md at the search root, one level below
// it, and two levels below it.
let seed = sandbox
.exec_command(
"mkdir -p skills/patch skills/nested/deeper && \
touch skills/SKILL.md skills/patch/SKILL.md skills/nested/deeper/SKILL.md",
10_000,
None,
None,
None,
)
.await
.expect("seed command should run");
// `*/SKILL.md` matches exactly one path segment: only the file one level
// below the search directory, not the root file or the deeper one.
let one_level = sandbox.glob("*/SKILL.md", Some("skills")).await;
// `**/SKILL.md` matches at any depth, including several levels down.
let recursive = sandbox.glob("**/SKILL.md", Some("skills")).await;
sandbox
.delete()
.await
.expect("docker cleanup should succeed");
assert!(
seed.success(),
"seeding the skills tree failed: stdout={} stderr={}",
seed.stdout_lossy(),
seed.stderr_lossy()
);
let one_level = one_level.expect("glob should run");
assert_eq!(
one_level.len(),
1,
"`*/SKILL.md` should match exactly one level below the search dir, got: {one_level:?}"
);
assert!(
one_level[0].ends_with("skills/patch/SKILL.md"),
"`*/SKILL.md` should match the one-level-deep file, got: {one_level:?}"
);
let recursive = recursive.expect("recursive glob should run");
assert!(
recursive
.iter()
.any(|path| path.ends_with("skills/nested/deeper/SKILL.md")),
"`**/SKILL.md` should match files nested several levels deep, got: {recursive:?}"
);
}
// The Fabro runtime directory is where prompt blobs materialize, so it must
// exist after initialization, sit outside the repository checkout, and stay
// owner-private along with the files written beneath it (issue #798).
#[tokio::test]
#[ignore = "requires real Docker container lifecycle; run explicitly when changing Docker runtime directory setup"]
async fn docker_runtime_directory_is_private_and_outside_workspace() {
let image = "buildpack-deps:noble";
if !docker_image_available(image).await {
return;
}
let sandbox = provider_sandbox(
SandboxProviderKind::DOCKER,
&ProviderAccess::default(),
SandboxSpec::new(SandboxSource::Image {
reference: image.to_string(),
}),
&CloneRequest::none(),
None,
None,
)
.await
.expect("docker sandbox should construct");
sandbox
.initialize()
.await
.expect("docker sandbox should initialize");
let runtime_directory = sandbox
.runtime_directory()
.expect("docker sandbox should expose a runtime directory")
.to_string();
assert!(
!runtime_directory.starts_with(sandbox.working_directory()),
"runtime directory {runtime_directory} must sit outside the workspace"
);
let blob_path = format!("{runtime_directory}/blobs/test-blob.json");
sandbox
.write_file(&blob_path, "{}")
.await
.expect("runtime blob write should succeed");
let modes = sandbox
.exec_command(
&format!("stat -c '%a' {runtime_directory} {blob_path}"),
10_000,
None,
None,
None,
)
.await
.expect("stat should run");
let readback = sandbox.read_file_text(&blob_path).await;
sandbox
.delete()
.await
.expect("docker cleanup should succeed");
assert!(modes.success(), "stat failed: {}", modes.stderr_lossy());
let modes = modes.stdout_lossy();
let modes: Vec<&str> = modes.split_whitespace().collect();
assert_eq!(
modes,
["700", "600"],
"runtime directory and blob file should be owner-private"
);
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(),
SandboxSpec::new(SandboxSource::Image {
reference: image.to_string(),
}),
&CloneRequest::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_mins(1));
let outcome = async {
contract.verify_files().await?;
contract.verify_search().await?;
contract.verify_commands().await
}
.await;
sandbox
.delete()
.await
.expect("docker sandbox should clean up");
outcome.expect("the Docker sandbox satisfies pebble's environment contract");
}