mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
478 lines
15 KiB
Rust
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"), "{}");
|
|
}
|