mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 12:04:15 +00:00
Add Docker-backed integration test rig
This commit is contained in:
parent
c74d8c9179
commit
abf9b1858f
18 changed files with 1138 additions and 368 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,9 +1,6 @@
|
||||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { existsSync } from "node:fs";
|
|
||||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { dirname, resolve } from "node:path";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import {
|
import {
|
||||||
InMemorySessionPersistDriver,
|
InMemorySessionPersistDriver,
|
||||||
|
|
@ -14,36 +11,16 @@ import {
|
||||||
type SessionPersistDriver,
|
type SessionPersistDriver,
|
||||||
type SessionRecord,
|
type SessionRecord,
|
||||||
} from "../src/index.ts";
|
} 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 { prepareMockAgentDataHome } from "./helpers/mock-agent.ts";
|
||||||
import WebSocket from "ws";
|
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> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
@ -174,7 +151,7 @@ function decodeProcessLogData(data: string, encoding: string): string {
|
||||||
|
|
||||||
function nodeCommand(source: string): { command: string; args: string[] } {
|
function nodeCommand(source: string): { command: string; args: string[] } {
|
||||||
return {
|
return {
|
||||||
command: process.execPath,
|
command: "node",
|
||||||
args: ["-e", source],
|
args: ["-e", source],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -322,32 +299,29 @@ esac
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Integration: TypeScript SDK flat session API", () => {
|
describe("Integration: TypeScript SDK flat session API", () => {
|
||||||
let handle: SandboxAgentSpawnHandle;
|
let handle: DockerSandboxAgentHandle;
|
||||||
let baseUrl: string;
|
let baseUrl: string;
|
||||||
let token: string;
|
let token: string;
|
||||||
let dataHome: string;
|
let layout: ReturnType<typeof createDockerTestLayout>;
|
||||||
let desktopHome: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeEach(async () => {
|
||||||
dataHome = mkdtempSync(join(tmpdir(), "sdk-integration-"));
|
layout = createDockerTestLayout();
|
||||||
desktopHome = mkdtempSync(join(tmpdir(), "sdk-desktop-"));
|
prepareMockAgentDataHome(layout.xdgDataHome);
|
||||||
const agentEnv = prepareMockAgentDataHome(dataHome);
|
const desktopEnv = prepareFakeDesktopEnv(layout.rootDir);
|
||||||
const desktopEnv = prepareFakeDesktopEnv(desktopHome);
|
|
||||||
|
|
||||||
handle = await spawnSandboxAgent({
|
handle = await startDockerSandboxAgent(layout, {
|
||||||
enabled: true,
|
|
||||||
log: "silent",
|
|
||||||
timeoutMs: 30000,
|
timeoutMs: 30000,
|
||||||
env: { ...agentEnv, ...desktopEnv },
|
env: desktopEnv,
|
||||||
});
|
});
|
||||||
baseUrl = handle.baseUrl;
|
baseUrl = handle.baseUrl;
|
||||||
token = handle.token;
|
token = handle.token;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterEach(async () => {
|
||||||
await handle.dispose();
|
await handle?.dispose?.();
|
||||||
rmSync(dataHome, { recursive: true, force: true });
|
if (layout) {
|
||||||
rmSync(desktopHome, { recursive: true, force: true });
|
disposeDockerTestLayout(layout);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects Node.js runtime", () => {
|
it("detects Node.js runtime", () => {
|
||||||
|
|
@ -426,11 +400,12 @@ describe("Integration: TypeScript SDK flat session API", () => {
|
||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
|
|
||||||
const directory = mkdtempSync(join(tmpdir(), "sdk-fs-"));
|
const directory = join(layout.rootDir, "fs-test");
|
||||||
const nestedDir = join(directory, "nested");
|
const nestedDir = join(directory, "nested");
|
||||||
const filePath = join(directory, "notes.txt");
|
const filePath = join(directory, "notes.txt");
|
||||||
const movedPath = join(directory, "notes-moved.txt");
|
const movedPath = join(directory, "notes-moved.txt");
|
||||||
const uploadDir = join(directory, "uploaded");
|
const uploadDir = join(directory, "uploaded");
|
||||||
|
mkdirSync(directory, { recursive: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const listedAgents = await sdk.listAgents({ config: true, noCache: true });
|
const listedAgents = await sdk.listAgents({ config: true, noCache: true });
|
||||||
|
|
@ -856,7 +831,9 @@ describe("Integration: TypeScript SDK flat session API", () => {
|
||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
|
|
||||||
const directory = mkdtempSync(join(tmpdir(), "sdk-config-"));
|
const directory = join(layout.rootDir, "config-test");
|
||||||
|
|
||||||
|
mkdirSync(directory, { recursive: true });
|
||||||
|
|
||||||
const mcpConfig = {
|
const mcpConfig = {
|
||||||
type: "local" as const,
|
type: "local" as const,
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,6 @@ export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
include: ["tests/**/*.test.ts"],
|
include: ["tests/**/*.test.ts"],
|
||||||
testTimeout: 30000,
|
testTimeout: 30000,
|
||||||
|
hookTimeout: 120000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,7 @@ mod build_version {
|
||||||
include!(concat!(env!("OUT_DIR"), "/version.rs"));
|
include!(concat!(env!("OUT_DIR"), "/version.rs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::desktop_install::{
|
use crate::desktop_install::{install_desktop, DesktopInstallRequest, DesktopPackageManager};
|
||||||
install_desktop, DesktopInstallRequest, DesktopPackageManager,
|
|
||||||
};
|
|
||||||
use crate::router::{
|
use crate::router::{
|
||||||
build_router_with_state, shutdown_servers, AppState, AuthConfig, BrandingMode,
|
build_router_with_state, shutdown_servers, AppState, AuthConfig, BrandingMode,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,12 @@ pub struct DesktopProblem {
|
||||||
|
|
||||||
impl DesktopProblem {
|
impl DesktopProblem {
|
||||||
pub fn unsupported_platform(message: impl Into<String>) -> Self {
|
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(
|
pub fn dependencies_missing(
|
||||||
|
|
@ -44,11 +49,21 @@ impl DesktopProblem {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn runtime_inactive(message: impl Into<String>) -> Self {
|
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 {
|
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(
|
pub fn runtime_failed(
|
||||||
|
|
@ -56,17 +71,35 @@ impl DesktopProblem {
|
||||||
install_command: Option<String>,
|
install_command: Option<String>,
|
||||||
processes: Vec<DesktopProcessInfo>,
|
processes: Vec<DesktopProcessInfo>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self::new(503, "Desktop Runtime Failed", "desktop_runtime_failed", message)
|
Self::new(
|
||||||
|
503,
|
||||||
|
"Desktop Runtime Failed",
|
||||||
|
"desktop_runtime_failed",
|
||||||
|
message,
|
||||||
|
)
|
||||||
.with_install_command(install_command)
|
.with_install_command(install_command)
|
||||||
.with_processes(processes)
|
.with_processes(processes)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn invalid_action(message: impl Into<String>) -> Self {
|
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 {
|
pub fn screenshot_failed(
|
||||||
Self::new(502, "Desktop Screenshot Failed", "desktop_screenshot_failed", message)
|
message: impl Into<String>,
|
||||||
|
processes: Vec<DesktopProcessInfo>,
|
||||||
|
) -> Self {
|
||||||
|
Self::new(
|
||||||
|
502,
|
||||||
|
"Desktop Screenshot Failed",
|
||||||
|
"desktop_screenshot_failed",
|
||||||
|
message,
|
||||||
|
)
|
||||||
.with_processes(processes)
|
.with_processes(processes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,10 +130,7 @@ impl DesktopProblem {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if !self.processes.is_empty() {
|
if !self.processes.is_empty() {
|
||||||
extensions.insert(
|
extensions.insert("processes".to_string(), json!(self.processes));
|
||||||
"processes".to_string(),
|
|
||||||
json!(self.processes),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ProblemDetails {
|
ProblemDetails {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ pub struct DesktopInstallRequest {
|
||||||
|
|
||||||
pub fn install_desktop(request: DesktopInstallRequest) -> Result<(), String> {
|
pub fn install_desktop(request: DesktopInstallRequest) -> Result<(), String> {
|
||||||
if std::env::consts::OS != "linux" {
|
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 {
|
let package_manager = match request.package_manager {
|
||||||
|
|
@ -47,7 +49,10 @@ pub fn install_desktop(request: DesktopInstallRequest) -> Result<(), String> {
|
||||||
println!(" - {package}");
|
println!(" - {package}");
|
||||||
}
|
}
|
||||||
println!("Install command:");
|
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 {
|
if request.print_only {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -76,10 +81,7 @@ fn detect_package_manager() -> Option<DesktopPackageManager> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn desktop_packages(
|
fn desktop_packages(package_manager: DesktopPackageManager, no_fonts: bool) -> Vec<String> {
|
||||||
package_manager: DesktopPackageManager,
|
|
||||||
no_fonts: bool,
|
|
||||||
) -> Vec<String> {
|
|
||||||
let mut packages = match package_manager {
|
let mut packages = match package_manager {
|
||||||
DesktopPackageManager::Apt => vec![
|
DesktopPackageManager::Apt => vec![
|
||||||
"xvfb",
|
"xvfb",
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,12 @@ use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::desktop_errors::DesktopProblem;
|
use crate::desktop_errors::DesktopProblem;
|
||||||
use crate::desktop_types::{
|
use crate::desktop_types::{
|
||||||
DesktopActionResponse, DesktopDisplayInfoResponse, DesktopErrorInfo, DesktopKeyboardPressRequest,
|
DesktopActionResponse, DesktopDisplayInfoResponse, DesktopErrorInfo,
|
||||||
DesktopKeyboardTypeRequest, DesktopMouseButton, DesktopMouseClickRequest,
|
DesktopKeyboardPressRequest, DesktopKeyboardTypeRequest, DesktopMouseButton,
|
||||||
DesktopMouseDragRequest, DesktopMouseMoveRequest, DesktopMousePositionResponse,
|
DesktopMouseClickRequest, DesktopMouseDragRequest, DesktopMouseMoveRequest,
|
||||||
DesktopMouseScrollRequest, DesktopProcessInfo, DesktopRegionScreenshotQuery, DesktopResolution,
|
DesktopMousePositionResponse, DesktopMouseScrollRequest, DesktopProcessInfo,
|
||||||
DesktopStartRequest, DesktopState, DesktopStatusResponse,
|
DesktopRegionScreenshotQuery, DesktopResolution, DesktopStartRequest, DesktopState,
|
||||||
|
DesktopStatusResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_WIDTH: u32 = 1440;
|
const DEFAULT_WIDTH: u32 = 1440;
|
||||||
|
|
@ -164,8 +165,9 @@ impl DesktopRuntime {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
self.ensure_state_dir_locked(&state)
|
self.ensure_state_dir_locked(&state).map_err(|err| {
|
||||||
.map_err(|err| DesktopProblem::runtime_failed(err, None, self.processes_locked(&state)))?;
|
DesktopProblem::runtime_failed(err, None, self.processes_locked(&state))
|
||||||
|
})?;
|
||||||
self.write_runtime_log_locked(&state, "starting desktop runtime");
|
self.write_runtime_log_locked(&state, "starting desktop runtime");
|
||||||
|
|
||||||
let width = request.width.unwrap_or(DEFAULT_WIDTH);
|
let width = request.width.unwrap_or(DEFAULT_WIDTH);
|
||||||
|
|
@ -211,7 +213,9 @@ impl DesktopRuntime {
|
||||||
})?;
|
})?;
|
||||||
state.resolution = Some(display_info.resolution.clone());
|
state.resolution = Some(display_info.resolution.clone());
|
||||||
|
|
||||||
self.capture_screenshot_locked(&state, None).await.map_err(|problem| {
|
self.capture_screenshot_locked(&state, None)
|
||||||
|
.await
|
||||||
|
.map_err(|problem| {
|
||||||
self.record_problem_locked(&mut state, &problem);
|
self.record_problem_locked(&mut state, &problem);
|
||||||
state.state = DesktopState::Failed;
|
state.state = DesktopState::Failed;
|
||||||
problem
|
problem
|
||||||
|
|
@ -279,7 +283,8 @@ impl DesktopRuntime {
|
||||||
let mut state = self.inner.lock().await;
|
let mut state = self.inner.lock().await;
|
||||||
let ready = self.ensure_ready_locked(&mut state).await?;
|
let ready = self.ensure_ready_locked(&mut state).await?;
|
||||||
let crop = format!("{}x{}+{}+{}", query.width, query.height, query.x, query.y);
|
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> {
|
pub async fn mouse_position(&self) -> Result<DesktopMousePositionResponse, DesktopProblem> {
|
||||||
|
|
@ -393,9 +398,7 @@ impl DesktopRuntime {
|
||||||
request: DesktopKeyboardTypeRequest,
|
request: DesktopKeyboardTypeRequest,
|
||||||
) -> Result<DesktopActionResponse, DesktopProblem> {
|
) -> Result<DesktopActionResponse, DesktopProblem> {
|
||||||
if request.text.is_empty() {
|
if request.text.is_empty() {
|
||||||
return Err(DesktopProblem::invalid_action(
|
return Err(DesktopProblem::invalid_action("text must not be empty"));
|
||||||
"text must not be empty",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut state = self.inner.lock().await;
|
let mut state = self.inner.lock().await;
|
||||||
|
|
@ -466,9 +469,9 @@ impl DesktopRuntime {
|
||||||
DesktopState::Inactive => Err(DesktopProblem::runtime_inactive(
|
DesktopState::Inactive => Err(DesktopProblem::runtime_inactive(
|
||||||
"Desktop runtime has not been started",
|
"Desktop runtime has not been started",
|
||||||
)),
|
)),
|
||||||
DesktopState::Starting | DesktopState::Stopping => Err(DesktopProblem::runtime_starting(
|
DesktopState::Starting | DesktopState::Stopping => Err(
|
||||||
"Desktop runtime is still transitioning",
|
DesktopProblem::runtime_starting("Desktop runtime is still transitioning"),
|
||||||
)),
|
),
|
||||||
DesktopState::Failed => Err(DesktopProblem::runtime_failed(
|
DesktopState::Failed => Err(DesktopProblem::runtime_failed(
|
||||||
state
|
state
|
||||||
.last_error
|
.last_error
|
||||||
|
|
@ -514,7 +517,10 @@ impl DesktopRuntime {
|
||||||
return;
|
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 {
|
if state.state == DesktopState::Inactive {
|
||||||
state.last_error = None;
|
state.last_error = None;
|
||||||
}
|
}
|
||||||
|
|
@ -626,16 +632,14 @@ impl DesktopRuntime {
|
||||||
state: &mut DesktopRuntimeStateData,
|
state: &mut DesktopRuntimeStateData,
|
||||||
) -> Result<(), DesktopProblem> {
|
) -> Result<(), DesktopProblem> {
|
||||||
if find_binary("dbus-launch").is_none() {
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = run_command_output(
|
let output = run_command_output("dbus-launch", &[], &state.environment, INPUT_TIMEOUT)
|
||||||
"dbus-launch",
|
|
||||||
&[],
|
|
||||||
&state.environment,
|
|
||||||
INPUT_TIMEOUT,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
DesktopProblem::runtime_failed(
|
DesktopProblem::runtime_failed(
|
||||||
|
|
@ -693,7 +697,8 @@ impl DesktopRuntime {
|
||||||
"tcp".to_string(),
|
"tcp".to_string(),
|
||||||
];
|
];
|
||||||
let log_path = self.config.state_dir.join("desktop-xvfb.log");
|
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);
|
state.xvfb = Some(child);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -738,12 +743,16 @@ impl DesktopRuntime {
|
||||||
ready: Option<&DesktopReadyContext>,
|
ready: Option<&DesktopReadyContext>,
|
||||||
) -> Result<Vec<u8>, DesktopProblem> {
|
) -> Result<Vec<u8>, DesktopProblem> {
|
||||||
match ready {
|
match ready {
|
||||||
Some(ready) => self
|
Some(ready) => {
|
||||||
.capture_screenshot_with_crop_locked(state, ready, "")
|
self.capture_screenshot_with_crop_locked(state, ready, "")
|
||||||
.await,
|
.await
|
||||||
|
}
|
||||||
None => {
|
None => {
|
||||||
let ready = DesktopReadyContext {
|
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(),
|
environment: state.environment.clone(),
|
||||||
resolution: state.resolution.clone().unwrap_or(DesktopResolution {
|
resolution: state.resolution.clone().unwrap_or(DesktopResolution {
|
||||||
width: DEFAULT_WIDTH,
|
width: DEFAULT_WIDTH,
|
||||||
|
|
@ -751,7 +760,8 @@ impl DesktopRuntime {
|
||||||
dpi: Some(DEFAULT_DPI),
|
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),
|
self.processes_locked(state),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
parse_mouse_position(&output.stdout).map_err(|message| {
|
parse_mouse_position(&output.stdout)
|
||||||
DesktopProblem::input_failed(message, self.processes_locked(state))
|
.map_err(|message| DesktopProblem::input_failed(message, self.processes_locked(state)))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_input_command_locked(
|
async fn run_input_command_locked(
|
||||||
|
|
@ -932,7 +941,14 @@ impl DesktopRuntime {
|
||||||
fn base_environment(&self, display: &str) -> Result<HashMap<String, String>, DesktopProblem> {
|
fn base_environment(&self, display: &str) -> Result<HashMap<String, String>, DesktopProblem> {
|
||||||
let mut environment = HashMap::new();
|
let mut environment = HashMap::new();
|
||||||
environment.insert("DISPLAY".to_string(), display.to_string());
|
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(
|
environment.insert(
|
||||||
"USER".to_string(),
|
"USER".to_string(),
|
||||||
std::env::var("USER").unwrap_or_else(|_| "sandbox-agent".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(),
|
std::env::var("PATH").unwrap_or_default(),
|
||||||
);
|
);
|
||||||
fs::create_dir_all(self.config.state_dir.join("home")).map_err(|err| {
|
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)
|
Ok(environment)
|
||||||
}
|
}
|
||||||
|
|
@ -971,14 +991,20 @@ impl DesktopRuntime {
|
||||||
.open(log_path)
|
.open(log_path)
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
DesktopProblem::runtime_failed(
|
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,
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let stderr = stdout.try_clone().map_err(|err| {
|
let stderr = stdout.try_clone().map_err(|err| {
|
||||||
DesktopProblem::runtime_failed(
|
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,
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
)
|
)
|
||||||
|
|
@ -1008,7 +1034,10 @@ impl DesktopRuntime {
|
||||||
|
|
||||||
async fn wait_for_socket(&self, display_num: i32) -> Result<(), DesktopProblem> {
|
async fn wait_for_socket(&self, display_num: i32) -> Result<(), DesktopProblem> {
|
||||||
let socket = socket_path(display_num);
|
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 _ = fs::create_dir_all(parent);
|
||||||
|
|
||||||
let start = tokio::time::Instant::now();
|
let start = tokio::time::Instant::now();
|
||||||
|
|
@ -1078,11 +1107,19 @@ impl DesktopRuntime {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_state_dir_locked(&self, state: &DesktopRuntimeStateData) -> Result<(), String> {
|
fn ensure_state_dir_locked(&self, state: &DesktopRuntimeStateData) -> Result<(), String> {
|
||||||
fs::create_dir_all(&self.config.state_dir)
|
fs::create_dir_all(&self.config.state_dir).map_err(|err| {
|
||||||
.map_err(|err| format!("failed to create desktop state dir {}: {err}", self.config.state_dir.display()))?;
|
format!(
|
||||||
|
"failed to create desktop state dir {}: {err}",
|
||||||
|
self.config.state_dir.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
if let Some(parent) = state.runtime_log_path.parent() {
|
if let Some(parent) = state.runtime_log_path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent).map_err(|err| {
|
||||||
.map_err(|err| format!("failed to create runtime log dir {}: {err}", parent.display()))?;
|
format!(
|
||||||
|
"failed to create runtime log dir {}: {err}",
|
||||||
|
parent.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -1105,7 +1142,11 @@ fn default_state_dir() -> PathBuf {
|
||||||
return PathBuf::from(value).join("sandbox-agent").join("desktop");
|
return PathBuf::from(value).join("sandbox-agent").join("desktop");
|
||||||
}
|
}
|
||||||
if let Some(home) = dirs::home_dir() {
|
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")
|
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 {
|
fn process_exists(pid: u32) -> bool {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
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))]
|
#[cfg(not(unix))]
|
||||||
{
|
{
|
||||||
|
|
@ -1186,8 +1228,12 @@ fn parse_xrandr_resolution(bytes: &[u8]) -> Result<DesktopResolution, String> {
|
||||||
if let Some(current) = parts.next() {
|
if let Some(current) = parts.next() {
|
||||||
let dims: Vec<&str> = current.split_whitespace().collect();
|
let dims: Vec<&str> = current.split_whitespace().collect();
|
||||||
if dims.len() >= 3 {
|
if dims.len() >= 3 {
|
||||||
let width = dims[0].parse::<u32>().map_err(|_| "failed to parse xrandr width".to_string())?;
|
let width = dims[0]
|
||||||
let height = dims[2].parse::<u32>().map_err(|_| "failed to parse xrandr height".to_string())?;
|
.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 {
|
return Ok(DesktopResolution {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
//! Sandbox agent core utilities.
|
//! Sandbox agent core utilities.
|
||||||
|
|
||||||
mod acp_proxy_runtime;
|
mod acp_proxy_runtime;
|
||||||
mod desktop_install;
|
|
||||||
mod desktop_errors;
|
|
||||||
mod desktop_runtime;
|
|
||||||
pub mod desktop_types;
|
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod daemon;
|
pub mod daemon;
|
||||||
|
mod desktop_errors;
|
||||||
|
mod desktop_install;
|
||||||
|
mod desktop_runtime;
|
||||||
|
pub mod desktop_types;
|
||||||
mod process_runtime;
|
mod process_runtime;
|
||||||
pub mod router;
|
pub mod router;
|
||||||
pub mod server_logs;
|
pub mod server_logs;
|
||||||
|
|
|
||||||
|
|
@ -190,13 +190,22 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
|
||||||
"/desktop/screenshot/region",
|
"/desktop/screenshot/region",
|
||||||
get(get_v1_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/move", post(post_v1_desktop_mouse_move))
|
||||||
.route("/desktop/mouse/click", post(post_v1_desktop_mouse_click))
|
.route("/desktop/mouse/click", post(post_v1_desktop_mouse_click))
|
||||||
.route("/desktop/mouse/drag", post(post_v1_desktop_mouse_drag))
|
.route("/desktop/mouse/drag", post(post_v1_desktop_mouse_drag))
|
||||||
.route("/desktop/mouse/scroll", post(post_v1_desktop_mouse_scroll))
|
.route("/desktop/mouse/scroll", post(post_v1_desktop_mouse_scroll))
|
||||||
.route("/desktop/keyboard/type", post(post_v1_desktop_keyboard_type))
|
.route(
|
||||||
.route("/desktop/keyboard/press", post(post_v1_desktop_keyboard_press))
|
"/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("/desktop/display/info", get(get_v1_desktop_display_info))
|
||||||
.route("/agents", get(get_v1_agents))
|
.route("/agents", get(get_v1_agents))
|
||||||
.route("/agents/:agent", get(get_v1_agent))
|
.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::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::http::{Method, Request, StatusCode};
|
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use http_body_util::BodyExt;
|
use reqwest::{Method, StatusCode};
|
||||||
use sandbox_agent::router::{build_router, AppState, AuthConfig};
|
use sandbox_agent::router::AuthConfig;
|
||||||
use sandbox_agent_agent_management::agents::AgentManager;
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tempfile::TempDir;
|
|
||||||
use tower::util::ServiceExt;
|
|
||||||
|
|
||||||
struct TestApp {
|
#[path = "support/docker.rs"]
|
||||||
app: axum::Router,
|
mod docker_support;
|
||||||
_install_dir: TempDir,
|
use docker_support::TestApp;
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_executable(path: &Path, script: &str) {
|
fn write_executable(path: &Path, script: &str) {
|
||||||
fs::write(path, script).expect("write executable");
|
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(
|
async fn send_request(
|
||||||
app: &axum::Router,
|
app: &docker_support::DockerApp,
|
||||||
method: Method,
|
method: Method,
|
||||||
uri: &str,
|
uri: &str,
|
||||||
body: Option<Value>,
|
body: Option<Value>,
|
||||||
) -> (StatusCode, Vec<u8>) {
|
) -> (StatusCode, Vec<u8>) {
|
||||||
let mut builder = Request::builder().method(method).uri(uri);
|
let client = reqwest::Client::new();
|
||||||
let request_body = if let Some(body) = body {
|
let response = if let Some(body) = body {
|
||||||
builder = builder.header("content-type", "application/json");
|
client
|
||||||
Body::from(body.to_string())
|
.request(method, app.http_url(uri))
|
||||||
} else {
|
.header("content-type", "application/json")
|
||||||
Body::empty()
|
.body(body.to_string())
|
||||||
};
|
.send()
|
||||||
|
|
||||||
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
|
.await
|
||||||
.expect("collect body")
|
.expect("request handled")
|
||||||
.to_bytes();
|
} else {
|
||||||
|
client
|
||||||
|
.request(method, app.http_url(uri))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request handled")
|
||||||
|
};
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = response.bytes().await.expect("collect body");
|
||||||
|
|
||||||
(status, bytes.to_vec())
|
(status, bytes.to_vec())
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +123,7 @@ async fn agent_process_matrix_smoke_and_jsonrpc_conformance() {
|
||||||
.chain(agent_process_only_agents.iter())
|
.chain(agent_process_only_agents.iter())
|
||||||
.copied()
|
.copied()
|
||||||
.collect();
|
.collect();
|
||||||
let test_app = TestApp::with_setup(|install_dir| {
|
let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_dir| {
|
||||||
for agent in native_agents {
|
for agent in native_agents {
|
||||||
setup_stub_artifacts(install_dir, agent);
|
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["id"], 2, "{agent}: session/new id");
|
||||||
assert_eq!(new_json["result"]["echoedMethod"], "session/new");
|
assert_eq!(new_json["result"]["echoedMethod"], "session/new");
|
||||||
|
|
||||||
let request = Request::builder()
|
let response = reqwest::Client::new()
|
||||||
.method(Method::GET)
|
.get(test_app.app.http_url(&format!("/v1/acp/{agent}-server")))
|
||||||
.uri(format!("/v1/acp/{agent}-server"))
|
.header("accept", "text/event-stream")
|
||||||
.body(Body::empty())
|
.send()
|
||||||
.expect("build sse request");
|
|
||||||
|
|
||||||
let response = test_app
|
|
||||||
.app
|
|
||||||
.clone()
|
|
||||||
.oneshot(request)
|
|
||||||
.await
|
.await
|
||||||
.expect("sse response");
|
.expect("sse response");
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
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 {
|
let chunk = tokio::time::timeout(std::time::Duration::from_secs(5), async move {
|
||||||
while let Some(item) = stream.next().await {
|
while let Some(item) = stream.next().await {
|
||||||
let bytes = item.expect("sse chunk");
|
let bytes = item.expect("sse chunk");
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,20 @@
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
use std::net::{TcpListener, TcpStream};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::http::{header, HeaderMap, Method, Request, StatusCode};
|
|
||||||
use axum::Router;
|
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use http_body_util::BodyExt;
|
use reqwest::header::{self, HeaderMap, HeaderName, HeaderValue};
|
||||||
use sandbox_agent::router::{build_router, AppState, AuthConfig};
|
use reqwest::{Method, StatusCode};
|
||||||
use sandbox_agent_agent_management::agents::AgentManager;
|
use sandbox_agent::router::AuthConfig;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use tokio::sync::oneshot;
|
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
use tower::util::ServiceExt;
|
|
||||||
|
|
||||||
struct TestApp {
|
#[path = "support/docker.rs"]
|
||||||
app: Router,
|
mod docker_support;
|
||||||
install_dir: TempDir,
|
use docker_support::{LiveServer, TestApp};
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct EnvVarGuard {
|
struct EnvVarGuard {
|
||||||
key: &'static str,
|
key: &'static str,
|
||||||
|
|
@ -59,56 +30,6 @@ struct FakeDesktopEnv {
|
||||||
_fake_state_dir: EnvVarGuard,
|
_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 {
|
impl EnvVarGuard {
|
||||||
fn set(key: &'static str, value: &str) -> Self {
|
fn set(key: &'static str, value: &str) -> Self {
|
||||||
let previous = std::env::var_os(key);
|
let previous = std::env::var_os(key);
|
||||||
|
|
@ -352,70 +273,64 @@ fn respond_json(stream: &mut TcpStream, body: &str) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_request(
|
async fn send_request(
|
||||||
app: &Router,
|
app: &docker_support::DockerApp,
|
||||||
method: Method,
|
method: Method,
|
||||||
uri: &str,
|
uri: &str,
|
||||||
body: Option<Value>,
|
body: Option<Value>,
|
||||||
headers: &[(&str, &str)],
|
headers: &[(&str, &str)],
|
||||||
) -> (StatusCode, HeaderMap, Vec<u8>) {
|
) -> (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 {
|
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 {
|
||||||
builder = builder.header(header::CONTENT_TYPE, "application/json");
|
builder
|
||||||
Body::from(body.to_string())
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(body.to_string())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request handled")
|
||||||
} else {
|
} 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 status = response.status();
|
||||||
let headers = response.headers().clone();
|
let headers = response.headers().clone();
|
||||||
let bytes = response
|
let bytes = response.bytes().await.expect("collect body");
|
||||||
.into_body()
|
|
||||||
.collect()
|
|
||||||
.await
|
|
||||||
.expect("collect body")
|
|
||||||
.to_bytes();
|
|
||||||
|
|
||||||
(status, headers, bytes.to_vec())
|
(status, headers, bytes.to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_request_raw(
|
async fn send_request_raw(
|
||||||
app: &Router,
|
app: &docker_support::DockerApp,
|
||||||
method: Method,
|
method: Method,
|
||||||
uri: &str,
|
uri: &str,
|
||||||
body: Option<Vec<u8>>,
|
body: Option<Vec<u8>>,
|
||||||
headers: &[(&str, &str)],
|
headers: &[(&str, &str)],
|
||||||
content_type: Option<&str>,
|
content_type: Option<&str>,
|
||||||
) -> (StatusCode, HeaderMap, Vec<u8>) {
|
) -> (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 {
|
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 {
|
if let Some(content_type) = content_type {
|
||||||
builder = builder.header(header::CONTENT_TYPE, content_type);
|
builder = builder.header(header::CONTENT_TYPE, content_type);
|
||||||
}
|
}
|
||||||
Body::from(body)
|
builder.body(body).send().await.expect("request handled")
|
||||||
} else {
|
} 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 status = response.status();
|
||||||
let headers = response.headers().clone();
|
let headers = response.headers().clone();
|
||||||
let bytes = response
|
let bytes = response.bytes().await.expect("collect body");
|
||||||
.into_body()
|
|
||||||
.collect()
|
|
||||||
.await
|
|
||||||
.expect("collect body")
|
|
||||||
.to_bytes();
|
|
||||||
|
|
||||||
(status, headers, bytes.to_vec())
|
(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 initialize = initialize_payload();
|
||||||
let (status, _, _body) = send_request(
|
let (status, _, _body) = send_request(
|
||||||
app,
|
app,
|
||||||
|
|
@ -453,17 +368,17 @@ async fn bootstrap_server(app: &Router, server_id: &str, agent: &str) {
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_first_sse_data(app: &Router, server_id: &str) -> String {
|
async fn read_first_sse_data(app: &docker_support::DockerApp, server_id: &str) -> String {
|
||||||
let request = Request::builder()
|
let client = reqwest::Client::new();
|
||||||
.method(Method::GET)
|
let response = client
|
||||||
.uri(format!("/v1/acp/{server_id}"))
|
.get(app.http_url(&format!("/v1/acp/{server_id}")))
|
||||||
.body(Body::empty())
|
.header("accept", "text/event-stream")
|
||||||
.expect("build request");
|
.send()
|
||||||
|
.await
|
||||||
let response = app.clone().oneshot(request).await.expect("sse response");
|
.expect("sse response");
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
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 {
|
tokio::time::timeout(Duration::from_secs(5), async move {
|
||||||
while let Some(chunk) = stream.next().await {
|
while let Some(chunk) = stream.next().await {
|
||||||
let bytes = chunk.expect("stream chunk");
|
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(
|
async fn read_first_sse_data_with_last_id(
|
||||||
app: &Router,
|
app: &docker_support::DockerApp,
|
||||||
server_id: &str,
|
server_id: &str,
|
||||||
last_event_id: u64,
|
last_event_id: u64,
|
||||||
) -> String {
|
) -> String {
|
||||||
let request = Request::builder()
|
let client = reqwest::Client::new();
|
||||||
.method(Method::GET)
|
let response = client
|
||||||
.uri(format!("/v1/acp/{server_id}"))
|
.get(app.http_url(&format!("/v1/acp/{server_id}")))
|
||||||
|
.header("accept", "text/event-stream")
|
||||||
.header("last-event-id", last_event_id.to_string())
|
.header("last-event-id", last_event_id.to_string())
|
||||||
.body(Body::empty())
|
.send()
|
||||||
.expect("build request");
|
.await
|
||||||
|
.expect("sse response");
|
||||||
let response = app.clone().oneshot(request).await.expect("sse response");
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
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 {
|
tokio::time::timeout(Duration::from_secs(5), async move {
|
||||||
while let Some(chunk) = stream.next().await {
|
while let Some(chunk) = stream.next().await {
|
||||||
let bytes = chunk.expect("stream chunk");
|
let bytes = chunk.expect("stream chunk");
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,9 @@ async fn mcp_config_requires_directory_and_name() {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn mcp_config_crud_round_trip() {
|
async fn mcp_config_crud_round_trip() {
|
||||||
let test_app = TestApp::new(AuthConfig::disabled());
|
let test_app = TestApp::new(AuthConfig::disabled());
|
||||||
let project = tempfile::tempdir().expect("tempdir");
|
let project = test_app.root_path().join("mcp-config-project");
|
||||||
let directory = project.path().to_string_lossy().to_string();
|
fs::create_dir_all(&project).expect("create project dir");
|
||||||
|
let directory = project.to_string_lossy().to_string();
|
||||||
|
|
||||||
let entry = json!({
|
let entry = json!({
|
||||||
"type": "local",
|
"type": "local",
|
||||||
|
|
@ -99,8 +100,9 @@ async fn skills_config_requires_directory_and_name() {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn skills_config_crud_round_trip() {
|
async fn skills_config_crud_round_trip() {
|
||||||
let test_app = TestApp::new(AuthConfig::disabled());
|
let test_app = TestApp::new(AuthConfig::disabled());
|
||||||
let project = tempfile::tempdir().expect("tempdir");
|
let project = test_app.root_path().join("skills-config-project");
|
||||||
let directory = project.path().to_string_lossy().to_string();
|
fs::create_dir_all(&project).expect("create project dir");
|
||||||
|
let directory = project.to_string_lossy().to_string();
|
||||||
|
|
||||||
let entry = json!({
|
let entry = json!({
|
||||||
"sources": [
|
"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 _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| {
|
let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_path| {
|
||||||
fs::create_dir_all(install_path.join("agent_processes"))
|
fs::create_dir_all(install_path.join("agent_processes"))
|
||||||
.expect("create agent processes dir");
|
.expect("create agent processes dir");
|
||||||
write_executable(&install_path.join("codex"), "#!/usr/bin/env sh\nexit 0\n");
|
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(
|
let (status, _, _) = send_request(
|
||||||
&test_app.app,
|
&test_app.app,
|
||||||
Method::POST,
|
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 test_app = TestApp::new(AuthConfig::disabled());
|
||||||
|
|
||||||
let (status, _, body) = send_request(
|
let (status, _, body) =
|
||||||
&test_app.app,
|
send_request(&test_app.app, Method::GET, "/v1/desktop/status", None, &[]).await;
|
||||||
Method::GET,
|
|
||||||
"/v1/desktop/status",
|
|
||||||
None,
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
let parsed = parse_json(&body);
|
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);
|
let parsed = parse_json(&body);
|
||||||
assert_eq!(parsed["state"], "active");
|
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!(display.starts_with(':'));
|
||||||
assert_eq!(parsed["resolution"]["width"], 1440);
|
assert_eq!(parsed["resolution"]["width"], 1440);
|
||||||
assert_eq!(parsed["resolution"]["height"], 900);
|
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!(status, StatusCode::OK);
|
||||||
assert_eq!(parse_json(&body)["ok"], true);
|
assert_eq!(parse_json(&body)["ok"], true);
|
||||||
|
|
||||||
let (status, _, body) = send_request(
|
let (status, _, body) =
|
||||||
&test_app.app,
|
send_request(&test_app.app, Method::POST, "/v1/desktop/stop", None, &[]).await;
|
||||||
Method::POST,
|
|
||||||
"/v1/desktop/stop",
|
|
||||||
None,
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
assert_eq!(parse_json(&body)["state"], "inactive");
|
assert_eq!(parse_json(&body)["state"], "inactive");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -413,22 +413,17 @@ async fn v1_process_logs_follow_sse_streams_entries() {
|
||||||
.expect("process id")
|
.expect("process id")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let request = Request::builder()
|
let response = reqwest::Client::new()
|
||||||
.method(Method::GET)
|
.get(test_app.app.http_url(&format!(
|
||||||
.uri(format!(
|
|
||||||
"/v1/processes/{process_id}/logs?stream=stdout&follow=true"
|
"/v1/processes/{process_id}/logs?stream=stdout&follow=true"
|
||||||
))
|
)))
|
||||||
.body(Body::empty())
|
.header("accept", "text/event-stream")
|
||||||
.expect("build request");
|
.send()
|
||||||
let response = test_app
|
|
||||||
.app
|
|
||||||
.clone()
|
|
||||||
.oneshot(request)
|
|
||||||
.await
|
.await
|
||||||
.expect("sse response");
|
.expect("sse response");
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
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 {
|
let chunk = tokio::time::timeout(Duration::from_secs(5), async move {
|
||||||
while let Some(chunk) = stream.next().await {
|
while let Some(chunk) = stream.next().await {
|
||||||
let bytes = chunk.expect("stream chunk");
|
let bytes = chunk.expect("stream chunk");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue