From 25f8491c6d2f027813e669a650920b12f669f765 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 8 Mar 2026 00:09:01 -0800 Subject: [PATCH] Add Docker-backed integration test rig --- docker/test-agent/Dockerfile | 28 + scripts/test-rig/ensure-image.sh | 26 + sdks/typescript/tests/helpers/docker.ts | 279 ++++++++++ sdks/typescript/tests/integration.test.ts | 82 +-- sdks/typescript/vitest.config.ts | 1 + server/packages/sandbox-agent/src/cli.rs | 4 +- .../sandbox-agent/src/desktop_errors.rs | 58 +- .../sandbox-agent/src/desktop_install.rs | 14 +- .../sandbox-agent/src/desktop_runtime.rs | 160 ++++-- server/packages/sandbox-agent/src/lib.rs | 8 +- server/packages/sandbox-agent/src/router.rs | 15 +- .../sandbox-agent/tests/support/docker.rs | 496 ++++++++++++++++++ .../tests/v1_agent_process_matrix.rs | 82 +-- server/packages/sandbox-agent/tests/v1_api.rs | 187 ++----- .../tests/v1_api/config_endpoints.rs | 10 +- .../tests/v1_api/control_plane.rs | 19 +- .../sandbox-agent/tests/v1_api/desktop.rs | 25 +- .../sandbox-agent/tests/v1_api/processes.rs | 17 +- 18 files changed, 1138 insertions(+), 373 deletions(-) create mode 100644 docker/test-agent/Dockerfile create mode 100755 scripts/test-rig/ensure-image.sh create mode 100644 sdks/typescript/tests/helpers/docker.ts create mode 100644 server/packages/sandbox-agent/tests/support/docker.rs diff --git a/docker/test-agent/Dockerfile b/docker/test-agent/Dockerfile new file mode 100644 index 0000000..cd6737f --- /dev/null +++ b/docker/test-agent/Dockerfile @@ -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"] diff --git a/scripts/test-rig/ensure-image.sh b/scripts/test-rig/ensure-image.sh new file mode 100755 index 0000000..2aa7c02 --- /dev/null +++ b/scripts/test-rig/ensure-image.sh @@ -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" diff --git a/sdks/typescript/tests/helpers/docker.ts b/sdks/typescript/tests/helpers/docker.ts new file mode 100644 index 0000000..353de6c --- /dev/null +++ b/sdks/typescript/tests/helpers/docker.ts @@ -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; +}; + +export type DockerSandboxAgentOptions = { + env?: Record; + 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 { + 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): Record { + const env: Record = { + 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(); + 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[] { + const mounts = new Set([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 { + 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; +} diff --git a/sdks/typescript/tests/integration.test.ts b/sdks/typescript/tests/integration.test.ts index 8cc343d..3e0dc52 100644 --- a/sdks/typescript/tests/integration.test.ts +++ b/sdks/typescript/tests/integration.test.ts @@ -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 { 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; - 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, diff --git a/sdks/typescript/vitest.config.ts b/sdks/typescript/vitest.config.ts index 8676010..a3ba3f3 100644 --- a/sdks/typescript/vitest.config.ts +++ b/sdks/typescript/vitest.config.ts @@ -4,5 +4,6 @@ export default defineConfig({ test: { include: ["tests/**/*.test.ts"], testTimeout: 30000, + hookTimeout: 120000, }, }); diff --git a/server/packages/sandbox-agent/src/cli.rs b/server/packages/sandbox-agent/src/cli.rs index 970bd17..9831cfa 100644 --- a/server/packages/sandbox-agent/src/cli.rs +++ b/server/packages/sandbox-agent/src/cli.rs @@ -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, }; diff --git a/server/packages/sandbox-agent/src/desktop_errors.rs b/server/packages/sandbox-agent/src/desktop_errors.rs index 8d81c61..5a3a016 100644 --- a/server/packages/sandbox-agent/src/desktop_errors.rs +++ b/server/packages/sandbox-agent/src/desktop_errors.rs @@ -16,7 +16,12 @@ pub struct DesktopProblem { impl DesktopProblem { pub fn unsupported_platform(message: impl Into) -> 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) -> 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) -> 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, processes: Vec, ) -> 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) -> 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, processes: Vec) -> Self { - Self::new(502, "Desktop Screenshot Failed", "desktop_screenshot_failed", message) - .with_processes(processes) + pub fn screenshot_failed( + message: impl Into, + processes: Vec, + ) -> Self { + Self::new( + 502, + "Desktop Screenshot Failed", + "desktop_screenshot_failed", + message, + ) + .with_processes(processes) } pub fn input_failed(message: impl Into, processes: Vec) -> 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 { diff --git a/server/packages/sandbox-agent/src/desktop_install.rs b/server/packages/sandbox-agent/src/desktop_install.rs index fa5766b..5b8a306 100644 --- a/server/packages/sandbox-agent/src/desktop_install.rs +++ b/server/packages/sandbox-agent/src/desktop_install.rs @@ -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 { None } -fn desktop_packages( - package_manager: DesktopPackageManager, - no_fonts: bool, -) -> Vec { +fn desktop_packages(package_manager: DesktopPackageManager, no_fonts: bool) -> Vec { let mut packages = match package_manager { DesktopPackageManager::Apt => vec![ "xvfb", diff --git a/server/packages/sandbox-agent/src/desktop_runtime.rs b/server/packages/sandbox-agent/src/desktop_runtime.rs index 09ff55a..f434f83 100644 --- a/server/packages/sandbox-agent/src/desktop_runtime.rs +++ b/server/packages/sandbox-agent/src/desktop_runtime.rs @@ -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 { @@ -393,9 +398,7 @@ impl DesktopRuntime { request: DesktopKeyboardTypeRequest, ) -> Result { 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, 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, 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 { if let Some(current) = parts.next() { let dims: Vec<&str> = current.split_whitespace().collect(); if dims.len() >= 3 { - let width = dims[0].parse::().map_err(|_| "failed to parse xrandr width".to_string())?; - let height = dims[2].parse::().map_err(|_| "failed to parse xrandr height".to_string())?; + let width = dims[0] + .parse::() + .map_err(|_| "failed to parse xrandr width".to_string())?; + let height = dims[2] + .parse::() + .map_err(|_| "failed to parse xrandr height".to_string())?; return Ok(DesktopResolution { width, height, diff --git a/server/packages/sandbox-agent/src/lib.rs b/server/packages/sandbox-agent/src/lib.rs index e128b7c..4ec2623 100644 --- a/server/packages/sandbox-agent/src/lib.rs +++ b/server/packages/sandbox-agent/src/lib.rs @@ -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; diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 44db01f..757b36b 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -190,13 +190,22 @@ pub fn build_router_with_state(shared: Arc) -> (Router, Arc) "/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)) diff --git a/server/packages/sandbox-agent/tests/support/docker.rs b/server/packages/sandbox-agent/tests/support/docker.rs new file mode 100644 index 0000000..af4d81c --- /dev/null +++ b/server/packages/sandbox-agent/tests/support/docker.rs @@ -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 = OnceLock::new(); +static DOCKER_BIN: OnceLock = 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(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 { + 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::>() + .join(":"); + env.insert("PATH".to_string(), format!("{joined}:{DEFAULT_PATH}")); + } + + env +} + +fn build_mounts(root: &Path, env: &BTreeMap) -> Vec { + 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, + 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 { + 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() +} diff --git a/server/packages/sandbox-agent/tests/v1_agent_process_matrix.rs b/server/packages/sandbox-agent/tests/v1_agent_process_matrix.rs index 029ca25..fc88c4c 100644 --- a/server/packages/sandbox-agent/tests/v1_agent_process_matrix.rs +++ b/server/packages/sandbox-agent/tests/v1_agent_process_matrix.rs @@ -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(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, ) -> (StatusCode, Vec) { - 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"); diff --git a/server/packages/sandbox-agent/tests/v1_api.rs b/server/packages/sandbox-agent/tests/v1_api.rs index 70b28f9..354088a 100644 --- a/server/packages/sandbox-agent/tests/v1_api.rs +++ b/server/packages/sandbox-agent/tests/v1_api.rs @@ -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(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>, - 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, headers: &[(&str, &str)], ) -> (StatusCode, HeaderMap, Vec) { - 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>, headers: &[(&str, &str)], content_type: Option<&str>, ) -> (StatusCode, HeaderMap, Vec) { - 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"); diff --git a/server/packages/sandbox-agent/tests/v1_api/config_endpoints.rs b/server/packages/sandbox-agent/tests/v1_api/config_endpoints.rs index 3aec8ca..e212c86 100644 --- a/server/packages/sandbox-agent/tests/v1_api/config_endpoints.rs +++ b/server/packages/sandbox-agent/tests/v1_api/config_endpoints.rs @@ -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": [ diff --git a/server/packages/sandbox-agent/tests/v1_api/control_plane.rs b/server/packages/sandbox-agent/tests/v1_api/control_plane.rs index dc352ca..d6e2129 100644 --- a/server/packages/sandbox-agent/tests/v1_api/control_plane.rs +++ b/server/packages/sandbox-agent/tests/v1_api/control_plane.rs @@ -177,20 +177,23 @@ async fn lazy_install_runs_on_first_bootstrap() { })); let _registry = EnvVarGuard::set("SANDBOX_AGENT_ACP_REGISTRY_URL", ®istry_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, diff --git a/server/packages/sandbox-agent/tests/v1_api/desktop.rs b/server/packages/sandbox-agent/tests/v1_api/desktop.rs index c56e7e1..e250ec1 100644 --- a/server/packages/sandbox-agent/tests/v1_api/desktop.rs +++ b/server/packages/sandbox-agent/tests/v1_api/desktop.rs @@ -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"); } diff --git a/server/packages/sandbox-agent/tests/v1_api/processes.rs b/server/packages/sandbox-agent/tests/v1_api/processes.rs index 3c02029..8a03643 100644 --- a/server/packages/sandbox-agent/tests/v1_api/processes.rs +++ b/server/packages/sandbox-agent/tests/v1_api/processes.rs @@ -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");