mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-03 02:24:33 +00:00
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>
513 lines
17 KiB
Rust
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");
|
|
}
|