fabro/lib/components/fabro-sandbox/tests/docker_streaming.rs
2026-08-26 11:16:37 -04:00

478 lines
15 KiB
Rust

#![cfg(feature = "docker")]
use std::sync::Arc;
use bollard::Docker;
use fabro_sandbox::{
CommandOutputCallback, DockerSandbox, DockerSandboxOptions, ExecStreamingRequest, Sandbox,
};
use tokio::sync::Mutex;
fn capture_bytes(chunks: Arc<Mutex<Vec<u8>>>) -> CommandOutputCallback {
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";
let Ok(docker) = Docker::connect_with_local_defaults() else {
return;
};
if docker.inspect_image(image).await.is_err() {
return;
}
let sandbox = DockerSandbox::new(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
skip_clone: true,
..DockerSandboxOptions::default()
},
None,
None,
None,
None,
None,
None,
)
.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(ExecStreamingRequest {
timeout_ms: Some(200),
output_callback: Some(capture_bytes(Arc::clone(&chunks))),
..ExecStreamingRequest::new(&command)
})
.await
.expect("streaming command should return a timeout result");
assert!(result.result.is_timed_out());
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
.cleanup()
.await
.expect("docker cleanup should succeed");
assert!(
!probe.stdout.contains(marker),
"timed-out docker exec should be terminated before returning, found: {}",
probe.stdout
);
}
#[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";
let Ok(docker) = Docker::connect_with_local_defaults() else {
return;
};
if docker.inspect_image(image).await.is_err() {
return;
}
let sandbox = DockerSandbox::new(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
skip_clone: true,
..DockerSandboxOptions::default()
},
None,
None,
None,
None,
None,
None,
)
.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(ExecStreamingRequest {
timeout_ms: Some(10_000),
stdin: Some(stdin.clone()),
..ExecStreamingRequest::new("cat")
})
.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
.cleanup()
.await
.expect("docker cleanup should succeed");
assert!(
result.result.is_success(),
"stdin command failed: stdout={} stderr={}",
result.result.stdout,
result.result.stderr
);
assert_eq!(result.result.stdout.as_bytes(), stdin);
assert!(
injection_probe.is_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";
let Ok(docker) = Docker::connect_with_local_defaults() else {
return;
};
if docker.inspect_image(image).await.is_err() {
return;
}
let sandbox = DockerSandbox::new(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
skip_clone: false,
..DockerSandboxOptions::default()
},
None,
None,
Some("https://github.com/brynary/rack-test".to_string()),
None,
None,
None,
)
.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
.cleanup()
.await
.expect("docker cleanup should succeed");
assert!(
result.is_success(),
"layout verification failed: stdout={} stderr={}",
result.stdout,
result.stderr
);
assert!(result.stdout.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";
let Ok(docker) = Docker::connect_with_local_defaults() else {
return;
};
if docker.inspect_image(image).await.is_err() {
return;
}
let sandbox = DockerSandbox::new(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
env_vars: vec!["BASH_ENV=/tmp/fabro-bash-env".to_string()],
skip_clone: true,
..DockerSandboxOptions::default()
},
None,
None,
None,
None,
None,
None,
)
.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.is_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(ExecStreamingRequest {
timeout_ms: Some(10_000),
output_callback: Some(capture_bytes(Arc::clone(&chunks))),
..ExecStreamingRequest::new(command)
})
.await
.expect("streaming command should run");
sandbox
.cleanup()
.await
.expect("docker cleanup should succeed");
assert!(
non_streaming.is_success(),
"non-streaming Bash-only command failed: stdout={} stderr={}",
non_streaming.stdout,
non_streaming.stderr
);
assert_eq!(non_streaming.stdout.trim(), "two");
assert!(
streaming.result.is_success(),
"streaming Bash-only command failed: stdout={} stderr={}",
streaming.result.stdout,
streaming.result.stderr
);
assert_eq!(streaming.result.stdout.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";
let Ok(docker) = Docker::connect_with_local_defaults() else {
return;
};
if docker.inspect_image(image).await.is_err() {
return;
}
let sandbox = DockerSandbox::new(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
skip_clone: true,
..DockerSandboxOptions::default()
},
None,
None,
None,
None,
None,
None,
)
.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
.cleanup()
.await
.expect("docker cleanup should succeed");
assert!(
seed.is_success(),
"seeding the skills tree failed: stdout={} stderr={}",
seed.stdout,
seed.stderr
);
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";
let Ok(docker) = Docker::connect_with_local_defaults() else {
return;
};
if docker.inspect_image(image).await.is_err() {
return;
}
let sandbox = DockerSandbox::new(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
skip_clone: true,
..DockerSandboxOptions::default()
},
None,
None,
None,
None,
None,
None,
)
.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
.cleanup()
.await
.expect("docker cleanup should succeed");
assert!(modes.is_success(), "stat failed: {}", modes.stderr);
let modes: Vec<&str> = modes.stdout.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"), "{}");
}