Add Docker-backed integration test rig

This commit is contained in:
Nathan Flurry 2026-03-08 00:09:01 -08:00
parent 641597afe6
commit 25f8491c6d
18 changed files with 1138 additions and 373 deletions

View file

@ -11,9 +11,7 @@ mod build_version {
include!(concat!(env!("OUT_DIR"), "/version.rs"));
}
use crate::desktop_install::{
install_desktop, DesktopInstallRequest, DesktopPackageManager,
};
use crate::desktop_install::{install_desktop, DesktopInstallRequest, DesktopPackageManager};
use crate::router::{
build_router_with_state, shutdown_servers, AppState, AuthConfig, BrandingMode,
};

View file

@ -16,7 +16,12 @@ pub struct DesktopProblem {
impl DesktopProblem {
pub fn unsupported_platform(message: impl Into<String>) -> Self {
Self::new(501, "Desktop Unsupported", "desktop_unsupported_platform", message)
Self::new(
501,
"Desktop Unsupported",
"desktop_unsupported_platform",
message,
)
}
pub fn dependencies_missing(
@ -44,11 +49,21 @@ impl DesktopProblem {
}
pub fn runtime_inactive(message: impl Into<String>) -> Self {
Self::new(409, "Desktop Runtime Inactive", "desktop_runtime_inactive", message)
Self::new(
409,
"Desktop Runtime Inactive",
"desktop_runtime_inactive",
message,
)
}
pub fn runtime_starting(message: impl Into<String>) -> Self {
Self::new(409, "Desktop Runtime Starting", "desktop_runtime_starting", message)
Self::new(
409,
"Desktop Runtime Starting",
"desktop_runtime_starting",
message,
)
}
pub fn runtime_failed(
@ -56,18 +71,36 @@ impl DesktopProblem {
install_command: Option<String>,
processes: Vec<DesktopProcessInfo>,
) -> Self {
Self::new(503, "Desktop Runtime Failed", "desktop_runtime_failed", message)
.with_install_command(install_command)
.with_processes(processes)
Self::new(
503,
"Desktop Runtime Failed",
"desktop_runtime_failed",
message,
)
.with_install_command(install_command)
.with_processes(processes)
}
pub fn invalid_action(message: impl Into<String>) -> Self {
Self::new(400, "Desktop Invalid Action", "desktop_invalid_action", message)
Self::new(
400,
"Desktop Invalid Action",
"desktop_invalid_action",
message,
)
}
pub fn screenshot_failed(message: impl Into<String>, processes: Vec<DesktopProcessInfo>) -> Self {
Self::new(502, "Desktop Screenshot Failed", "desktop_screenshot_failed", message)
.with_processes(processes)
pub fn screenshot_failed(
message: impl Into<String>,
processes: Vec<DesktopProcessInfo>,
) -> Self {
Self::new(
502,
"Desktop Screenshot Failed",
"desktop_screenshot_failed",
message,
)
.with_processes(processes)
}
pub fn input_failed(message: impl Into<String>, processes: Vec<DesktopProcessInfo>) -> Self {
@ -97,10 +130,7 @@ impl DesktopProblem {
);
}
if !self.processes.is_empty() {
extensions.insert(
"processes".to_string(),
json!(self.processes),
);
extensions.insert("processes".to_string(), json!(self.processes));
}
ProblemDetails {

View file

@ -22,7 +22,9 @@ pub struct DesktopInstallRequest {
pub fn install_desktop(request: DesktopInstallRequest) -> Result<(), String> {
if std::env::consts::OS != "linux" {
return Err("desktop installation is only supported on Linux hosts and sandboxes".to_string());
return Err(
"desktop installation is only supported on Linux hosts and sandboxes".to_string(),
);
}
let package_manager = match request.package_manager {
@ -47,7 +49,10 @@ pub fn install_desktop(request: DesktopInstallRequest) -> Result<(), String> {
println!(" - {package}");
}
println!("Install command:");
println!(" {}", render_install_command(package_manager, used_sudo, &packages));
println!(
" {}",
render_install_command(package_manager, used_sudo, &packages)
);
if request.print_only {
return Ok(());
@ -76,10 +81,7 @@ fn detect_package_manager() -> Option<DesktopPackageManager> {
None
}
fn desktop_packages(
package_manager: DesktopPackageManager,
no_fonts: bool,
) -> Vec<String> {
fn desktop_packages(package_manager: DesktopPackageManager, no_fonts: bool) -> Vec<String> {
let mut packages = match package_manager {
DesktopPackageManager::Apt => vec![
"xvfb",

View file

@ -10,11 +10,12 @@ use tokio::sync::Mutex;
use crate::desktop_errors::DesktopProblem;
use crate::desktop_types::{
DesktopActionResponse, DesktopDisplayInfoResponse, DesktopErrorInfo, DesktopKeyboardPressRequest,
DesktopKeyboardTypeRequest, DesktopMouseButton, DesktopMouseClickRequest,
DesktopMouseDragRequest, DesktopMouseMoveRequest, DesktopMousePositionResponse,
DesktopMouseScrollRequest, DesktopProcessInfo, DesktopRegionScreenshotQuery, DesktopResolution,
DesktopStartRequest, DesktopState, DesktopStatusResponse,
DesktopActionResponse, DesktopDisplayInfoResponse, DesktopErrorInfo,
DesktopKeyboardPressRequest, DesktopKeyboardTypeRequest, DesktopMouseButton,
DesktopMouseClickRequest, DesktopMouseDragRequest, DesktopMouseMoveRequest,
DesktopMousePositionResponse, DesktopMouseScrollRequest, DesktopProcessInfo,
DesktopRegionScreenshotQuery, DesktopResolution, DesktopStartRequest, DesktopState,
DesktopStatusResponse,
};
const DEFAULT_WIDTH: u32 = 1440;
@ -164,8 +165,9 @@ impl DesktopRuntime {
));
}
self.ensure_state_dir_locked(&state)
.map_err(|err| DesktopProblem::runtime_failed(err, None, self.processes_locked(&state)))?;
self.ensure_state_dir_locked(&state).map_err(|err| {
DesktopProblem::runtime_failed(err, None, self.processes_locked(&state))
})?;
self.write_runtime_log_locked(&state, "starting desktop runtime");
let width = request.width.unwrap_or(DEFAULT_WIDTH);
@ -211,11 +213,13 @@ impl DesktopRuntime {
})?;
state.resolution = Some(display_info.resolution.clone());
self.capture_screenshot_locked(&state, None).await.map_err(|problem| {
self.record_problem_locked(&mut state, &problem);
state.state = DesktopState::Failed;
problem
})?;
self.capture_screenshot_locked(&state, None)
.await
.map_err(|problem| {
self.record_problem_locked(&mut state, &problem);
state.state = DesktopState::Failed;
problem
})?;
state.state = DesktopState::Active;
state.started_at = Some(chrono::Utc::now().to_rfc3339());
@ -279,7 +283,8 @@ impl DesktopRuntime {
let mut state = self.inner.lock().await;
let ready = self.ensure_ready_locked(&mut state).await?;
let crop = format!("{}x{}+{}+{}", query.width, query.height, query.x, query.y);
self.capture_screenshot_with_crop_locked(&state, &ready, &crop).await
self.capture_screenshot_with_crop_locked(&state, &ready, &crop)
.await
}
pub async fn mouse_position(&self) -> Result<DesktopMousePositionResponse, DesktopProblem> {
@ -393,9 +398,7 @@ impl DesktopRuntime {
request: DesktopKeyboardTypeRequest,
) -> Result<DesktopActionResponse, DesktopProblem> {
if request.text.is_empty() {
return Err(DesktopProblem::invalid_action(
"text must not be empty",
));
return Err(DesktopProblem::invalid_action("text must not be empty"));
}
let mut state = self.inner.lock().await;
@ -466,9 +469,9 @@ impl DesktopRuntime {
DesktopState::Inactive => Err(DesktopProblem::runtime_inactive(
"Desktop runtime has not been started",
)),
DesktopState::Starting | DesktopState::Stopping => Err(DesktopProblem::runtime_starting(
"Desktop runtime is still transitioning",
)),
DesktopState::Starting | DesktopState::Stopping => Err(
DesktopProblem::runtime_starting("Desktop runtime is still transitioning"),
),
DesktopState::Failed => Err(DesktopProblem::runtime_failed(
state
.last_error
@ -514,7 +517,10 @@ impl DesktopRuntime {
return;
}
if matches!(state.state, DesktopState::Inactive | DesktopState::Starting | DesktopState::Stopping) {
if matches!(
state.state,
DesktopState::Inactive | DesktopState::Starting | DesktopState::Stopping
) {
if state.state == DesktopState::Inactive {
state.last_error = None;
}
@ -626,24 +632,22 @@ impl DesktopRuntime {
state: &mut DesktopRuntimeStateData,
) -> Result<(), DesktopProblem> {
if find_binary("dbus-launch").is_none() {
self.write_runtime_log_locked(state, "dbus-launch not found; continuing without D-Bus session");
self.write_runtime_log_locked(
state,
"dbus-launch not found; continuing without D-Bus session",
);
return Ok(());
}
let output = run_command_output(
"dbus-launch",
&[],
&state.environment,
INPUT_TIMEOUT,
)
.await
.map_err(|err| {
DesktopProblem::runtime_failed(
format!("failed to launch dbus-launch: {err}"),
None,
self.processes_locked(state),
)
})?;
let output = run_command_output("dbus-launch", &[], &state.environment, INPUT_TIMEOUT)
.await
.map_err(|err| {
DesktopProblem::runtime_failed(
format!("failed to launch dbus-launch: {err}"),
None,
self.processes_locked(state),
)
})?;
if !output.status.success() {
self.write_runtime_log_locked(
@ -693,7 +697,8 @@ impl DesktopRuntime {
"tcp".to_string(),
];
let log_path = self.config.state_dir.join("desktop-xvfb.log");
let child = self.spawn_logged_process("Xvfb", "Xvfb", &args, &state.environment, &log_path)?;
let child =
self.spawn_logged_process("Xvfb", "Xvfb", &args, &state.environment, &log_path)?;
state.xvfb = Some(child);
Ok(())
}
@ -738,12 +743,16 @@ impl DesktopRuntime {
ready: Option<&DesktopReadyContext>,
) -> Result<Vec<u8>, DesktopProblem> {
match ready {
Some(ready) => self
.capture_screenshot_with_crop_locked(state, ready, "")
.await,
Some(ready) => {
self.capture_screenshot_with_crop_locked(state, ready, "")
.await
}
None => {
let ready = DesktopReadyContext {
display: state.display.clone().unwrap_or_else(|| format!(":{}", state.display_num)),
display: state
.display
.clone()
.unwrap_or_else(|| format!(":{}", state.display_num)),
environment: state.environment.clone(),
resolution: state.resolution.clone().unwrap_or(DesktopResolution {
width: DEFAULT_WIDTH,
@ -751,7 +760,8 @@ impl DesktopRuntime {
dpi: Some(DEFAULT_DPI),
}),
};
self.capture_screenshot_with_crop_locked(state, &ready, "").await
self.capture_screenshot_with_crop_locked(state, &ready, "")
.await
}
}
}
@ -815,9 +825,8 @@ impl DesktopRuntime {
self.processes_locked(state),
));
}
parse_mouse_position(&output.stdout).map_err(|message| {
DesktopProblem::input_failed(message, self.processes_locked(state))
})
parse_mouse_position(&output.stdout)
.map_err(|message| DesktopProblem::input_failed(message, self.processes_locked(state)))
}
async fn run_input_command_locked(
@ -932,7 +941,14 @@ impl DesktopRuntime {
fn base_environment(&self, display: &str) -> Result<HashMap<String, String>, DesktopProblem> {
let mut environment = HashMap::new();
environment.insert("DISPLAY".to_string(), display.to_string());
environment.insert("HOME".to_string(), self.config.state_dir.join("home").to_string_lossy().to_string());
environment.insert(
"HOME".to_string(),
self.config
.state_dir
.join("home")
.to_string_lossy()
.to_string(),
);
environment.insert(
"USER".to_string(),
std::env::var("USER").unwrap_or_else(|_| "sandbox-agent".to_string()),
@ -942,7 +958,11 @@ impl DesktopRuntime {
std::env::var("PATH").unwrap_or_default(),
);
fs::create_dir_all(self.config.state_dir.join("home")).map_err(|err| {
DesktopProblem::runtime_failed(format!("failed to create desktop home: {err}"), None, Vec::new())
DesktopProblem::runtime_failed(
format!("failed to create desktop home: {err}"),
None,
Vec::new(),
)
})?;
Ok(environment)
}
@ -971,14 +991,20 @@ impl DesktopRuntime {
.open(log_path)
.map_err(|err| {
DesktopProblem::runtime_failed(
format!("failed to open desktop log file {}: {err}", log_path.display()),
format!(
"failed to open desktop log file {}: {err}",
log_path.display()
),
None,
Vec::new(),
)
})?;
let stderr = stdout.try_clone().map_err(|err| {
DesktopProblem::runtime_failed(
format!("failed to clone desktop log file {}: {err}", log_path.display()),
format!(
"failed to clone desktop log file {}: {err}",
log_path.display()
),
None,
Vec::new(),
)
@ -1008,7 +1034,10 @@ impl DesktopRuntime {
async fn wait_for_socket(&self, display_num: i32) -> Result<(), DesktopProblem> {
let socket = socket_path(display_num);
let parent = socket.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("/tmp/.X11-unix"));
let parent = socket
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("/tmp/.X11-unix"));
let _ = fs::create_dir_all(parent);
let start = tokio::time::Instant::now();
@ -1078,11 +1107,19 @@ impl DesktopRuntime {
}
fn ensure_state_dir_locked(&self, state: &DesktopRuntimeStateData) -> Result<(), String> {
fs::create_dir_all(&self.config.state_dir)
.map_err(|err| format!("failed to create desktop state dir {}: {err}", self.config.state_dir.display()))?;
fs::create_dir_all(&self.config.state_dir).map_err(|err| {
format!(
"failed to create desktop state dir {}: {err}",
self.config.state_dir.display()
)
})?;
if let Some(parent) = state.runtime_log_path.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("failed to create runtime log dir {}: {err}", parent.display()))?;
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create runtime log dir {}: {err}",
parent.display()
)
})?;
}
Ok(())
}
@ -1105,7 +1142,11 @@ fn default_state_dir() -> PathBuf {
return PathBuf::from(value).join("sandbox-agent").join("desktop");
}
if let Some(home) = dirs::home_dir() {
return home.join(".local").join("state").join("sandbox-agent").join("desktop");
return home
.join(".local")
.join("state")
.join("sandbox-agent")
.join("desktop");
}
std::env::temp_dir().join("sandbox-agent-desktop")
}
@ -1161,7 +1202,8 @@ fn child_is_running(child: &Child) -> bool {
fn process_exists(pid: u32) -> bool {
#[cfg(unix)]
unsafe {
return libc::kill(pid as i32, 0) == 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH);
return libc::kill(pid as i32, 0) == 0
|| std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH);
}
#[cfg(not(unix))]
{
@ -1186,8 +1228,12 @@ fn parse_xrandr_resolution(bytes: &[u8]) -> Result<DesktopResolution, String> {
if let Some(current) = parts.next() {
let dims: Vec<&str> = current.split_whitespace().collect();
if dims.len() >= 3 {
let width = dims[0].parse::<u32>().map_err(|_| "failed to parse xrandr width".to_string())?;
let height = dims[2].parse::<u32>().map_err(|_| "failed to parse xrandr height".to_string())?;
let width = dims[0]
.parse::<u32>()
.map_err(|_| "failed to parse xrandr width".to_string())?;
let height = dims[2]
.parse::<u32>()
.map_err(|_| "failed to parse xrandr height".to_string())?;
return Ok(DesktopResolution {
width,
height,

View file

@ -1,12 +1,12 @@
//! Sandbox agent core utilities.
mod acp_proxy_runtime;
mod desktop_install;
mod desktop_errors;
mod desktop_runtime;
pub mod desktop_types;
pub mod cli;
pub mod daemon;
mod desktop_errors;
mod desktop_install;
mod desktop_runtime;
pub mod desktop_types;
mod process_runtime;
pub mod router;
pub mod server_logs;

View file

@ -190,13 +190,22 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
"/desktop/screenshot/region",
get(get_v1_desktop_screenshot_region),
)
.route("/desktop/mouse/position", get(get_v1_desktop_mouse_position))
.route(
"/desktop/mouse/position",
get(get_v1_desktop_mouse_position),
)
.route("/desktop/mouse/move", post(post_v1_desktop_mouse_move))
.route("/desktop/mouse/click", post(post_v1_desktop_mouse_click))
.route("/desktop/mouse/drag", post(post_v1_desktop_mouse_drag))
.route("/desktop/mouse/scroll", post(post_v1_desktop_mouse_scroll))
.route("/desktop/keyboard/type", post(post_v1_desktop_keyboard_type))
.route("/desktop/keyboard/press", post(post_v1_desktop_keyboard_press))
.route(
"/desktop/keyboard/type",
post(post_v1_desktop_keyboard_type),
)
.route(
"/desktop/keyboard/press",
post(post_v1_desktop_keyboard_press),
)
.route("/desktop/display/info", get(get_v1_desktop_display_info))
.route("/agents", get(get_v1_agents))
.route("/agents/:agent", get(get_v1_agent))

View file

@ -0,0 +1,496 @@
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sandbox_agent::router::AuthConfig;
use tempfile::TempDir;
const CONTAINER_PORT: u16 = 3000;
const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
const STANDARD_PATHS: &[&str] = &[
"/usr/local/sbin",
"/usr/local/bin",
"/usr/sbin",
"/usr/bin",
"/sbin",
"/bin",
];
static IMAGE_TAG: OnceLock<String> = OnceLock::new();
static DOCKER_BIN: OnceLock<PathBuf> = OnceLock::new();
static CONTAINER_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Clone)]
pub struct DockerApp {
base_url: String,
}
impl DockerApp {
pub fn http_url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
pub fn ws_url(&self, path: &str) -> String {
let suffix = self
.base_url
.strip_prefix("http://")
.unwrap_or(&self.base_url);
format!("ws://{suffix}{path}")
}
}
pub struct TestApp {
pub app: DockerApp,
install_dir: PathBuf,
_root: TempDir,
container_id: String,
}
impl TestApp {
pub fn new(auth: AuthConfig) -> Self {
Self::with_setup(auth, |_| {})
}
pub fn with_setup<F>(auth: AuthConfig, setup: F) -> Self
where
F: FnOnce(&Path),
{
let root = tempfile::tempdir().expect("create docker test root");
let layout = TestLayout::new(root.path());
layout.create();
setup(&layout.install_dir);
let container_id = unique_container_id();
let image = ensure_test_image();
let env = build_env(&layout, &auth);
let mounts = build_mounts(root.path(), &env);
let base_url = run_container(&container_id, &image, &mounts, &env, &auth);
Self {
app: DockerApp { base_url },
install_dir: layout.install_dir,
_root: root,
container_id,
}
}
pub fn install_path(&self) -> &Path {
&self.install_dir
}
pub fn root_path(&self) -> &Path {
self._root.path()
}
}
impl Drop for TestApp {
fn drop(&mut self) {
let _ = Command::new(docker_bin())
.args(["rm", "-f", &self.container_id])
.output();
}
}
pub struct LiveServer {
base_url: String,
}
impl LiveServer {
pub async fn spawn(app: DockerApp) -> Self {
Self {
base_url: app.base_url,
}
}
pub fn http_url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
pub fn ws_url(&self, path: &str) -> String {
let suffix = self
.base_url
.strip_prefix("http://")
.unwrap_or(&self.base_url);
format!("ws://{suffix}{path}")
}
pub async fn shutdown(self) {}
}
struct TestLayout {
home: PathBuf,
xdg_data_home: PathBuf,
xdg_state_home: PathBuf,
appdata: PathBuf,
local_appdata: PathBuf,
install_dir: PathBuf,
}
impl TestLayout {
fn new(root: &Path) -> Self {
let home = root.join("home");
let xdg_data_home = root.join("xdg-data");
let xdg_state_home = root.join("xdg-state");
let appdata = root.join("appdata").join("Roaming");
let local_appdata = root.join("appdata").join("Local");
let install_dir = xdg_data_home.join("sandbox-agent").join("bin");
Self {
home,
xdg_data_home,
xdg_state_home,
appdata,
local_appdata,
install_dir,
}
}
fn create(&self) {
for dir in [
&self.home,
&self.xdg_data_home,
&self.xdg_state_home,
&self.appdata,
&self.local_appdata,
&self.install_dir,
] {
fs::create_dir_all(dir).expect("create docker test dir");
}
}
}
fn ensure_test_image() -> String {
IMAGE_TAG
.get_or_init(|| {
let repo_root = repo_root();
let script = repo_root
.join("scripts")
.join("test-rig")
.join("ensure-image.sh");
let output = Command::new("/bin/bash")
.arg(&script)
.output()
.expect("run ensure-image.sh");
if !output.status.success() {
panic!(
"failed to build sandbox-agent test image: {}",
String::from_utf8_lossy(&output.stderr)
);
}
String::from_utf8(output.stdout)
.expect("image tag utf8")
.trim()
.to_string()
})
.clone()
}
fn build_env(layout: &TestLayout, auth: &AuthConfig) -> BTreeMap<String, String> {
let mut env = BTreeMap::new();
env.insert(
"HOME".to_string(),
layout.home.to_string_lossy().to_string(),
);
env.insert(
"USERPROFILE".to_string(),
layout.home.to_string_lossy().to_string(),
);
env.insert(
"XDG_DATA_HOME".to_string(),
layout.xdg_data_home.to_string_lossy().to_string(),
);
env.insert(
"XDG_STATE_HOME".to_string(),
layout.xdg_state_home.to_string_lossy().to_string(),
);
env.insert(
"APPDATA".to_string(),
layout.appdata.to_string_lossy().to_string(),
);
env.insert(
"LOCALAPPDATA".to_string(),
layout.local_appdata.to_string_lossy().to_string(),
);
if let Some(value) = std::env::var_os("XDG_STATE_HOME") {
env.insert(
"XDG_STATE_HOME".to_string(),
PathBuf::from(value).to_string_lossy().to_string(),
);
}
for (key, value) in std::env::vars() {
if key == "PATH" {
continue;
}
if key == "XDG_STATE_HOME" || key == "HOME" || key == "USERPROFILE" {
continue;
}
if key.starts_with("SANDBOX_AGENT_") || key.starts_with("OPENCODE_COMPAT_") {
env.insert(key.clone(), rewrite_localhost_url(&key, &value));
}
}
if let Some(token) = auth.token.as_ref() {
env.insert("SANDBOX_AGENT_TEST_AUTH_TOKEN".to_string(), token.clone());
}
let custom_path_entries =
custom_path_entries(layout.install_dir.parent().expect("install base"));
if custom_path_entries.is_empty() {
env.insert("PATH".to_string(), DEFAULT_PATH.to_string());
} else {
let joined = custom_path_entries
.iter()
.map(|path| path.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(":");
env.insert("PATH".to_string(), format!("{joined}:{DEFAULT_PATH}"));
}
env
}
fn build_mounts(root: &Path, env: &BTreeMap<String, String>) -> Vec<PathBuf> {
let mut mounts = BTreeSet::new();
mounts.insert(root.to_path_buf());
for key in [
"HOME",
"USERPROFILE",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
"APPDATA",
"LOCALAPPDATA",
"SANDBOX_AGENT_DESKTOP_FAKE_STATE_DIR",
] {
if let Some(value) = env.get(key) {
let path = PathBuf::from(value);
if path.is_absolute() {
mounts.insert(path);
}
}
}
if let Some(path_value) = env.get("PATH") {
for entry in path_value.split(':') {
if entry.is_empty() || STANDARD_PATHS.contains(&entry) {
continue;
}
let path = PathBuf::from(entry);
if path.is_absolute() && path.exists() {
mounts.insert(path);
}
}
}
mounts.into_iter().collect()
}
fn run_container(
container_id: &str,
image: &str,
mounts: &[PathBuf],
env: &BTreeMap<String, String>,
auth: &AuthConfig,
) -> String {
let mut args = vec![
"run".to_string(),
"-d".to_string(),
"--rm".to_string(),
"--name".to_string(),
container_id.to_string(),
"-p".to_string(),
format!("127.0.0.1::{CONTAINER_PORT}"),
];
#[cfg(unix)]
{
args.push("--user".to_string());
args.push(format!("{}:{}", unsafe { libc::geteuid() }, unsafe {
libc::getegid()
}));
}
if cfg!(target_os = "linux") {
args.push("--add-host".to_string());
args.push("host.docker.internal:host-gateway".to_string());
}
for mount in mounts {
args.push("-v".to_string());
args.push(format!("{}:{}", mount.display(), mount.display()));
}
for (key, value) in env {
args.push("-e".to_string());
args.push(format!("{key}={value}"));
}
args.push(image.to_string());
args.push("server".to_string());
args.push("--host".to_string());
args.push("0.0.0.0".to_string());
args.push("--port".to_string());
args.push(CONTAINER_PORT.to_string());
match auth.token.as_ref() {
Some(token) => {
args.push("--token".to_string());
args.push(token.clone());
}
None => args.push("--no-token".to_string()),
}
let output = Command::new(docker_bin())
.args(&args)
.output()
.expect("start docker test container");
if !output.status.success() {
panic!(
"failed to start docker test container: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let port_output = Command::new(docker_bin())
.args(["port", container_id, &format!("{CONTAINER_PORT}/tcp")])
.output()
.expect("resolve mapped docker port");
if !port_output.status.success() {
panic!(
"failed to resolve docker test port: {}",
String::from_utf8_lossy(&port_output.stderr)
);
}
let mapping = String::from_utf8(port_output.stdout)
.expect("docker port utf8")
.trim()
.to_string();
let host_port = mapping.rsplit(':').next().expect("mapped host port").trim();
let base_url = format!("http://127.0.0.1:{host_port}");
wait_for_health(&base_url, auth.token.as_deref());
base_url
}
fn wait_for_health(base_url: &str, token: Option<&str>) {
let started = SystemTime::now();
loop {
if probe_health(base_url, token) {
return;
}
if started
.elapsed()
.unwrap_or_else(|_| Duration::from_secs(0))
.gt(&Duration::from_secs(30))
{
panic!("timed out waiting for sandbox-agent docker test server");
}
thread::sleep(Duration::from_millis(200));
}
}
fn probe_health(base_url: &str, token: Option<&str>) -> bool {
let address = base_url.strip_prefix("http://").unwrap_or(base_url);
let mut stream = match TcpStream::connect(address) {
Ok(stream) => stream,
Err(_) => return false,
};
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));
let mut request =
format!("GET /v1/health HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n");
if let Some(token) = token {
request.push_str(&format!("Authorization: Bearer {token}\r\n"));
}
request.push_str("\r\n");
if stream.write_all(request.as_bytes()).is_err() {
return false;
}
let mut response = String::new();
if stream.read_to_string(&mut response).is_err() {
return false;
}
response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200")
}
fn custom_path_entries(root: &Path) -> Vec<PathBuf> {
let mut entries = Vec::new();
if let Some(value) = std::env::var_os("PATH") {
for entry in std::env::split_paths(&value) {
if !entry.exists() {
continue;
}
if entry.starts_with(root) || entry.starts_with(std::env::temp_dir()) {
entries.push(entry);
}
}
}
entries.sort();
entries.dedup();
entries
}
fn rewrite_localhost_url(key: &str, value: &str) -> String {
if key.ends_with("_URL") || key.ends_with("_URI") {
return value
.replace("http://127.0.0.1", "http://host.docker.internal")
.replace("http://localhost", "http://host.docker.internal");
}
value.to_string()
}
fn unique_container_id() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_millis())
.unwrap_or(0);
let counter = CONTAINER_COUNTER.fetch_add(1, Ordering::Relaxed);
format!(
"sandbox-agent-test-{}-{millis}-{counter}",
std::process::id()
)
}
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.canonicalize()
.expect("repo root")
}
fn docker_bin() -> &'static Path {
DOCKER_BIN
.get_or_init(|| {
if let Some(value) = std::env::var_os("SANDBOX_AGENT_TEST_DOCKER_BIN") {
let path = PathBuf::from(value);
if path.exists() {
return path;
}
}
for candidate in [
"/usr/local/bin/docker",
"/opt/homebrew/bin/docker",
"/usr/bin/docker",
] {
let path = PathBuf::from(candidate);
if path.exists() {
return path;
}
}
PathBuf::from("docker")
})
.as_path()
}

View file

@ -1,37 +1,14 @@
use std::fs;
use std::path::Path;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use futures::StreamExt;
use http_body_util::BodyExt;
use sandbox_agent::router::{build_router, AppState, AuthConfig};
use sandbox_agent_agent_management::agents::AgentManager;
use reqwest::{Method, StatusCode};
use sandbox_agent::router::AuthConfig;
use serde_json::{json, Value};
use tempfile::TempDir;
use tower::util::ServiceExt;
struct TestApp {
app: axum::Router,
_install_dir: TempDir,
}
impl TestApp {
fn with_setup<F>(setup: F) -> Self
where
F: FnOnce(&Path),
{
let install_dir = tempfile::tempdir().expect("create temp install dir");
setup(install_dir.path());
let manager = AgentManager::new(install_dir.path()).expect("create agent manager");
let state = AppState::new(AuthConfig::disabled(), manager);
let app = build_router(state);
Self {
app,
_install_dir: install_dir,
}
}
}
#[path = "support/docker.rs"]
mod docker_support;
use docker_support::TestApp;
fn write_executable(path: &Path, script: &str) {
fs::write(path, script).expect("write executable");
@ -101,28 +78,29 @@ fn setup_stub_agent_process_only(install_dir: &Path, agent: &str) {
}
async fn send_request(
app: &axum::Router,
app: &docker_support::DockerApp,
method: Method,
uri: &str,
body: Option<Value>,
) -> (StatusCode, Vec<u8>) {
let mut builder = Request::builder().method(method).uri(uri);
let request_body = if let Some(body) = body {
builder = builder.header("content-type", "application/json");
Body::from(body.to_string())
let client = reqwest::Client::new();
let response = if let Some(body) = body {
client
.request(method, app.http_url(uri))
.header("content-type", "application/json")
.body(body.to_string())
.send()
.await
.expect("request handled")
} else {
Body::empty()
client
.request(method, app.http_url(uri))
.send()
.await
.expect("request handled")
};
let request = builder.body(request_body).expect("build request");
let response = app.clone().oneshot(request).await.expect("request handled");
let status = response.status();
let bytes = response
.into_body()
.collect()
.await
.expect("collect body")
.to_bytes();
let bytes = response.bytes().await.expect("collect body");
(status, bytes.to_vec())
}
@ -145,7 +123,7 @@ async fn agent_process_matrix_smoke_and_jsonrpc_conformance() {
.chain(agent_process_only_agents.iter())
.copied()
.collect();
let test_app = TestApp::with_setup(|install_dir| {
let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_dir| {
for agent in native_agents {
setup_stub_artifacts(install_dir, agent);
}
@ -201,21 +179,15 @@ async fn agent_process_matrix_smoke_and_jsonrpc_conformance() {
assert_eq!(new_json["id"], 2, "{agent}: session/new id");
assert_eq!(new_json["result"]["echoedMethod"], "session/new");
let request = Request::builder()
.method(Method::GET)
.uri(format!("/v1/acp/{agent}-server"))
.body(Body::empty())
.expect("build sse request");
let response = test_app
.app
.clone()
.oneshot(request)
let response = reqwest::Client::new()
.get(test_app.app.http_url(&format!("/v1/acp/{agent}-server")))
.header("accept", "text/event-stream")
.send()
.await
.expect("sse response");
assert_eq!(response.status(), StatusCode::OK);
let mut stream = response.into_body().into_data_stream();
let mut stream = response.bytes_stream();
let chunk = tokio::time::timeout(std::time::Duration::from_secs(5), async move {
while let Some(item) = stream.next().await {
let bytes = item.expect("sse chunk");

View file

@ -1,49 +1,20 @@
use std::fs;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::net::{TcpListener, TcpStream};
use std::path::Path;
use std::time::Duration;
use axum::body::Body;
use axum::http::{header, HeaderMap, Method, Request, StatusCode};
use axum::Router;
use futures::StreamExt;
use http_body_util::BodyExt;
use sandbox_agent::router::{build_router, AppState, AuthConfig};
use sandbox_agent_agent_management::agents::AgentManager;
use reqwest::header::{self, HeaderMap, HeaderName, HeaderValue};
use reqwest::{Method, StatusCode};
use sandbox_agent::router::AuthConfig;
use serde_json::{json, Value};
use serial_test::serial;
use tempfile::TempDir;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use tower::util::ServiceExt;
struct TestApp {
app: Router,
install_dir: TempDir,
}
impl TestApp {
fn new(auth: AuthConfig) -> Self {
Self::with_setup(auth, |_| {})
}
fn with_setup<F>(auth: AuthConfig, setup: F) -> Self
where
F: FnOnce(&Path),
{
let install_dir = tempfile::tempdir().expect("create temp install dir");
setup(install_dir.path());
let manager = AgentManager::new(install_dir.path()).expect("create agent manager");
let state = AppState::new(auth, manager);
let app = build_router(state);
Self { app, install_dir }
}
fn install_path(&self) -> &Path {
self.install_dir.path()
}
}
#[path = "support/docker.rs"]
mod docker_support;
use docker_support::{LiveServer, TestApp};
struct EnvVarGuard {
key: &'static str,
@ -59,56 +30,6 @@ struct FakeDesktopEnv {
_fake_state_dir: EnvVarGuard,
}
struct LiveServer {
address: SocketAddr,
shutdown_tx: Option<oneshot::Sender<()>>,
task: JoinHandle<()>,
}
impl LiveServer {
async fn spawn(app: Router) -> Self {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind live server");
let address = listener.local_addr().expect("live server address");
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let task = tokio::spawn(async move {
let server =
axum::serve(listener, app.into_make_service()).with_graceful_shutdown(async {
let _ = shutdown_rx.await;
});
let _ = server.await;
});
Self {
address,
shutdown_tx: Some(shutdown_tx),
task,
}
}
fn http_url(&self, path: &str) -> String {
format!("http://{}{}", self.address, path)
}
fn ws_url(&self, path: &str) -> String {
format!("ws://{}{}", self.address, path)
}
async fn shutdown(mut self) {
if let Some(shutdown_tx) = self.shutdown_tx.take() {
let _ = shutdown_tx.send(());
}
let _ = tokio::time::timeout(Duration::from_secs(3), async {
let _ = self.task.await;
})
.await;
}
}
impl EnvVarGuard {
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var_os(key);
@ -352,70 +273,64 @@ fn respond_json(stream: &mut TcpStream, body: &str) {
}
async fn send_request(
app: &Router,
app: &docker_support::DockerApp,
method: Method,
uri: &str,
body: Option<Value>,
headers: &[(&str, &str)],
) -> (StatusCode, HeaderMap, Vec<u8>) {
let mut builder = Request::builder().method(method).uri(uri);
let client = reqwest::Client::new();
let mut builder = client.request(method, app.http_url(uri));
for (name, value) in headers {
builder = builder.header(*name, *value);
let header_name = HeaderName::from_bytes(name.as_bytes()).expect("header name");
let header_value = HeaderValue::from_str(value).expect("header value");
builder = builder.header(header_name, header_value);
}
let request_body = if let Some(body) = body {
builder = builder.header(header::CONTENT_TYPE, "application/json");
Body::from(body.to_string())
let response = if let Some(body) = body {
builder
.header(header::CONTENT_TYPE, "application/json")
.body(body.to_string())
.send()
.await
.expect("request handled")
} else {
Body::empty()
builder.send().await.expect("request handled")
};
let request = builder.body(request_body).expect("build request");
let response = app.clone().oneshot(request).await.expect("request handled");
let status = response.status();
let headers = response.headers().clone();
let bytes = response
.into_body()
.collect()
.await
.expect("collect body")
.to_bytes();
let bytes = response.bytes().await.expect("collect body");
(status, headers, bytes.to_vec())
}
async fn send_request_raw(
app: &Router,
app: &docker_support::DockerApp,
method: Method,
uri: &str,
body: Option<Vec<u8>>,
headers: &[(&str, &str)],
content_type: Option<&str>,
) -> (StatusCode, HeaderMap, Vec<u8>) {
let mut builder = Request::builder().method(method).uri(uri);
let client = reqwest::Client::new();
let mut builder = client.request(method, app.http_url(uri));
for (name, value) in headers {
builder = builder.header(*name, *value);
let header_name = HeaderName::from_bytes(name.as_bytes()).expect("header name");
let header_value = HeaderValue::from_str(value).expect("header value");
builder = builder.header(header_name, header_value);
}
let request_body = if let Some(body) = body {
let response = if let Some(body) = body {
if let Some(content_type) = content_type {
builder = builder.header(header::CONTENT_TYPE, content_type);
}
Body::from(body)
builder.body(body).send().await.expect("request handled")
} else {
Body::empty()
builder.send().await.expect("request handled")
};
let request = builder.body(request_body).expect("build request");
let response = app.clone().oneshot(request).await.expect("request handled");
let status = response.status();
let headers = response.headers().clone();
let bytes = response
.into_body()
.collect()
.await
.expect("collect body")
.to_bytes();
let bytes = response.bytes().await.expect("collect body");
(status, headers, bytes.to_vec())
}
@ -440,7 +355,7 @@ fn initialize_payload() -> Value {
})
}
async fn bootstrap_server(app: &Router, server_id: &str, agent: &str) {
async fn bootstrap_server(app: &docker_support::DockerApp, server_id: &str, agent: &str) {
let initialize = initialize_payload();
let (status, _, _body) = send_request(
app,
@ -453,17 +368,17 @@ async fn bootstrap_server(app: &Router, server_id: &str, agent: &str) {
assert_eq!(status, StatusCode::OK);
}
async fn read_first_sse_data(app: &Router, server_id: &str) -> String {
let request = Request::builder()
.method(Method::GET)
.uri(format!("/v1/acp/{server_id}"))
.body(Body::empty())
.expect("build request");
let response = app.clone().oneshot(request).await.expect("sse response");
async fn read_first_sse_data(app: &docker_support::DockerApp, server_id: &str) -> String {
let client = reqwest::Client::new();
let response = client
.get(app.http_url(&format!("/v1/acp/{server_id}")))
.header("accept", "text/event-stream")
.send()
.await
.expect("sse response");
assert_eq!(response.status(), StatusCode::OK);
let mut stream = response.into_body().into_data_stream();
let mut stream = response.bytes_stream();
tokio::time::timeout(Duration::from_secs(5), async move {
while let Some(chunk) = stream.next().await {
let bytes = chunk.expect("stream chunk");
@ -479,21 +394,21 @@ async fn read_first_sse_data(app: &Router, server_id: &str) -> String {
}
async fn read_first_sse_data_with_last_id(
app: &Router,
app: &docker_support::DockerApp,
server_id: &str,
last_event_id: u64,
) -> String {
let request = Request::builder()
.method(Method::GET)
.uri(format!("/v1/acp/{server_id}"))
let client = reqwest::Client::new();
let response = client
.get(app.http_url(&format!("/v1/acp/{server_id}")))
.header("accept", "text/event-stream")
.header("last-event-id", last_event_id.to_string())
.body(Body::empty())
.expect("build request");
let response = app.clone().oneshot(request).await.expect("sse response");
.send()
.await
.expect("sse response");
assert_eq!(response.status(), StatusCode::OK);
let mut stream = response.into_body().into_data_stream();
let mut stream = response.bytes_stream();
tokio::time::timeout(Duration::from_secs(5), async move {
while let Some(chunk) = stream.next().await {
let bytes = chunk.expect("stream chunk");

View file

@ -22,8 +22,9 @@ async fn mcp_config_requires_directory_and_name() {
#[tokio::test]
async fn mcp_config_crud_round_trip() {
let test_app = TestApp::new(AuthConfig::disabled());
let project = tempfile::tempdir().expect("tempdir");
let directory = project.path().to_string_lossy().to_string();
let project = test_app.root_path().join("mcp-config-project");
fs::create_dir_all(&project).expect("create project dir");
let directory = project.to_string_lossy().to_string();
let entry = json!({
"type": "local",
@ -99,8 +100,9 @@ async fn skills_config_requires_directory_and_name() {
#[tokio::test]
async fn skills_config_crud_round_trip() {
let test_app = TestApp::new(AuthConfig::disabled());
let project = tempfile::tempdir().expect("tempdir");
let directory = project.path().to_string_lossy().to_string();
let project = test_app.root_path().join("skills-config-project");
fs::create_dir_all(&project).expect("create project dir");
let directory = project.to_string_lossy().to_string();
let entry = json!({
"sources": [

View file

@ -177,20 +177,23 @@ async fn lazy_install_runs_on_first_bootstrap() {
}));
let _registry = EnvVarGuard::set("SANDBOX_AGENT_ACP_REGISTRY_URL", &registry_url);
let helper_bin_root = tempfile::tempdir().expect("helper bin tempdir");
let helper_bin = helper_bin_root.path().join("bin");
fs::create_dir_all(&helper_bin).expect("create helper bin dir");
write_fake_npm(&helper_bin.join("npm"));
let original_path = std::env::var_os("PATH").unwrap_or_default();
let mut paths = vec![helper_bin.clone()];
paths.extend(std::env::split_paths(&original_path));
let merged_path = std::env::join_paths(paths).expect("join PATH");
let _path_guard = EnvVarGuard::set_os("PATH", merged_path.as_os_str());
let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_path| {
fs::create_dir_all(install_path.join("agent_processes"))
.expect("create agent processes dir");
write_executable(&install_path.join("codex"), "#!/usr/bin/env sh\nexit 0\n");
fs::create_dir_all(install_path.join("bin")).expect("create bin dir");
write_fake_npm(&install_path.join("bin").join("npm"));
});
let original_path = std::env::var_os("PATH").unwrap_or_default();
let mut paths = vec![test_app.install_path().join("bin")];
paths.extend(std::env::split_paths(&original_path));
let merged_path = std::env::join_paths(paths).expect("join PATH");
let _path_guard = EnvVarGuard::set_os("PATH", merged_path.as_os_str());
let (status, _, _) = send_request(
&test_app.app,
Method::POST,

View file

@ -10,14 +10,8 @@ async fn v1_desktop_status_reports_install_required_when_dependencies_are_missin
let test_app = TestApp::new(AuthConfig::disabled());
let (status, _, body) = send_request(
&test_app.app,
Method::GET,
"/v1/desktop/status",
None,
&[],
)
.await;
let (status, _, body) =
send_request(&test_app.app, Method::GET, "/v1/desktop/status", None, &[]).await;
assert_eq!(status, StatusCode::OK);
let parsed = parse_json(&body);
@ -59,7 +53,10 @@ async fn v1_desktop_lifecycle_and_actions_work_with_fake_runtime() {
);
let parsed = parse_json(&body);
assert_eq!(parsed["state"], "active");
let display = parsed["display"].as_str().expect("desktop display").to_string();
let display = parsed["display"]
.as_str()
.expect("desktop display")
.to_string();
assert!(display.starts_with(':'));
assert_eq!(parsed["resolution"]["width"], 1440);
assert_eq!(parsed["resolution"]["height"], 900);
@ -209,14 +206,8 @@ async fn v1_desktop_lifecycle_and_actions_work_with_fake_runtime() {
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body)["ok"], true);
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v1/desktop/stop",
None,
&[],
)
.await;
let (status, _, body) =
send_request(&test_app.app, Method::POST, "/v1/desktop/stop", None, &[]).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body)["state"], "inactive");
}

View file

@ -413,22 +413,17 @@ async fn v1_process_logs_follow_sse_streams_entries() {
.expect("process id")
.to_string();
let request = Request::builder()
.method(Method::GET)
.uri(format!(
let response = reqwest::Client::new()
.get(test_app.app.http_url(&format!(
"/v1/processes/{process_id}/logs?stream=stdout&follow=true"
))
.body(Body::empty())
.expect("build request");
let response = test_app
.app
.clone()
.oneshot(request)
)))
.header("accept", "text/event-stream")
.send()
.await
.expect("sse response");
assert_eq!(response.status(), StatusCode::OK);
let mut stream = response.into_body().into_data_stream();
let mut stream = response.bytes_stream();
let chunk = tokio::time::timeout(Duration::from_secs(5), async move {
while let Some(chunk) = stream.next().await {
let bytes = chunk.expect("stream chunk");