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

@ -0,0 +1,28 @@
FROM rust:1.88.0-bookworm AS builder
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY server/ ./server/
COPY gigacode/ ./gigacode/
COPY resources/agent-schemas/artifacts/ ./resources/agent-schemas/artifacts/
COPY scripts/agent-configs/ ./scripts/agent-configs/
ENV SANDBOX_AGENT_SKIP_INSPECTOR=1
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/build/target \
cargo build -p sandbox-agent --release && \
cp target/release/sandbox-agent /sandbox-agent
FROM node:22-bookworm-slim
RUN apt-get update -qq && \
apt-get install -y -qq --no-install-recommends ca-certificates bash libstdc++6 > /dev/null 2>&1 && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /sandbox-agent /usr/local/bin/sandbox-agent
EXPOSE 3000
ENTRYPOINT ["sandbox-agent"]
CMD ["server", "--host", "0.0.0.0", "--port", "3000", "--no-token"]

View file

@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
IMAGE_TAG="${SANDBOX_AGENT_TEST_IMAGE:-sandbox-agent-test:dev}"
LOCK_DIR="$ROOT_DIR/.context/docker-test-image.lock"
release_lock() {
if [[ -d "$LOCK_DIR" ]]; then
rm -rf "$LOCK_DIR"
fi
}
while ! mkdir "$LOCK_DIR" 2>/dev/null; do
sleep 1
done
trap release_lock EXIT
docker build \
--tag "$IMAGE_TAG" \
--file "$ROOT_DIR/docker/test-agent/Dockerfile" \
"$ROOT_DIR" \
>/dev/null
printf '%s\n' "$IMAGE_TAG"

View file

@ -0,0 +1,279 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, "../../../..");
const ENSURE_IMAGE = resolve(REPO_ROOT, "scripts/test-rig/ensure-image.sh");
const CONTAINER_PORT = 3000;
const DEFAULT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
const STANDARD_PATHS = new Set([
"/usr/local/sbin",
"/usr/local/bin",
"/usr/sbin",
"/usr/bin",
"/sbin",
"/bin",
]);
let cachedImage: string | undefined;
let containerCounter = 0;
export type DockerSandboxAgentHandle = {
baseUrl: string;
token: string;
dispose: () => Promise<void>;
};
export type DockerSandboxAgentOptions = {
env?: Record<string, string>;
timeoutMs?: number;
};
type TestLayout = {
rootDir: string;
homeDir: string;
xdgDataHome: string;
xdgStateHome: string;
appDataDir: string;
localAppDataDir: string;
installDir: string;
};
export function createDockerTestLayout(): TestLayout {
const tempRoot = join(REPO_ROOT, ".context", "docker-test-");
mkdirSync(resolve(REPO_ROOT, ".context"), { recursive: true });
const rootDir = mkdtempSync(tempRoot);
const homeDir = join(rootDir, "home");
const xdgDataHome = join(rootDir, "xdg-data");
const xdgStateHome = join(rootDir, "xdg-state");
const appDataDir = join(rootDir, "appdata", "Roaming");
const localAppDataDir = join(rootDir, "appdata", "Local");
const installDir = join(xdgDataHome, "sandbox-agent", "bin");
for (const dir of [homeDir, xdgDataHome, xdgStateHome, appDataDir, localAppDataDir, installDir]) {
mkdirSync(dir, { recursive: true });
}
return {
rootDir,
homeDir,
xdgDataHome,
xdgStateHome,
appDataDir,
localAppDataDir,
installDir,
};
}
export function disposeDockerTestLayout(layout: TestLayout): void {
try {
rmSync(layout.rootDir, { recursive: true, force: true });
} catch (error) {
if (
typeof process.getuid === "function" &&
typeof process.getgid === "function"
) {
try {
execFileSync(
"docker",
[
"run",
"--rm",
"--user",
"0:0",
"--entrypoint",
"sh",
"-v",
`${layout.rootDir}:${layout.rootDir}`,
ensureImage(),
"-c",
`chown -R ${process.getuid()}:${process.getgid()} '${layout.rootDir}'`,
],
{ stdio: "pipe" },
);
rmSync(layout.rootDir, { recursive: true, force: true });
return;
} catch {}
}
throw error;
}
}
export async function startDockerSandboxAgent(
layout: TestLayout,
options: DockerSandboxAgentOptions = {},
): Promise<DockerSandboxAgentHandle> {
const image = ensureImage();
const containerId = uniqueContainerId();
const env = buildEnv(layout, options.env ?? {});
const mounts = buildMounts(layout.rootDir, env);
const args = [
"run",
"-d",
"--rm",
"--name",
containerId,
"-p",
`127.0.0.1::${CONTAINER_PORT}`,
];
if (typeof process.getuid === "function" && typeof process.getgid === "function") {
args.push("--user", `${process.getuid()}:${process.getgid()}`);
}
if (process.platform === "linux") {
args.push("--add-host", "host.docker.internal:host-gateway");
}
for (const mount of mounts) {
args.push("-v", `${mount}:${mount}`);
}
for (const [key, value] of Object.entries(env)) {
args.push("-e", `${key}=${value}`);
}
args.push(
image,
"server",
"--host",
"0.0.0.0",
"--port",
String(CONTAINER_PORT),
"--no-token",
);
execFileSync("docker", args, { stdio: "pipe" });
try {
const mapping = execFileSync("docker", ["port", containerId, `${CONTAINER_PORT}/tcp`], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
const hostPort = mapping.split(":").at(-1)?.trim();
if (!hostPort) {
throw new Error(`missing mapped host port in ${mapping}`);
}
const baseUrl = `http://127.0.0.1:${hostPort}`;
await waitForHealth(baseUrl, options.timeoutMs ?? 30_000);
return {
baseUrl,
token: "",
dispose: async () => {
try {
execFileSync("docker", ["rm", "-f", containerId], { stdio: "pipe" });
} catch {}
},
};
} catch (error) {
try {
execFileSync("docker", ["rm", "-f", containerId], { stdio: "pipe" });
} catch {}
throw error;
}
}
function ensureImage(): string {
if (cachedImage) {
return cachedImage;
}
cachedImage = execFileSync("bash", [ENSURE_IMAGE], {
cwd: REPO_ROOT,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
return cachedImage;
}
function buildEnv(layout: TestLayout, extraEnv: Record<string, string>): Record<string, string> {
const env: Record<string, string> = {
HOME: layout.homeDir,
USERPROFILE: layout.homeDir,
XDG_DATA_HOME: layout.xdgDataHome,
XDG_STATE_HOME: layout.xdgStateHome,
APPDATA: layout.appDataDir,
LOCALAPPDATA: layout.localAppDataDir,
PATH: DEFAULT_PATH,
};
const customPathEntries = new Set<string>();
for (const entry of (extraEnv.PATH ?? "").split(":")) {
if (!entry || entry === DEFAULT_PATH || !entry.startsWith("/")) continue;
if (entry.startsWith(layout.rootDir)) {
customPathEntries.add(entry);
}
}
if (customPathEntries.size > 0) {
env.PATH = `${Array.from(customPathEntries).join(":")}:${DEFAULT_PATH}`;
}
for (const [key, value] of Object.entries(extraEnv)) {
if (key === "PATH") {
continue;
}
env[key] = rewriteLocalhostUrl(key, value);
}
return env;
}
function buildMounts(rootDir: string, env: Record<string, string>): string[] {
const mounts = new Set<string>([rootDir]);
for (const key of [
"HOME",
"USERPROFILE",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
"APPDATA",
"LOCALAPPDATA",
"SANDBOX_AGENT_DESKTOP_FAKE_STATE_DIR",
]) {
const value = env[key];
if (value?.startsWith("/")) {
mounts.add(value);
}
}
for (const entry of (env.PATH ?? "").split(":")) {
if (entry.startsWith("/") && !STANDARD_PATHS.has(entry)) {
mounts.add(entry);
}
}
return Array.from(mounts);
}
async function waitForHealth(baseUrl: string, timeoutMs: number): Promise<void> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
try {
const response = await fetch(`${baseUrl}/v1/health`);
if (response.ok) {
return;
}
} catch {}
await new Promise((resolve) => setTimeout(resolve, 200));
}
throw new Error(`timed out waiting for sandbox-agent health at ${baseUrl}`);
}
function uniqueContainerId(): string {
containerCounter += 1;
return `sandbox-agent-ts-${process.pid}-${Date.now().toString(36)}-${containerCounter.toString(36)}`;
}
function rewriteLocalhostUrl(key: string, value: string): string {
if (key.endsWith("_URL") || key.endsWith("_URI")) {
return value
.replace("http://127.0.0.1", "http://host.docker.internal")
.replace("http://localhost", "http://host.docker.internal");
}
return value;
}

View file

@ -1,50 +1,22 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { existsSync } from "node:fs";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
import {
InMemorySessionPersistDriver,
SandboxAgent,
type SessionEvent,
} from "../src/index.ts";
import { spawnSandboxAgent, isNodeRuntime, type SandboxAgentSpawnHandle } from "../src/spawn.ts";
import { isNodeRuntime } from "../src/spawn.ts";
import {
createDockerTestLayout,
disposeDockerTestLayout,
startDockerSandboxAgent,
type DockerSandboxAgentHandle,
} from "./helpers/docker.ts";
import { prepareMockAgentDataHome } from "./helpers/mock-agent.ts";
import WebSocket from "ws";
const __dirname = dirname(fileURLToPath(import.meta.url));
function findBinary(): string | null {
if (process.env.SANDBOX_AGENT_BIN) {
return process.env.SANDBOX_AGENT_BIN;
}
const cargoPaths = [
resolve(__dirname, "../../../target/debug/sandbox-agent"),
resolve(__dirname, "../../../target/release/sandbox-agent"),
];
for (const p of cargoPaths) {
if (existsSync(p)) {
return p;
}
}
return null;
}
const BINARY_PATH = findBinary();
if (!BINARY_PATH) {
throw new Error(
"sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN.",
);
}
if (!process.env.SANDBOX_AGENT_BIN) {
process.env.SANDBOX_AGENT_BIN = BINARY_PATH;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@ -145,7 +117,7 @@ function decodeProcessLogData(data: string, encoding: string): string {
function nodeCommand(source: string): { command: string; args: string[] } {
return {
command: process.execPath,
command: "node",
args: ["-e", source],
};
}
@ -293,32 +265,29 @@ esac
}
describe("Integration: TypeScript SDK flat session API", () => {
let handle: SandboxAgentSpawnHandle;
let handle: DockerSandboxAgentHandle;
let baseUrl: string;
let token: string;
let dataHome: string;
let desktopHome: string;
let layout: ReturnType<typeof createDockerTestLayout>;
beforeAll(async () => {
dataHome = mkdtempSync(join(tmpdir(), "sdk-integration-"));
desktopHome = mkdtempSync(join(tmpdir(), "sdk-desktop-"));
const agentEnv = prepareMockAgentDataHome(dataHome);
const desktopEnv = prepareFakeDesktopEnv(desktopHome);
beforeEach(async () => {
layout = createDockerTestLayout();
prepareMockAgentDataHome(layout.xdgDataHome);
const desktopEnv = prepareFakeDesktopEnv(layout.rootDir);
handle = await spawnSandboxAgent({
enabled: true,
log: "silent",
handle = await startDockerSandboxAgent(layout, {
timeoutMs: 30000,
env: { ...agentEnv, ...desktopEnv },
env: desktopEnv,
});
baseUrl = handle.baseUrl;
token = handle.token;
});
afterAll(async () => {
await handle.dispose();
rmSync(dataHome, { recursive: true, force: true });
rmSync(desktopHome, { recursive: true, force: true });
afterEach(async () => {
await handle?.dispose?.();
if (layout) {
disposeDockerTestLayout(layout);
}
});
it("detects Node.js runtime", () => {
@ -376,11 +345,12 @@ describe("Integration: TypeScript SDK flat session API", () => {
token,
});
const directory = mkdtempSync(join(tmpdir(), "sdk-fs-"));
const directory = join(layout.rootDir, "fs-test");
const nestedDir = join(directory, "nested");
const filePath = join(directory, "notes.txt");
const movedPath = join(directory, "notes-moved.txt");
const uploadDir = join(directory, "uploaded");
mkdirSync(directory, { recursive: true });
try {
const listedAgents = await sdk.listAgents({ config: true, noCache: true });
@ -777,7 +747,9 @@ describe("Integration: TypeScript SDK flat session API", () => {
token,
});
const directory = mkdtempSync(join(tmpdir(), "sdk-config-"));
const directory = join(layout.rootDir, "config-test");
mkdirSync(directory, { recursive: true });
const mcpConfig = {
type: "local" as const,

View file

@ -4,5 +4,6 @@ export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
testTimeout: 30000,
hookTimeout: 120000,
},
});

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");