mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 09:01:17 +00:00
Add Docker-backed integration test rig
This commit is contained in:
parent
641597afe6
commit
25f8491c6d
18 changed files with 1138 additions and 373 deletions
28
docker/test-agent/Dockerfile
Normal file
28
docker/test-agent/Dockerfile
Normal 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"]
|
||||
26
scripts/test-rig/ensure-image.sh
Executable file
26
scripts/test-rig/ensure-image.sh
Executable 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"
|
||||
279
sdks/typescript/tests/helpers/docker.ts
Normal file
279
sdks/typescript/tests/helpers/docker.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -4,5 +4,6 @@ export default defineConfig({
|
|||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 120000,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
496
server/packages/sandbox-agent/tests/support/docker.rs
Normal file
496
server/packages/sandbox-agent/tests/support/docker.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue