diff --git a/docker/test-agent/Dockerfile b/docker/test-agent/Dockerfile index cd6737f..5494a4c 100644 --- a/docker/test-agent/Dockerfile +++ b/docker/test-agent/Dockerfile @@ -17,12 +17,25 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ 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 && \ + apt-get install -y -qq --no-install-recommends \ + ca-certificates \ + bash \ + libstdc++6 \ + xvfb \ + openbox \ + xdotool \ + imagemagick \ + x11-xserver-utils \ + dbus-x11 \ + xauth \ + fonts-dejavu-core \ + xterm \ + > /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"] +ENTRYPOINT ["/usr/local/bin/sandbox-agent"] CMD ["server", "--host", "0.0.0.0", "--port", "3000", "--no-token"] diff --git a/frontend/packages/inspector/src/components/debug/DesktopTab.test.tsx b/frontend/packages/inspector/src/components/debug/DesktopTab.test.tsx index 8b7465e..4a5579f 100644 --- a/frontend/packages/inspector/src/components/debug/DesktopTab.test.tsx +++ b/frontend/packages/inspector/src/components/debug/DesktopTab.test.tsx @@ -2,149 +2,149 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { SandboxAgent } from "sandbox-agent"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SandboxAgent } from "sandbox-agent"; +import { + createDockerTestLayout, + disposeDockerTestLayout, + startDockerSandboxAgent, + type DockerSandboxAgentHandle, +} from "../../../../../../sdks/typescript/tests/helpers/docker.ts"; import DesktopTab from "./DesktopTab"; -type MockDesktopClient = Pick< - SandboxAgent, - "getDesktopStatus" | "startDesktop" | "stopDesktop" | "takeDesktopScreenshot" ->; +type DockerTestLayout = ReturnType; -describe("DesktopTab", () => { +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitFor( + fn: () => T | undefined | null, + timeoutMs = 20_000, + stepMs = 50, +): Promise { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const value = fn(); + if (value !== undefined && value !== null) { + return value; + } + await sleep(stepMs); + } + throw new Error("timed out waiting for condition"); +} + +function findButton(container: HTMLElement, label: string): HTMLButtonElement | undefined { + return Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes(label), + ) as HTMLButtonElement | undefined; +} + +describe.sequential("DesktopTab", () => { let container: HTMLDivElement; let root: Root; - let createObjectUrl: ReturnType; - let revokeObjectUrl: ReturnType; + let layout: DockerTestLayout | undefined; + let handle: DockerSandboxAgentHandle | undefined; + let client: SandboxAgent | undefined; beforeEach(() => { - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); - createObjectUrl = vi.fn(() => "blob:test-screenshot"); - revokeObjectUrl = vi.fn(); - vi.stubGlobal( - "URL", - Object.assign(URL, { - createObjectURL: createObjectUrl, - revokeObjectURL: revokeObjectUrl, - }), - ); }); afterEach(async () => { await act(async () => { root.unmount(); }); + if (client) { + await client.stopDesktop().catch(() => {}); + await client.dispose().catch(() => {}); + } + if (handle) { + await handle.dispose(); + } + if (layout) { + disposeDockerTestLayout(layout); + } container.remove(); - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - vi.unstubAllGlobals(); + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; + client = undefined; + handle = undefined; + layout = undefined; }); + async function connectDesktopClient(options?: { pathMode?: "merge" | "replace" }): Promise { + layout = createDockerTestLayout(); + handle = await startDockerSandboxAgent(layout, { + timeoutMs: 30_000, + pathMode: options?.pathMode, + env: options?.pathMode === "replace" + ? { PATH: layout.rootDir } + : undefined, + }); + client = await SandboxAgent.connect({ + baseUrl: handle.baseUrl, + token: handle.token, + }); + return client; + } + it("renders install remediation when desktop deps are missing", async () => { - const client = { - getDesktopStatus: vi.fn().mockResolvedValue({ - state: "install_required", - display: null, - resolution: null, - startedAt: null, - lastError: { - code: "desktop_dependencies_missing", - message: "Desktop dependencies are not installed", - }, - missingDependencies: ["Xvfb", "openbox"], - installCommand: "sandbox-agent install desktop --yes", - processes: [], - runtimeLogPath: "/tmp/runtime.log", - }), - startDesktop: vi.fn(), - stopDesktop: vi.fn(), - takeDesktopScreenshot: vi.fn(), - } as unknown as MockDesktopClient; + const connectedClient = await connectDesktopClient({ pathMode: "replace" }); await act(async () => { - root.render( client as unknown as SandboxAgent} />); + root.render( connectedClient} />); + }); + + await waitFor(() => { + const text = container.textContent ?? ""; + return text.includes("install_required") ? text : undefined; }); expect(container.textContent).toContain("install_required"); expect(container.textContent).toContain("sandbox-agent install desktop --yes"); expect(container.textContent).toContain("Xvfb"); - expect(client.getDesktopStatus).toHaveBeenCalledTimes(1); }); it("starts desktop, refreshes screenshot, and stops desktop", async () => { - const client = { - getDesktopStatus: vi.fn().mockResolvedValue({ - state: "inactive", - display: null, - resolution: null, - startedAt: null, - lastError: null, - missingDependencies: [], - installCommand: null, - processes: [], - runtimeLogPath: null, - }), - startDesktop: vi.fn().mockResolvedValue({ - state: "active", - display: ":99", - resolution: { width: 1440, height: 900, dpi: 96 }, - startedAt: "2026-03-07T00:00:00Z", - lastError: null, - missingDependencies: [], - installCommand: null, - processes: [], - runtimeLogPath: "/tmp/runtime.log", - }), - stopDesktop: vi.fn().mockResolvedValue({ - state: "inactive", - display: null, - resolution: null, - startedAt: null, - lastError: null, - missingDependencies: [], - installCommand: null, - processes: [], - runtimeLogPath: "/tmp/runtime.log", - }), - takeDesktopScreenshot: vi.fn().mockResolvedValue( - new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), - ), - } as unknown as MockDesktopClient; + const connectedClient = await connectDesktopClient(); await act(async () => { - root.render( client as unknown as SandboxAgent} />); + root.render( connectedClient} />); }); - const startButton = Array.from(container.querySelectorAll("button")).find((button) => - button.textContent?.includes("Start Desktop"), - ); - expect(startButton).toBeTruthy(); + await waitFor(() => { + const text = container.textContent ?? ""; + return text.includes("inactive") ? true : undefined; + }); + const startButton = await waitFor(() => findButton(container, "Start Desktop")); await act(async () => { - startButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await vi.runAllTimersAsync(); + startButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => { + const screenshot = container.querySelector("img[alt='Desktop screenshot']") as HTMLImageElement | null; + return screenshot?.src ? screenshot : undefined; }); - expect(client.startDesktop).toHaveBeenCalledTimes(1); - expect(client.takeDesktopScreenshot).toHaveBeenCalled(); const screenshot = container.querySelector("img[alt='Desktop screenshot']") as HTMLImageElement | null; - expect(screenshot?.src).toContain("blob:test-screenshot"); - - const stopButton = Array.from(container.querySelectorAll("button")).find((button) => - button.textContent?.includes("Stop Desktop"), - ); - expect(stopButton).toBeTruthy(); + expect(screenshot).toBeTruthy(); + expect(screenshot?.src.startsWith("blob:") || screenshot?.src.startsWith("data:image/png")).toBe(true); + expect(container.textContent).toContain("active"); + const stopButton = await waitFor(() => findButton(container, "Stop Desktop")); await act(async () => { - stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await vi.runAllTimersAsync(); + stopButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => { + const text = container.textContent ?? ""; + return text.includes("inactive") ? true : undefined; }); - expect(client.stopDesktop).toHaveBeenCalledTimes(1); expect(container.textContent).toContain("inactive"); }); }); diff --git a/frontend/packages/inspector/src/components/debug/DesktopTab.tsx b/frontend/packages/inspector/src/components/debug/DesktopTab.tsx index 2d531da..1a66585 100644 --- a/frontend/packages/inspector/src/components/debug/DesktopTab.tsx +++ b/frontend/packages/inspector/src/components/debug/DesktopTab.tsx @@ -22,6 +22,29 @@ const formatStartedAt = (value: string | null | undefined): string => { return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString(); }; +const createScreenshotUrl = async (bytes: Uint8Array): Promise => { + const payload = new Uint8Array(bytes.byteLength); + payload.set(bytes); + const blob = new Blob([payload.buffer], { type: "image/png" }); + + if (typeof URL.createObjectURL === "function") { + return URL.createObjectURL(blob); + } + + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error ?? new Error("Unable to read screenshot blob.")); + reader.onload = () => { + if (typeof reader.result === "string") { + resolve(reader.result); + } else { + reject(new Error("Unable to read screenshot blob.")); + } + }; + reader.readAsDataURL(blob); + }); +}; + const DesktopTab = ({ getClient, }: { @@ -43,7 +66,7 @@ const DesktopTab = ({ const revokeScreenshotUrl = useCallback(() => { setScreenshotUrl((current) => { - if (current) { + if (current?.startsWith("blob:") && typeof URL.revokeObjectURL === "function") { URL.revokeObjectURL(current); } return null; @@ -76,10 +99,7 @@ const DesktopTab = ({ try { const bytes = await getClient().takeDesktopScreenshot(); revokeScreenshotUrl(); - const payload = new Uint8Array(bytes.byteLength); - payload.set(bytes); - const blob = new Blob([payload.buffer], { type: "image/png" }); - setScreenshotUrl(URL.createObjectURL(blob)); + setScreenshotUrl(await createScreenshotUrl(bytes)); } catch (captureError) { revokeScreenshotUrl(); setScreenshotError(extractErrorMessage(captureError, "Unable to capture desktop screenshot.")); diff --git a/sdks/typescript/tests/helpers/docker.ts b/sdks/typescript/tests/helpers/docker.ts index 353de6c..7c610ba 100644 --- a/sdks/typescript/tests/helpers/docker.ts +++ b/sdks/typescript/tests/helpers/docker.ts @@ -28,6 +28,7 @@ export type DockerSandboxAgentHandle = { export type DockerSandboxAgentOptions = { env?: Record; + pathMode?: "merge" | "replace"; timeoutMs?: number; }; @@ -107,7 +108,7 @@ export async function startDockerSandboxAgent( ): Promise { const image = ensureImage(); const containerId = uniqueContainerId(); - const env = buildEnv(layout, options.env ?? {}); + const env = buildEnv(layout, options.env ?? {}, options.pathMode ?? "merge"); const mounts = buildMounts(layout.rootDir, env); const args = [ @@ -190,7 +191,11 @@ function ensureImage(): string { return cachedImage; } -function buildEnv(layout: TestLayout, extraEnv: Record): Record { +function buildEnv( + layout: TestLayout, + extraEnv: Record, + pathMode: "merge" | "replace", +): Record { const env: Record = { HOME: layout.homeDir, USERPROFILE: layout.homeDir, @@ -208,7 +213,9 @@ function buildEnv(layout: TestLayout, extraEnv: Record): Record< customPathEntries.add(entry); } } - if (customPathEntries.size > 0) { + if (pathMode === "replace") { + env.PATH = extraEnv.PATH ?? ""; + } else if (customPathEntries.size > 0) { env.PATH = `${Array.from(customPathEntries).join(":")}:${DEFAULT_PATH}`; } diff --git a/sdks/typescript/tests/integration.test.ts b/sdks/typescript/tests/integration.test.ts index 825d674..4448591 100644 --- a/sdks/typescript/tests/integration.test.ts +++ b/sdks/typescript/tests/integration.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { @@ -87,6 +87,15 @@ async function waitForAsync(fn: () => Promise, timeoutM throw new Error("timed out waiting for condition"); } +async function withTimeout(promise: Promise, label: string, timeoutMs = 15_000): Promise { + return await Promise.race([ + promise, + sleep(timeoutMs).then(() => { + throw new Error(`${label} timed out after ${timeoutMs}ms`); + }), + ]); +} + function buildTarArchive(entries: Array<{ name: string; content: string }>): Uint8Array { const blocks: Buffer[] = []; @@ -177,146 +186,37 @@ function forwardRequest( return defaultFetch(forwardedUrl, forwardedInit); } -function writeExecutable(path: string, source: string): void { - writeFileSync(path, source, "utf8"); - chmodSync(path, 0o755); -} +async function launchDesktopFocusWindow(sdk: SandboxAgent, display: string): Promise { + const windowProcess = await sdk.createProcess({ + command: "xterm", + args: [ + "-geometry", + "80x24+40+40", + "-title", + "Sandbox Desktop Test", + "-e", + "sh", + "-lc", + "sleep 60", + ], + env: { DISPLAY: display }, + }); -function prepareFakeDesktopEnv(root: string): Record { - const binDir = join(root, "bin"); - const xdgStateHome = join(root, "xdg-state"); - const fakeStateDir = join(root, "fake-state"); - mkdirSync(binDir, { recursive: true }); - mkdirSync(xdgStateHome, { recursive: true }); - mkdirSync(fakeStateDir, { recursive: true }); + await waitForAsync(async () => { + const result = await sdk.runProcess({ + command: "sh", + args: [ + "-lc", + "wid=\"$(xdotool search --onlyvisible --name 'Sandbox Desktop Test' 2>/dev/null | head -n 1 || true)\"; if [ -z \"$wid\" ]; then exit 3; fi; xdotool windowactivate \"$wid\"", + ], + env: { DISPLAY: display }, + timeoutMs: 5_000, + }); - writeExecutable( - join(binDir, "Xvfb"), - `#!/usr/bin/env sh -set -eu -display="\${1:-:191}" -number="\${display#:}" -socket="/tmp/.X11-unix/X\${number}" -mkdir -p /tmp/.X11-unix -touch "$socket" -cleanup() { - rm -f "$socket" - exit 0 -} -trap cleanup INT TERM EXIT -while :; do - sleep 1 -done -`, - ); + return result.exitCode === 0 ? true : undefined; + }, 10_000, 200); - writeExecutable( - join(binDir, "openbox"), - `#!/usr/bin/env sh -set -eu -trap 'exit 0' INT TERM -while :; do - sleep 1 -done -`, - ); - - writeExecutable( - join(binDir, "dbus-launch"), - `#!/usr/bin/env sh -set -eu -echo "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/sandbox-agent-test-bus" -echo "DBUS_SESSION_BUS_PID=$$" -`, - ); - - writeExecutable( - join(binDir, "xrandr"), - `#!/usr/bin/env sh -set -eu -cat <<'EOF' -Screen 0: minimum 1 x 1, current 1440 x 900, maximum 32767 x 32767 -EOF -`, - ); - - writeExecutable( - join(binDir, "import"), - `#!/usr/bin/env sh -set -eu -printf '\\211PNG\\r\\n\\032\\n\\000\\000\\000\\rIHDR\\000\\000\\000\\001\\000\\000\\000\\001\\010\\006\\000\\000\\000\\037\\025\\304\\211\\000\\000\\000\\013IDATx\\234c\\000\\001\\000\\000\\005\\000\\001\\r\\n-\\264\\000\\000\\000\\000IEND\\256B\`\\202' -`, - ); - - writeExecutable( - join(binDir, "xdotool"), - `#!/usr/bin/env sh -set -eu -state_dir="\${SANDBOX_AGENT_DESKTOP_FAKE_STATE_DIR:?missing fake state dir}" -state_file="\${state_dir}/mouse" -mkdir -p "$state_dir" -if [ ! -f "$state_file" ]; then - printf '0 0\\n' > "$state_file" -fi - -read_state() { - read -r x y < "$state_file" -} - -write_state() { - printf '%s %s\\n' "$1" "$2" > "$state_file" -} - -command="\${1:-}" -case "$command" in - getmouselocation) - read_state - printf 'X=%s\\nY=%s\\nSCREEN=0\\nWINDOW=0\\n' "$x" "$y" - ;; - mousemove) - shift - x="\${1:-0}" - y="\${2:-0}" - shift 2 || true - while [ "$#" -gt 0 ]; do - token="$1" - shift - case "$token" in - mousemove) - x="\${1:-0}" - y="\${2:-0}" - shift 2 || true - ;; - mousedown|mouseup) - shift 1 || true - ;; - click) - if [ "\${1:-}" = "--repeat" ]; then - shift 2 || true - fi - shift 1 || true - ;; - esac - done - write_state "$x" "$y" - ;; - type|key) - exit 0 - ;; - *) - exit 0 - ;; -esac -`, - ); - - return { - SANDBOX_AGENT_DESKTOP_TEST_ASSUME_LINUX: "1", - SANDBOX_AGENT_DESKTOP_DISPLAY_NUM: "191", - SANDBOX_AGENT_DESKTOP_FAKE_STATE_DIR: fakeStateDir, - XDG_STATE_HOME: xdgStateHome, - PATH: `${binDir}:${process.env.PATH ?? ""}`, - }; + return windowProcess.id; } describe("Integration: TypeScript SDK flat session API", () => { @@ -328,11 +228,9 @@ describe("Integration: TypeScript SDK flat session API", () => { beforeEach(async () => { layout = createDockerTestLayout(); prepareMockAgentDataHome(layout.xdgDataHome); - const desktopEnv = prepareFakeDesktopEnv(layout.rootDir); handle = await startDockerSandboxAgent(layout, { timeoutMs: 30000, - env: desktopEnv, }); baseUrl = handle.baseUrl; token = handle.token; @@ -490,16 +388,26 @@ describe("Integration: TypeScript SDK flat session API", () => { token, fetch: customFetch, }); + let sessionId: string | undefined; - await sdk.getHealth(); - const session = await sdk.createSession({ agent: "mock" }); - expect(session.agent).toBe("mock"); - await sdk.destroySession(session.id); + try { + await withTimeout(sdk.getHealth(), "custom fetch getHealth"); + const session = await withTimeout( + sdk.createSession({ agent: "mock" }), + "custom fetch createSession", + ); + sessionId = session.id; + expect(session.agent).toBe("mock"); + await withTimeout(sdk.destroySession(session.id), "custom fetch destroySession"); - expect(seenPaths).toContain("/v1/health"); - expect(seenPaths.some((path) => path.startsWith("/v1/acp/"))).toBe(true); - - await sdk.dispose(); + expect(seenPaths).toContain("/v1/health"); + expect(seenPaths.some((path) => path.startsWith("/v1/acp/"))).toBe(true); + } finally { + if (sessionId) { + await sdk.destroySession(sessionId).catch(() => {}); + } + await withTimeout(sdk.dispose(), "custom fetch dispose"); + } }, 60_000); it("requires baseUrl when fetch is not provided", async () => { @@ -1103,6 +1011,7 @@ describe("Integration: TypeScript SDK flat session API", () => { baseUrl, token, }); + let focusWindowProcessId: string | undefined; try { const initialStatus = await sdk.getDesktopStatus(); @@ -1168,6 +1077,8 @@ describe("Integration: TypeScript SDK flat session API", () => { expect(position.x).toBe(80); expect(position.y).toBe(90); + focusWindowProcessId = await launchDesktopFocusWindow(sdk, started.display!); + const typed = await sdk.typeDesktopText({ text: "hello desktop", delayMs: 5, @@ -1180,6 +1091,10 @@ describe("Integration: TypeScript SDK flat session API", () => { const stopped = await sdk.stopDesktop(); expect(stopped.state).toBe("inactive"); } finally { + if (focusWindowProcessId) { + await sdk.killProcess(focusWindowProcessId, { waitMs: 5_000 }).catch(() => {}); + await sdk.deleteProcess(focusWindowProcessId).catch(() => {}); + } await sdk.stopDesktop().catch(() => {}); await sdk.dispose(); } diff --git a/server/packages/sandbox-agent/tests/support/docker.rs b/server/packages/sandbox-agent/tests/support/docker.rs index 3315e49..6ebf87f 100644 --- a/server/packages/sandbox-agent/tests/support/docker.rs +++ b/server/packages/sandbox-agent/tests/support/docker.rs @@ -53,12 +53,25 @@ pub struct TestApp { container_id: String, } +#[derive(Default)] +pub struct TestAppOptions { + pub env: BTreeMap, + pub replace_path: bool, +} + 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), + { + Self::with_options(auth, TestAppOptions::default(), setup) + } + + pub fn with_options(auth: AuthConfig, options: TestAppOptions, setup: F) -> Self where F: FnOnce(&Path), { @@ -69,7 +82,7 @@ impl TestApp { let container_id = unique_container_id(); let image = ensure_test_image(); - let env = build_env(&layout, &auth); + let env = build_env(&layout, &auth, &options); let mounts = build_mounts(root.path(), &env); let base_url = run_container(&container_id, &image, &mounts, &env, &auth); @@ -191,7 +204,11 @@ fn ensure_test_image() -> String { .clone() } -fn build_env(layout: &TestLayout, auth: &AuthConfig) -> BTreeMap { +fn build_env( + layout: &TestLayout, + auth: &AuthConfig, + options: &TestAppOptions, +) -> BTreeMap { let mut env = BTreeMap::new(); env.insert( "HOME".to_string(), @@ -240,20 +257,35 @@ fn build_env(layout: &TestLayout, auth: &AuthConfig) -> BTreeMap env.insert("SANDBOX_AGENT_TEST_AUTH_TOKEN".to_string(), token.clone()); } - let mut custom_path_entries = custom_path_entries(layout.install_dir.parent().expect("install base")); - custom_path_entries.extend(explicit_path_entries()); - custom_path_entries.sort(); - custom_path_entries.dedup(); - - if custom_path_entries.is_empty() { - env.insert("PATH".to_string(), DEFAULT_PATH.to_string()); + if options.replace_path { + env.insert( + "PATH".to_string(), + options.env.get("PATH").cloned().unwrap_or_default(), + ); } 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}")); + let mut custom_path_entries = + custom_path_entries(layout.install_dir.parent().expect("install base")); + custom_path_entries.extend(explicit_path_entries()); + custom_path_entries.sort(); + custom_path_entries.dedup(); + + 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}")); + } + } + + for (key, value) in &options.env { + if key == "PATH" { + continue; + } + env.insert(key.clone(), value.clone()); } env diff --git a/server/packages/sandbox-agent/tests/v1_api.rs b/server/packages/sandbox-agent/tests/v1_api.rs index 354088a..e6be9ca 100644 --- a/server/packages/sandbox-agent/tests/v1_api.rs +++ b/server/packages/sandbox-agent/tests/v1_api.rs @@ -10,7 +10,6 @@ use reqwest::{Method, StatusCode}; use sandbox_agent::router::AuthConfig; use serde_json::{json, Value}; use serial_test::serial; -use tempfile::TempDir; #[path = "support/docker.rs"] mod docker_support; @@ -21,15 +20,6 @@ struct EnvVarGuard { previous: Option, } -struct FakeDesktopEnv { - _temp: TempDir, - _path: EnvVarGuard, - _xdg_state_home: EnvVarGuard, - _assume_linux: EnvVarGuard, - _display_num: EnvVarGuard, - _fake_state_dir: EnvVarGuard, -} - impl EnvVarGuard { fn set(key: &'static str, value: &str) -> Self { let previous = std::env::var_os(key); @@ -97,153 +87,6 @@ exit 0 ); } -fn setup_fake_desktop_env() -> FakeDesktopEnv { - let temp = tempfile::tempdir().expect("create fake desktop tempdir"); - let bin_dir = temp.path().join("bin"); - let xdg_state_home = temp.path().join("state"); - let fake_state_dir = temp.path().join("desktop-state"); - fs::create_dir_all(&bin_dir).expect("create fake desktop bin dir"); - fs::create_dir_all(&xdg_state_home).expect("create xdg state home"); - fs::create_dir_all(&fake_state_dir).expect("create fake state dir"); - - write_executable( - &bin_dir.join("Xvfb"), - r#"#!/usr/bin/env sh -set -eu -display="${1:-:99}" -number="${display#:}" -socket="/tmp/.X11-unix/X${number}" -mkdir -p /tmp/.X11-unix -touch "$socket" -cleanup() { - rm -f "$socket" - exit 0 -} -trap cleanup INT TERM EXIT -while :; do - sleep 1 -done -"#, - ); - - write_executable( - &bin_dir.join("openbox"), - r#"#!/usr/bin/env sh -set -eu -trap 'exit 0' INT TERM -while :; do - sleep 1 -done -"#, - ); - - write_executable( - &bin_dir.join("dbus-launch"), - r#"#!/usr/bin/env sh -set -eu -echo "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/sandbox-agent-test-bus" -echo "DBUS_SESSION_BUS_PID=$$" -"#, - ); - - write_executable( - &bin_dir.join("xrandr"), - r#"#!/usr/bin/env sh -set -eu -cat <<'EOF' -Screen 0: minimum 1 x 1, current 1440 x 900, maximum 32767 x 32767 -EOF -"#, - ); - - write_executable( - &bin_dir.join("import"), - r#"#!/usr/bin/env sh -set -eu -printf '\211PNG\r\n\032\n\000\000\000\rIHDR\000\000\000\001\000\000\000\001\010\006\000\000\000\037\025\304\211\000\000\000\013IDATx\234c\000\001\000\000\005\000\001\r\n-\264\000\000\000\000IEND\256B`\202' -"#, - ); - - write_executable( - &bin_dir.join("xdotool"), - r#"#!/usr/bin/env sh -set -eu -state_dir="${SANDBOX_AGENT_DESKTOP_FAKE_STATE_DIR:?missing fake state dir}" -state_file="${state_dir}/mouse" -mkdir -p "$state_dir" -if [ ! -f "$state_file" ]; then - printf '0 0\n' > "$state_file" -fi - -read_state() { - read -r x y < "$state_file" -} - -write_state() { - printf '%s %s\n' "$1" "$2" > "$state_file" -} - -command="${1:-}" -case "$command" in - getmouselocation) - read_state - printf 'X=%s\nY=%s\nSCREEN=0\nWINDOW=0\n' "$x" "$y" - ;; - mousemove) - shift - x="${1:-0}" - y="${2:-0}" - shift 2 || true - while [ "$#" -gt 0 ]; do - token="$1" - shift - case "$token" in - mousemove) - x="${1:-0}" - y="${2:-0}" - shift 2 || true - ;; - mousedown|mouseup) - shift 1 || true - ;; - click) - if [ "${1:-}" = "--repeat" ]; then - shift 2 || true - fi - shift 1 || true - ;; - esac - done - write_state "$x" "$y" - ;; - type|key) - exit 0 - ;; - *) - exit 0 - ;; -esac -"#, - ); - - let original_path = std::env::var_os("PATH").unwrap_or_default(); - let mut paths = vec![bin_dir]; - paths.extend(std::env::split_paths(&original_path)); - let merged_path = std::env::join_paths(paths).expect("join PATH"); - - FakeDesktopEnv { - _temp: temp, - _path: EnvVarGuard::set_os("PATH", merged_path.as_os_str()), - _xdg_state_home: EnvVarGuard::set_os("XDG_STATE_HOME", xdg_state_home.as_os_str()), - _assume_linux: EnvVarGuard::set("SANDBOX_AGENT_DESKTOP_TEST_ASSUME_LINUX", "1"), - _display_num: EnvVarGuard::set("SANDBOX_AGENT_DESKTOP_DISPLAY_NUM", "190"), - _fake_state_dir: EnvVarGuard::set_os( - "SANDBOX_AGENT_DESKTOP_FAKE_STATE_DIR", - fake_state_dir.as_os_str(), - ), - } -} - fn serve_registry_once(document: Value) -> String { let listener = TcpListener::bind("127.0.0.1:0").expect("bind registry server"); let address = listener.local_addr().expect("registry address"); @@ -335,6 +178,34 @@ async fn send_request_raw( (status, headers, bytes.to_vec()) } +async fn launch_desktop_focus_window(app: &docker_support::DockerApp, display: &str) { + let command = r#"nohup xterm -geometry 80x24+40+40 -title 'Sandbox Desktop Test' -e sh -lc 'sleep 60' >/tmp/sandbox-agent-xterm.log 2>&1 < /dev/null & for _ in $(seq 1 50); do wid="$(xdotool search --onlyvisible --name 'Sandbox Desktop Test' 2>/dev/null | head -n 1 || true)"; if [ -n "$wid" ]; then xdotool windowactivate "$wid"; exit 0; fi; sleep 0.1; done; exit 1"#; + let (status, _, body) = send_request( + app, + Method::POST, + "/v1/processes/run", + Some(json!({ + "command": "sh", + "args": ["-lc", command], + "env": { + "DISPLAY": display, + }, + "timeoutMs": 10_000 + })), + &[], + ) + .await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected desktop focus window launch response: {}", + String::from_utf8_lossy(&body) + ); + let parsed = parse_json(&body); + assert_eq!(parsed["exitCode"], 0); +} + fn parse_json(bytes: &[u8]) -> Value { if bytes.is_empty() { Value::Null diff --git a/server/packages/sandbox-agent/tests/v1_api/desktop.rs b/server/packages/sandbox-agent/tests/v1_api/desktop.rs index e250ec1..e982bdd 100644 --- a/server/packages/sandbox-agent/tests/v1_api/desktop.rs +++ b/server/packages/sandbox-agent/tests/v1_api/desktop.rs @@ -1,14 +1,25 @@ use super::*; use serial_test::serial; +use std::collections::BTreeMap; #[tokio::test] #[serial] async fn v1_desktop_status_reports_install_required_when_dependencies_are_missing() { let temp = tempfile::tempdir().expect("create empty path tempdir"); - let _path = EnvVarGuard::set_os("PATH", temp.path().as_os_str()); - let _assume_linux = EnvVarGuard::set("SANDBOX_AGENT_DESKTOP_TEST_ASSUME_LINUX", "1"); + let mut env = BTreeMap::new(); + env.insert( + "PATH".to_string(), + temp.path().to_string_lossy().to_string(), + ); - let test_app = TestApp::new(AuthConfig::disabled()); + let test_app = TestApp::with_options( + AuthConfig::disabled(), + docker_support::TestAppOptions { + env, + replace_path: true, + }, + |_| {}, + ); let (status, _, body) = send_request(&test_app.app, Method::GET, "/v1/desktop/status", None, &[]).await; @@ -29,8 +40,7 @@ async fn v1_desktop_status_reports_install_required_when_dependencies_are_missin #[tokio::test] #[serial] -async fn v1_desktop_lifecycle_and_actions_work_with_fake_runtime() { - let _fake = setup_fake_desktop_env(); +async fn v1_desktop_lifecycle_and_actions_work_with_real_runtime() { let test_app = TestApp::new(AuthConfig::disabled()); let (status, _, body) = send_request( @@ -184,6 +194,8 @@ async fn v1_desktop_lifecycle_and_actions_work_with_fake_runtime() { assert_eq!(position["x"], 220); assert_eq!(position["y"], 230); + launch_desktop_focus_window(&test_app.app, &display).await; + let (status, _, body) = send_request( &test_app.app, Method::POST,