feat: sprites support

This commit is contained in:
Nathan Flurry 2026-03-25 12:22:00 -07:00
parent 9cd9252725
commit 5da35e6dfa
35 changed files with 746 additions and 1257 deletions

View file

@ -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);
});
});

View file

@ -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);