Add arc-sprites crate for Sprites (Fly.io) VM sandbox

Implements the Sandbox trait backed by the `sprite` CLI binary.
Includes 29 unit tests with mock runner and an e2e integration test
against the live Sprites service.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-07 17:49:06 -05:00
parent ac535b925b
commit c3ec637f09
5 changed files with 1515 additions and 0 deletions

16
Cargo.lock generated
View file

@ -322,6 +322,22 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "arc-sprites"
version = "0.1.0"
dependencies = [
"arc-agent",
"async-trait",
"base64",
"chrono",
"rand 0.8.5",
"serde",
"tempfile",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "arc-types"
version = "0.1.0"

View file

@ -0,0 +1,24 @@
[package]
name = "arc-sprites"
edition.workspace = true
version.workspace = true
license.workspace = true
description = "Sprites (Fly.io) VM sandbox for Arc agent tool operations"
[lib]
doctest = false
[dependencies]
arc-agent = { path = "../arc-agent" }
async-trait.workspace = true
tokio.workspace = true
tokio-util.workspace = true
serde.workspace = true
chrono.workspace = true
rand.workspace = true
tracing.workspace = true
base64.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] }
tempfile = "3"

View file

@ -0,0 +1,41 @@
use async_trait::async_trait;
use crate::{SpriteOutput, SpriteRunner};
/// Real implementation that invokes the `sprite` CLI binary.
#[derive(Default)]
pub struct CliSpriteRunner;
impl CliSpriteRunner {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl SpriteRunner for CliSpriteRunner {
async fn run(&self, args: &[&str]) -> Result<SpriteOutput, String> {
let output = tokio::process::Command::new("sprite")
.args(args)
.output()
.await
.map_err(|e| format!("Failed to run sprite command: {e}"))?;
Ok(SpriteOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
exit_code: output.status.code().unwrap_or(-1),
})
}
async fn run_with_timeout(
&self,
args: &[&str],
timeout: std::time::Duration,
) -> Result<SpriteOutput, String> {
match tokio::time::timeout(timeout, self.run(args)).await {
Ok(result) => result,
Err(_) => Err("Command timed out".to_string()),
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,197 @@
//! E2E tests against the live Sprites service.
//!
//! Requires an authenticated `sprite` CLI. Run with:
//! cargo test -p arc-sprites --test e2e -- --ignored
use arc_agent::sandbox::Sandbox;
use arc_sprites::{CliSpriteRunner, SpritesConfig, SpritesSandbox};
/// Full lifecycle test: create sprite, run operations, destroy sprite.
#[tokio::test]
#[ignore]
async fn full_lifecycle() {
let runner = CliSpriteRunner::new();
let config = SpritesConfig::default();
let sandbox = SpritesSandbox::new(Box::new(runner), config);
// --- Initialize ---
sandbox.initialize().await.unwrap();
let name = sandbox.sandbox_info();
assert!(
name.starts_with("arc-"),
"expected sprite name starting with arc-, got: {name}",
);
// Wrap the rest in a closure-like block so we can always cleanup
let result = run_operations(&sandbox).await;
// --- Cleanup (always runs) ---
sandbox.cleanup().await.unwrap();
// Propagate any error from operations
result.unwrap();
}
async fn run_operations(sandbox: &SpritesSandbox) -> Result<(), String> {
// --- Metadata ---
assert_eq!(sandbox.working_directory(), "/home/sprite");
assert_eq!(sandbox.platform(), "linux");
assert_eq!(sandbox.os_version(), "Linux (Sprites)");
// --- exec_command: basic ---
let result = sandbox
.exec_command("echo hello", 30_000, None, None, None)
.await?;
assert_eq!(result.exit_code, 0, "exec_command failed: {}", result.stderr);
assert_eq!(result.stdout.trim(), "hello");
assert!(!result.timed_out);
// --- exec_command: working directory ---
let result = sandbox
.exec_command("pwd", 30_000, Some("/tmp"), None, None)
.await?;
assert_eq!(result.exit_code, 0, "pwd failed: {}", result.stderr);
assert_eq!(result.stdout.trim(), "/tmp");
// --- exec_command: env vars ---
let mut env = std::collections::HashMap::new();
env.insert("TEST_VAR".to_string(), "sprite_value".to_string());
let result = sandbox
.exec_command("echo $TEST_VAR", 30_000, None, Some(&env), None)
.await?;
assert_eq!(result.exit_code, 0, "env exec failed: {}", result.stderr);
assert_eq!(result.stdout.trim(), "sprite_value");
// --- write_file + read_file round-trip ---
sandbox
.write_file("test-e2e/hello.txt", "Hello, Sprites!\nSecond line\n")
.await?;
let content = sandbox
.read_file("test-e2e/hello.txt", None, None)
.await?;
assert!(
content.contains("Hello, Sprites!"),
"read_file missing content: {content}",
);
assert!(
content.contains("1 | "),
"read_file missing line numbers: {content}",
);
assert!(
content.contains("2 | Second line"),
"read_file missing second line: {content}",
);
// --- read_file with offset and limit ---
let content = sandbox
.read_file("test-e2e/hello.txt", Some(1), Some(1))
.await?;
assert!(
content.contains("2 | Second line"),
"offset read missing line 2: {content}",
);
assert!(
!content.contains("Hello, Sprites!"),
"offset read should skip line 1: {content}",
);
// --- file_exists ---
assert!(
sandbox.file_exists("test-e2e/hello.txt").await?,
"file should exist",
);
assert!(
!sandbox.file_exists("test-e2e/nonexistent.txt").await?,
"file should not exist",
);
// --- delete_file ---
sandbox
.write_file("test-e2e/to-delete.txt", "delete me")
.await?;
assert!(sandbox.file_exists("test-e2e/to-delete.txt").await?);
sandbox.delete_file("test-e2e/to-delete.txt").await?;
assert!(
!sandbox.file_exists("test-e2e/to-delete.txt").await?,
"file should be deleted",
);
// --- list_directory ---
sandbox
.write_file("test-e2e/sub/a.txt", "aaa")
.await?;
sandbox
.write_file("test-e2e/sub/b.txt", "bbb")
.await?;
let entries = sandbox.list_directory("test-e2e/sub", None).await?;
assert_eq!(entries.len(), 2, "expected 2 entries, got: {entries:?}");
assert_eq!(entries[0].name, "a.txt");
assert_eq!(entries[1].name, "b.txt");
assert!(!entries[0].is_dir);
// --- grep ---
sandbox
.write_file("test-e2e/search/code.rs", "fn main() {\n println!(\"hello\");\n}\n")
.await?;
sandbox
.write_file("test-e2e/search/data.txt", "no match here\n")
.await?;
let grep_results = sandbox
.grep("println", "test-e2e/search", &Default::default())
.await?;
assert_eq!(
grep_results.len(),
1,
"expected 1 grep match, got: {grep_results:?}",
);
assert!(
grep_results[0].contains("println"),
"grep result should contain match: {}",
grep_results[0],
);
// --- grep: no matches ---
let grep_results = sandbox
.grep("zzz_no_match_zzz", "test-e2e/search", &Default::default())
.await?;
assert!(
grep_results.is_empty(),
"expected no grep matches, got: {grep_results:?}",
);
// --- glob ---
let glob_results = sandbox.glob("*.rs", Some("test-e2e/search")).await?;
assert_eq!(
glob_results.len(),
1,
"expected 1 glob match, got: {glob_results:?}",
);
assert!(
glob_results[0].contains("code.rs"),
"glob result should contain code.rs: {}",
glob_results[0],
);
// --- download_file_to_local ---
let download_content = "binary-like content for download test";
sandbox
.write_file("test-e2e/download.bin", download_content)
.await?;
let tmp = tempfile::tempdir().map_err(|e| format!("tempdir: {e}"))?;
let local_path = tmp.path().join("downloaded.bin");
sandbox
.download_file_to_local("test-e2e/download.bin", &local_path)
.await?;
let downloaded = tokio::fs::read_to_string(&local_path)
.await
.map_err(|e| format!("read local: {e}"))?;
assert_eq!(
downloaded, download_content,
"download content mismatch",
);
Ok(())
}