mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 09:01:17 +00:00
feat: sprites support
This commit is contained in:
parent
9cd9252725
commit
5da35e6dfa
35 changed files with 746 additions and 1257 deletions
|
|
@ -46,12 +46,17 @@
|
|||
"./computesdk": {
|
||||
"types": "./dist/providers/computesdk.d.ts",
|
||||
"import": "./dist/providers/computesdk.js"
|
||||
},
|
||||
"./sprites": {
|
||||
"types": "./dist/providers/sprites.d.ts",
|
||||
"import": "./dist/providers/sprites.js"
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cloudflare/sandbox": ">=0.1.0",
|
||||
"@daytonaio/sdk": ">=0.12.0",
|
||||
"@e2b/code-interpreter": ">=1.0.0",
|
||||
"@fly/sprites": ">=0.0.1",
|
||||
"@vercel/sandbox": ">=0.1.0",
|
||||
"dockerode": ">=4.0.0",
|
||||
"get-port": ">=7.0.0",
|
||||
|
|
@ -68,6 +73,9 @@
|
|||
"@e2b/code-interpreter": {
|
||||
"optional": true
|
||||
},
|
||||
"@fly/sprites": {
|
||||
"optional": true
|
||||
},
|
||||
"@vercel/sandbox": {
|
||||
"optional": true
|
||||
},
|
||||
|
|
@ -104,6 +112,7 @@
|
|||
"@cloudflare/sandbox": ">=0.1.0",
|
||||
"@daytonaio/sdk": ">=0.12.0",
|
||||
"@e2b/code-interpreter": ">=1.0.0",
|
||||
"@fly/sprites": ">=0.0.1",
|
||||
"@types/dockerode": "^4.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
|
|
|
|||
|
|
@ -147,3 +147,9 @@ export type {
|
|||
SandboxAgentSpawnLogMode,
|
||||
SandboxAgentSpawnOptions,
|
||||
} from "./spawn.ts";
|
||||
|
||||
export type {
|
||||
SpritesProviderOptions,
|
||||
SpritesCreateOverrides,
|
||||
SpritesClientOverrides,
|
||||
} from "./providers/sprites.ts";
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { DEFAULT_SANDBOX_AGENT_IMAGE, buildServerStartCommand } from "./shared.t
|
|||
|
||||
const DEFAULT_AGENT_PORT = 3000;
|
||||
const DEFAULT_PREVIEW_TTL_SECONDS = 4 * 60 * 60;
|
||||
const DEFAULT_CWD = "/home/sandbox";
|
||||
|
||||
type DaytonaCreateParams = NonNullable<Parameters<Daytona["create"]>[0]>;
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ export interface DaytonaProviderOptions {
|
|||
create?: DaytonaCreateOverrides | (() => DaytonaCreateOverrides | Promise<DaytonaCreateOverrides>);
|
||||
image?: DaytonaCreateParams["image"];
|
||||
agentPort?: number;
|
||||
cwd?: string;
|
||||
previewTtlSeconds?: number;
|
||||
deleteTimeoutSeconds?: number;
|
||||
}
|
||||
|
|
@ -26,12 +28,13 @@ async function resolveCreateOptions(value: DaytonaProviderOptions["create"]): Pr
|
|||
export function daytona(options: DaytonaProviderOptions = {}): SandboxProvider {
|
||||
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
|
||||
const image = options.image ?? DEFAULT_SANDBOX_AGENT_IMAGE;
|
||||
const cwd = options.cwd ?? DEFAULT_CWD;
|
||||
const previewTtlSeconds = options.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS;
|
||||
const client = new Daytona();
|
||||
|
||||
return {
|
||||
name: "daytona",
|
||||
defaultCwd: "/home/daytona",
|
||||
defaultCwd: cwd,
|
||||
async create(): Promise<string> {
|
||||
const createOpts = await resolveCreateOptions(options.create);
|
||||
const sandbox = await client.create({
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts";
|
|||
|
||||
const DEFAULT_AGENT_PORT = 3000;
|
||||
const DEFAULT_TIMEOUT_MS = 3_600_000;
|
||||
const SANDBOX_AGENT_PATH_EXPORT = 'export PATH="/usr/local/bin:$HOME/.local/bin:$PATH"';
|
||||
|
||||
type E2BCreateOverrides = Omit<Partial<SandboxBetaCreateOpts>, "timeoutMs" | "autoPause">;
|
||||
type E2BConnectOverrides = Omit<Partial<SandboxConnectOpts>, "timeoutMs">;
|
||||
|
|
@ -35,6 +36,11 @@ async function resolveTemplate(value: E2BTemplateOverride | undefined): Promise<
|
|||
return typeof value === "function" ? await value() : value;
|
||||
}
|
||||
|
||||
function buildShellCommand(command: string, strict = false): string {
|
||||
const strictPrefix = strict ? "set -euo pipefail; " : "";
|
||||
return `bash -lc '${strictPrefix}${SANDBOX_AGENT_PATH_EXPORT}; ${command}'`;
|
||||
}
|
||||
|
||||
export function e2b(options: E2BProviderOptions = {}): SandboxProvider {
|
||||
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
|
|
@ -56,15 +62,15 @@ export function e2b(options: E2BProviderOptions = {}): SandboxProvider {
|
|||
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await Sandbox.betaCreate({ allowInternetAccess: true, ...restCreateOpts, timeoutMs, autoPause } as any);
|
||||
|
||||
await sandbox.commands.run(`curl -fsSL ${SANDBOX_AGENT_INSTALL_SCRIPT} | sh`).then((r) => {
|
||||
await sandbox.commands.run(buildShellCommand(`curl -fsSL ${SANDBOX_AGENT_INSTALL_SCRIPT} | sh`, true)).then((r) => {
|
||||
if (r.exitCode !== 0) throw new Error(`e2b install failed:\n${r.stderr}`);
|
||||
});
|
||||
for (const agent of DEFAULT_AGENTS) {
|
||||
await sandbox.commands.run(`sandbox-agent install-agent ${agent}`).then((r) => {
|
||||
await sandbox.commands.run(buildShellCommand(`sandbox-agent install-agent ${agent}`)).then((r) => {
|
||||
if (r.exitCode !== 0) throw new Error(`e2b agent install failed: ${agent}\n${r.stderr}`);
|
||||
});
|
||||
}
|
||||
await sandbox.commands.run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`, { background: true, timeoutMs: 0 });
|
||||
await sandbox.commands.run(buildShellCommand(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`), { background: true, timeoutMs: 0 });
|
||||
|
||||
return sandbox.sandboxId;
|
||||
},
|
||||
|
|
@ -100,7 +106,7 @@ export function e2b(options: E2BProviderOptions = {}): SandboxProvider {
|
|||
async ensureServer(sandboxId: string): Promise<void> {
|
||||
const connectOpts = await resolveOptions(options.connect, sandboxId);
|
||||
const sandbox = await Sandbox.connect(sandboxId, { ...connectOpts, timeoutMs } as SandboxConnectOpts);
|
||||
await sandbox.commands.run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`, { background: true, timeoutMs: 0 });
|
||||
await sandbox.commands.run(buildShellCommand(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`), { background: true, timeoutMs: 0 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
export const DEFAULT_SANDBOX_AGENT_IMAGE = "rivetdev/sandbox-agent:0.5.0-rc.2-full";
|
||||
export const SANDBOX_AGENT_INSTALL_SCRIPT = "https://releases.rivet.dev/sandbox-agent/0.4.x/install.sh";
|
||||
export const SANDBOX_AGENT_VERSION = "0.5.0-rc.2";
|
||||
export const DEFAULT_SANDBOX_AGENT_IMAGE = `rivetdev/sandbox-agent:${SANDBOX_AGENT_VERSION}-full`;
|
||||
export const SANDBOX_AGENT_INSTALL_SCRIPT = `https://releases.rivet.dev/sandbox-agent/${SANDBOX_AGENT_VERSION}/install.sh`;
|
||||
export const SANDBOX_AGENT_NPX_SPEC = `@sandbox-agent/cli@${SANDBOX_AGENT_VERSION}`;
|
||||
export const DEFAULT_AGENTS = ["claude", "codex"] as const;
|
||||
|
||||
export function buildServerStartCommand(port: number): string {
|
||||
|
|
|
|||
267
sdks/typescript/src/providers/sprites.ts
Normal file
267
sdks/typescript/src/providers/sprites.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import { ExecError, SpritesClient, type ClientOptions as SpritesClientOptions, type SpriteConfig } from "@fly/sprites";
|
||||
import { SandboxDestroyedError } from "../client.ts";
|
||||
import type { SandboxProvider } from "./types.ts";
|
||||
import { SANDBOX_AGENT_NPX_SPEC } from "./shared.ts";
|
||||
|
||||
const DEFAULT_AGENT_PORT = 8080;
|
||||
const DEFAULT_SERVICE_NAME = "sandbox-agent";
|
||||
const DEFAULT_NAME_PREFIX = "sandbox-agent";
|
||||
const DEFAULT_SERVICE_START_DURATION = "10m";
|
||||
|
||||
export interface SpritesCreateOverrides {
|
||||
name?: string;
|
||||
config?: SpriteConfig;
|
||||
}
|
||||
|
||||
export type SpritesClientOverrides = Partial<SpritesClientOptions>;
|
||||
|
||||
export interface SpritesProviderOptions {
|
||||
token?: string | (() => string | Promise<string>);
|
||||
client?: SpritesClientOverrides | (() => SpritesClientOverrides | Promise<SpritesClientOverrides>);
|
||||
create?: SpritesCreateOverrides | (() => SpritesCreateOverrides | Promise<SpritesCreateOverrides>);
|
||||
env?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
|
||||
installAgents?: readonly string[];
|
||||
agentPort?: number;
|
||||
serviceName?: string;
|
||||
serviceStartDuration?: string;
|
||||
namePrefix?: string;
|
||||
}
|
||||
|
||||
type SpritesSandboxProvider = SandboxProvider & {
|
||||
getToken(sandboxId: string): Promise<string>;
|
||||
};
|
||||
|
||||
interface SpritesService {
|
||||
cmd?: string;
|
||||
args?: string[];
|
||||
http_port?: number | null;
|
||||
state?: {
|
||||
status?: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveValue<T>(value: T | (() => T | Promise<T>) | undefined, fallback: T): Promise<T> {
|
||||
if (value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value === "function") {
|
||||
return await (value as () => T | Promise<T>)();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function resolveToken(value: SpritesProviderOptions["token"]): Promise<string> {
|
||||
const token = await resolveValue(value, process.env.SPRITES_API_KEY ?? process.env.SPRITE_TOKEN ?? process.env.SPRITES_TOKEN ?? "");
|
||||
if (!token) {
|
||||
throw new Error("sprites provider requires a token. Set SPRITES_API_KEY (or SPRITE_TOKEN) or pass `token`.");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function createSpritesClient(token: string, options: SpritesClientOverrides): SpritesClient {
|
||||
return new SpritesClient(token, options);
|
||||
}
|
||||
|
||||
function generateSpriteName(prefix: string): string {
|
||||
const suffix =
|
||||
typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID().slice(0, 8)
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return `${prefix}-${suffix}`.toLowerCase();
|
||||
}
|
||||
|
||||
function isSpriteNotFoundError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.startsWith("Sprite not found:");
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function buildServiceCommand(env: Record<string, string>, port: number): string {
|
||||
const exportParts: string[] = [];
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
||||
throw new Error(`sprites provider received an invalid environment variable name: ${key}`);
|
||||
}
|
||||
exportParts.push(`export ${key}=${shellQuote(value)}`);
|
||||
}
|
||||
|
||||
exportParts.push(`exec npx -y ${SANDBOX_AGENT_NPX_SPEC} server --no-token --host 0.0.0.0 --port ${port}`);
|
||||
return exportParts.join("; ");
|
||||
}
|
||||
|
||||
async function runSpriteCommand(sprite: ReturnType<SpritesClient["sprite"]>, file: string, args: string[], env?: Record<string, string>): Promise<void> {
|
||||
try {
|
||||
const result = await sprite.execFile(file, args, env ? { env } : undefined);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`sprites command failed: ${file} ${args.join(" ")}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ExecError) {
|
||||
throw new Error(
|
||||
`sprites command failed: ${file} ${args.join(" ")} (exit ${error.exitCode})\nstdout:\n${String(error.stdout)}\nstderr:\n${String(error.stderr)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchService(client: SpritesClient, spriteName: string, serviceName: string): Promise<SpritesService | undefined> {
|
||||
const response = await fetch(`${client.baseURL}/v1/sprites/${encodeURIComponent(spriteName)}/services/${encodeURIComponent(serviceName)}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${client.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`sprites service lookup failed (status ${response.status}): ${await response.text()}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as SpritesService;
|
||||
}
|
||||
|
||||
async function upsertService(client: SpritesClient, spriteName: string, serviceName: string, port: number, command: string): Promise<void> {
|
||||
const existing = await fetchService(client, spriteName, serviceName);
|
||||
const expectedArgs = ["-lc", command];
|
||||
const isCurrent = existing?.cmd === "bash" && existing.http_port === port && JSON.stringify(existing.args ?? []) === JSON.stringify(expectedArgs);
|
||||
if (isCurrent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${client.baseURL}/v1/sprites/${encodeURIComponent(spriteName)}/services/${encodeURIComponent(serviceName)}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${client.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
cmd: "bash",
|
||||
args: expectedArgs,
|
||||
http_port: port,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`sprites service upsert failed (status ${response.status}): ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function startServiceIfNeeded(client: SpritesClient, spriteName: string, serviceName: string, duration: string): Promise<void> {
|
||||
const existing = await fetchService(client, spriteName, serviceName);
|
||||
if (existing?.state?.status === "running" || existing?.state?.status === "starting") {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${client.baseURL}/v1/sprites/${encodeURIComponent(spriteName)}/services/${encodeURIComponent(serviceName)}/start?duration=${encodeURIComponent(duration)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${client.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`sprites service start failed (status ${response.status}): ${await response.text()}`);
|
||||
}
|
||||
|
||||
await response.text();
|
||||
}
|
||||
|
||||
async function ensureService(
|
||||
client: SpritesClient,
|
||||
spriteName: string,
|
||||
serviceName: string,
|
||||
port: number,
|
||||
duration: string,
|
||||
env: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const command = buildServiceCommand(env, port);
|
||||
await upsertService(client, spriteName, serviceName, port, command);
|
||||
await startServiceIfNeeded(client, spriteName, serviceName, duration);
|
||||
}
|
||||
|
||||
export function sprites(options: SpritesProviderOptions = {}): SandboxProvider {
|
||||
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
|
||||
const serviceName = options.serviceName ?? DEFAULT_SERVICE_NAME;
|
||||
const serviceStartDuration = options.serviceStartDuration ?? DEFAULT_SERVICE_START_DURATION;
|
||||
const namePrefix = options.namePrefix ?? DEFAULT_NAME_PREFIX;
|
||||
const installAgents = [...(options.installAgents ?? [])];
|
||||
|
||||
const getClient = async (): Promise<SpritesClient> => {
|
||||
const token = await resolveToken(options.token);
|
||||
const clientOptions = await resolveValue(options.client, {});
|
||||
return createSpritesClient(token, clientOptions);
|
||||
};
|
||||
|
||||
const getServerEnv = async (): Promise<Record<string, string>> => {
|
||||
return await resolveValue(options.env, {});
|
||||
};
|
||||
|
||||
const provider: SpritesSandboxProvider = {
|
||||
name: "sprites",
|
||||
defaultCwd: "/home/sprite",
|
||||
async create(): Promise<string> {
|
||||
const client = await getClient();
|
||||
const createOptions = await resolveValue(options.create, {});
|
||||
const spriteName = createOptions.name ?? generateSpriteName(namePrefix);
|
||||
const sprite = await client.createSprite(spriteName, createOptions.config);
|
||||
|
||||
const serverEnv = await getServerEnv();
|
||||
for (const agent of installAgents) {
|
||||
await runSpriteCommand(sprite, "bash", ["-lc", `npx -y ${SANDBOX_AGENT_NPX_SPEC} install-agent ${agent}`], serverEnv);
|
||||
}
|
||||
|
||||
await ensureService(client, spriteName, serviceName, agentPort, serviceStartDuration, serverEnv);
|
||||
return sprite.name;
|
||||
},
|
||||
async destroy(sandboxId: string): Promise<void> {
|
||||
const client = await getClient();
|
||||
try {
|
||||
await client.deleteSprite(sandboxId);
|
||||
} catch (error) {
|
||||
if (isSpriteNotFoundError(error) || (error instanceof Error && error.message.includes("status 404"))) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async reconnect(sandboxId: string): Promise<void> {
|
||||
const client = await getClient();
|
||||
try {
|
||||
await client.getSprite(sandboxId);
|
||||
} catch (error) {
|
||||
if (isSpriteNotFoundError(error)) {
|
||||
throw new SandboxDestroyedError(sandboxId, "sprites", { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async getUrl(sandboxId: string): Promise<string> {
|
||||
const client = await getClient();
|
||||
const sprite = await client.getSprite(sandboxId);
|
||||
const url = (sprite as { url?: string }).url;
|
||||
if (!url) {
|
||||
throw new Error(`sprites API did not return a URL for sprite: ${sandboxId}`);
|
||||
}
|
||||
return url;
|
||||
},
|
||||
async ensureServer(sandboxId: string): Promise<void> {
|
||||
const client = await getClient();
|
||||
await ensureService(client, sandboxId, serviceName, agentPort, serviceStartDuration, await getServerEnv());
|
||||
},
|
||||
async getToken(): Promise<string> {
|
||||
return await resolveToken(options.token);
|
||||
},
|
||||
};
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
|
@ -29,6 +29,12 @@ const computeSdkMocks = vi.hoisted(() => ({
|
|||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const spritesMocks = vi.hoisted(() => ({
|
||||
createSprite: vi.fn(),
|
||||
getSprite: vi.fn(),
|
||||
deleteSprite: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@e2b/code-interpreter", () => ({
|
||||
NotFoundError: e2bMocks.MockNotFoundError,
|
||||
Sandbox: {
|
||||
|
|
@ -58,9 +64,26 @@ vi.mock("computesdk", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("@fly/sprites", () => ({
|
||||
SpritesClient: class MockSpritesClient {
|
||||
readonly token: string;
|
||||
readonly baseURL: string;
|
||||
|
||||
constructor(token: string, options: { baseURL?: string } = {}) {
|
||||
this.token = token;
|
||||
this.baseURL = options.baseURL ?? "https://api.sprites.dev";
|
||||
}
|
||||
|
||||
createSprite = spritesMocks.createSprite;
|
||||
getSprite = spritesMocks.getSprite;
|
||||
deleteSprite = spritesMocks.deleteSprite;
|
||||
},
|
||||
}));
|
||||
|
||||
import { e2b } from "../src/providers/e2b.ts";
|
||||
import { modal } from "../src/providers/modal.ts";
|
||||
import { computesdk } from "../src/providers/computesdk.ts";
|
||||
import { sprites } from "../src/providers/sprites.ts";
|
||||
|
||||
function createFetch(): typeof fetch {
|
||||
return async () => new Response(null, { status: 200 });
|
||||
|
|
@ -110,6 +133,9 @@ beforeEach(() => {
|
|||
modalMocks.sandboxFromId.mockReset();
|
||||
computeSdkMocks.create.mockReset();
|
||||
computeSdkMocks.getById.mockReset();
|
||||
spritesMocks.createSprite.mockReset();
|
||||
spritesMocks.getSprite.mockReset();
|
||||
spritesMocks.deleteSprite.mockReset();
|
||||
});
|
||||
|
||||
describe("SandboxAgent provider lifecycle", () => {
|
||||
|
|
@ -308,7 +334,7 @@ describe("modal provider", () => {
|
|||
|
||||
expect(modalMocks.appsFromName).toHaveBeenCalledWith("custom-app", { createIfMissing: true });
|
||||
expect(modalMocks.imageFromRegistry).toHaveBeenCalledWith("python:3.12-slim");
|
||||
expect(image.dockerfileCommands).toHaveBeenCalled();
|
||||
expect(image.dockerfileCommands).not.toHaveBeenCalled();
|
||||
expect(modalMocks.sandboxCreate).toHaveBeenCalledWith(
|
||||
app,
|
||||
image,
|
||||
|
|
@ -347,3 +373,139 @@ describe("computesdk provider", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sprites provider", () => {
|
||||
it("creates a sprite, installs sandbox-agent, and configures the managed service", async () => {
|
||||
const sprite = {
|
||||
name: "sprite-1",
|
||||
execFile: vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })),
|
||||
};
|
||||
spritesMocks.createSprite.mockResolvedValue(sprite);
|
||||
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ state: { status: "stopped" } }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = sprites({
|
||||
token: "sprite-token",
|
||||
create: {
|
||||
name: "sprite-1",
|
||||
},
|
||||
env: {
|
||||
OPENAI_API_KEY: "test'value",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(provider.create()).resolves.toBe("sprite-1");
|
||||
|
||||
expect(spritesMocks.createSprite).toHaveBeenCalledWith("sprite-1", undefined);
|
||||
expect(sprite.execFile).not.toHaveBeenCalled();
|
||||
|
||||
const putCall = fetchMock.mock.calls.find(([url, init]) => String(url).includes("/services/sandbox-agent") && init?.method === "PUT");
|
||||
expect(putCall).toBeDefined();
|
||||
expect(String(putCall?.[0])).toContain("/v1/sprites/sprite-1/services/sandbox-agent");
|
||||
expect(putCall?.[1]?.headers).toMatchObject({
|
||||
Authorization: "Bearer sprite-token",
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
const serviceRequest = JSON.parse(String(putCall?.[1]?.body)) as { args: string[] };
|
||||
expect(serviceRequest.args[1]).toContain("exec npx -y @sandbox-agent/cli@0.5.0-rc.2 server --no-token --host 0.0.0.0 --port 8080");
|
||||
expect(serviceRequest.args[1]).toContain("OPENAI_API_KEY='test'\\''value'");
|
||||
});
|
||||
|
||||
it("optionally installs agents through npx when requested", async () => {
|
||||
const sprite = {
|
||||
name: "sprite-1",
|
||||
execFile: vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })),
|
||||
};
|
||||
spritesMocks.createSprite.mockResolvedValue(sprite);
|
||||
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ state: { status: "stopped" } }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = sprites({
|
||||
token: "sprite-token",
|
||||
create: { name: "sprite-1" },
|
||||
env: { OPENAI_API_KEY: "test" },
|
||||
installAgents: ["claude", "codex"],
|
||||
});
|
||||
|
||||
await provider.create();
|
||||
|
||||
expect(sprite.execFile).toHaveBeenCalledWith("bash", ["-lc", "npx -y @sandbox-agent/cli@0.5.0-rc.2 install-agent claude"], {
|
||||
env: { OPENAI_API_KEY: "test" },
|
||||
});
|
||||
expect(sprite.execFile).toHaveBeenCalledWith("bash", ["-lc", "npx -y @sandbox-agent/cli@0.5.0-rc.2 install-agent codex"], {
|
||||
env: { OPENAI_API_KEY: "test" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the sprite URL and provider token for authenticated access", async () => {
|
||||
spritesMocks.getSprite.mockResolvedValue({
|
||||
name: "sprite-1",
|
||||
url: "https://sprite-1.sprites.app",
|
||||
});
|
||||
|
||||
const provider = sprites({
|
||||
token: "sprite-token",
|
||||
});
|
||||
|
||||
await expect(provider.getUrl?.("sprite-1")).resolves.toBe("https://sprite-1.sprites.app");
|
||||
await expect((provider as SandboxProvider & { getToken: (sandboxId: string) => Promise<string> }).getToken("sprite-1")).resolves.toBe("sprite-token");
|
||||
});
|
||||
|
||||
it("maps missing reconnect targets to SandboxDestroyedError", async () => {
|
||||
spritesMocks.getSprite.mockRejectedValue(new Error("Sprite not found: missing-sprite"));
|
||||
const provider = sprites({
|
||||
token: "sprite-token",
|
||||
});
|
||||
|
||||
await expect(provider.reconnect?.("missing-sprite")).rejects.toBeInstanceOf(SandboxDestroyedError);
|
||||
});
|
||||
|
||||
it("skips starting the service when the desired service is already running", async () => {
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
cmd: "bash",
|
||||
args: ["-lc", "exec npx -y @sandbox-agent/cli@0.5.0-rc.2 server --no-token --host 0.0.0.0 --port 8080"],
|
||||
http_port: 8080,
|
||||
state: { status: "running" },
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
cmd: "bash",
|
||||
args: ["-lc", "exec npx -y @sandbox-agent/cli@0.5.0-rc.2 server --no-token --host 0.0.0.0 --port 8080"],
|
||||
http_port: 8080,
|
||||
state: { status: "running" },
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = sprites({
|
||||
token: "sprite-token",
|
||||
});
|
||||
|
||||
await provider.ensureServer?.("sprite-1");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls.every(([, init]) => init?.method === "GET")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { daytona } from "../src/providers/daytona.ts";
|
|||
import { vercel } from "../src/providers/vercel.ts";
|
||||
import { modal } from "../src/providers/modal.ts";
|
||||
import { computesdk } from "../src/providers/computesdk.ts";
|
||||
import { sprites } from "../src/providers/sprites.ts";
|
||||
import { prepareMockAgentDataHome } from "./helpers/mock-agent.ts";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -47,7 +48,7 @@ function isModuleAvailable(name: string): boolean {
|
|||
_require.resolve(name);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
return existsSync(resolve(__dirname, "../node_modules", ...name.split("/"), "package.json"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +70,8 @@ interface ProviderEntry {
|
|||
name: string;
|
||||
/** Human-readable reasons this provider can't run, or empty if ready. */
|
||||
skipReasons: string[];
|
||||
/** Human-readable reasons session tests can't run, or empty if ready. */
|
||||
sessionSkipReasons?: string[];
|
||||
/** Return a fresh provider instance for a single test. */
|
||||
createProvider: () => SandboxProvider;
|
||||
/** Optional per-provider setup (e.g. create temp dirs). Returns cleanup fn. */
|
||||
|
|
@ -79,6 +82,8 @@ interface ProviderEntry {
|
|||
startTimeoutMs?: number;
|
||||
/** Some providers (e.g. local) can verify the sandbox is gone after destroy. */
|
||||
canVerifyDestroyedSandbox?: boolean;
|
||||
/** Working directory to use for createSession/prompt tests. */
|
||||
sessionCwd?: string;
|
||||
/**
|
||||
* Whether session tests (createSession, prompt) should run.
|
||||
* The mock agent only works with local provider (requires mock-acp process binary).
|
||||
|
|
@ -92,6 +97,10 @@ function missingEnvVars(...vars: string[]): string[] {
|
|||
return missing.length > 0 ? [`missing env: ${missing.join(", ")}`] : [];
|
||||
}
|
||||
|
||||
function missingAnyEnvVars(...vars: string[]): string[] {
|
||||
return vars.some((v) => Boolean(process.env[v])) ? [] : [`missing env: one of ${vars.join(", ")}`];
|
||||
}
|
||||
|
||||
function missingModules(...modules: string[]): string[] {
|
||||
const missing = modules.filter((m) => !isModuleAvailable(m));
|
||||
return missing.length > 0 ? [`missing npm packages: ${missing.join(", ")}`] : [];
|
||||
|
|
@ -116,6 +125,7 @@ function buildProviders(): ProviderEntry[] {
|
|||
skipReasons: [],
|
||||
agent: "mock",
|
||||
canVerifyDestroyedSandbox: true,
|
||||
sessionCwd: process.cwd(),
|
||||
sessionTestsEnabled: true,
|
||||
setup() {
|
||||
dataHome = mkdtempSync(join(tmpdir(), "sdk-provider-local-"));
|
||||
|
|
@ -165,7 +175,6 @@ function buildProviders(): ProviderEntry[] {
|
|||
}
|
||||
|
||||
// --- e2b ---
|
||||
// Session tests disabled: see docker comment above (ACP protocol mismatch).
|
||||
{
|
||||
entries.push({
|
||||
name: "e2b",
|
||||
|
|
@ -173,7 +182,9 @@ function buildProviders(): ProviderEntry[] {
|
|||
agent: "claude",
|
||||
startTimeoutMs: 300_000,
|
||||
canVerifyDestroyedSandbox: false,
|
||||
sessionTestsEnabled: false,
|
||||
sessionSkipReasons: missingEnvVars("ANTHROPIC_API_KEY"),
|
||||
sessionCwd: "/home/user",
|
||||
sessionTestsEnabled: true,
|
||||
createProvider() {
|
||||
return e2b({
|
||||
create: { envs: collectApiKeys() },
|
||||
|
|
@ -183,7 +194,6 @@ function buildProviders(): ProviderEntry[] {
|
|||
}
|
||||
|
||||
// --- daytona ---
|
||||
// Session tests disabled: see docker comment above (ACP protocol mismatch).
|
||||
{
|
||||
entries.push({
|
||||
name: "daytona",
|
||||
|
|
@ -191,7 +201,9 @@ function buildProviders(): ProviderEntry[] {
|
|||
agent: "claude",
|
||||
startTimeoutMs: 300_000,
|
||||
canVerifyDestroyedSandbox: false,
|
||||
sessionTestsEnabled: false,
|
||||
sessionSkipReasons: missingEnvVars("ANTHROPIC_API_KEY"),
|
||||
sessionCwd: "/home/sandbox",
|
||||
sessionTestsEnabled: true,
|
||||
createProvider() {
|
||||
return daytona({
|
||||
create: { envVars: collectApiKeys() },
|
||||
|
|
@ -201,7 +213,6 @@ function buildProviders(): ProviderEntry[] {
|
|||
}
|
||||
|
||||
// --- vercel ---
|
||||
// Session tests disabled: see docker comment above (ACP protocol mismatch).
|
||||
{
|
||||
entries.push({
|
||||
name: "vercel",
|
||||
|
|
@ -219,7 +230,6 @@ function buildProviders(): ProviderEntry[] {
|
|||
}
|
||||
|
||||
// --- modal ---
|
||||
// Session tests disabled: see docker comment above (ACP protocol mismatch).
|
||||
{
|
||||
entries.push({
|
||||
name: "modal",
|
||||
|
|
@ -227,9 +237,12 @@ function buildProviders(): ProviderEntry[] {
|
|||
agent: "claude",
|
||||
startTimeoutMs: 300_000,
|
||||
canVerifyDestroyedSandbox: false,
|
||||
sessionTestsEnabled: false,
|
||||
sessionSkipReasons: missingEnvVars("ANTHROPIC_API_KEY"),
|
||||
sessionCwd: "/root",
|
||||
sessionTestsEnabled: true,
|
||||
createProvider() {
|
||||
return modal({
|
||||
image: process.env.SANDBOX_AGENT_MODAL_IMAGE,
|
||||
create: { secrets: collectApiKeys() },
|
||||
});
|
||||
},
|
||||
|
|
@ -237,7 +250,6 @@ function buildProviders(): ProviderEntry[] {
|
|||
}
|
||||
|
||||
// --- computesdk ---
|
||||
// Session tests disabled: see docker comment above (ACP protocol mismatch).
|
||||
{
|
||||
entries.push({
|
||||
name: "computesdk",
|
||||
|
|
@ -254,6 +266,28 @@ function buildProviders(): ProviderEntry[] {
|
|||
});
|
||||
}
|
||||
|
||||
// --- sprites ---
|
||||
{
|
||||
entries.push({
|
||||
name: "sprites",
|
||||
skipReasons: [...missingAnyEnvVars("SPRITES_API_KEY", "SPRITE_TOKEN", "SPRITES_TOKEN"), ...missingModules("@fly/sprites")],
|
||||
agent: "claude",
|
||||
startTimeoutMs: 300_000,
|
||||
canVerifyDestroyedSandbox: false,
|
||||
sessionSkipReasons: missingEnvVars("ANTHROPIC_API_KEY"),
|
||||
sessionCwd: "/home/sprite",
|
||||
sessionTestsEnabled: true,
|
||||
createProvider() {
|
||||
return sprites({
|
||||
token: process.env.SPRITES_API_KEY ?? process.env.SPRITE_TOKEN ?? process.env.SPRITES_TOKEN,
|
||||
env: collectApiKeys(),
|
||||
installAgents: ["claude"],
|
||||
serviceStartDuration: "10m",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
|
|
@ -375,7 +409,7 @@ function providerSuite(entry: ProviderEntry) {
|
|||
|
||||
// -- session tests (require working agent) --
|
||||
|
||||
const sessionIt = entry.sessionTestsEnabled ? it : it.skip;
|
||||
const sessionIt = entry.sessionTestsEnabled && (entry.sessionSkipReasons?.length ?? 0) === 0 ? it : it.skip;
|
||||
|
||||
sessionIt(
|
||||
"creates sessions with persisted sandboxId",
|
||||
|
|
@ -383,7 +417,7 @@ function providerSuite(entry: ProviderEntry) {
|
|||
const persist = new InMemorySessionPersistDriver();
|
||||
sdk = await SandboxAgent.start({ sandbox: entry.createProvider(), persist });
|
||||
|
||||
const session = await sdk.createSession({ agent: entry.agent });
|
||||
const session = await sdk.createSession({ agent: entry.agent, cwd: entry.sessionCwd });
|
||||
const record = await persist.getSession(session.id);
|
||||
|
||||
expect(record?.sandboxId).toBe(sdk.sandboxId);
|
||||
|
|
@ -396,7 +430,7 @@ function providerSuite(entry: ProviderEntry) {
|
|||
async () => {
|
||||
sdk = await SandboxAgent.start({ sandbox: entry.createProvider() });
|
||||
|
||||
const session = await sdk.createSession({ agent: entry.agent });
|
||||
const session = await sdk.createSession({ agent: entry.agent, cwd: entry.sessionCwd });
|
||||
const events: unknown[] = [];
|
||||
const off = session.onEvent((event) => {
|
||||
events.push(event);
|
||||
|
|
|
|||
|
|
@ -11,10 +11,21 @@ export default defineConfig({
|
|||
"src/providers/cloudflare.ts",
|
||||
"src/providers/modal.ts",
|
||||
"src/providers/computesdk.ts",
|
||||
"src/providers/sprites.ts",
|
||||
],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
external: ["@cloudflare/sandbox", "@daytonaio/sdk", "@e2b/code-interpreter", "@vercel/sandbox", "dockerode", "get-port", "modal", "computesdk"],
|
||||
external: [
|
||||
"@cloudflare/sandbox",
|
||||
"@daytonaio/sdk",
|
||||
"@e2b/code-interpreter",
|
||||
"@fly/sprites",
|
||||
"@vercel/sandbox",
|
||||
"dockerode",
|
||||
"get-port",
|
||||
"modal",
|
||||
"computesdk",
|
||||
],
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue