diff --git a/docs/deploy/daytona.mdx b/docs/deploy/daytona.mdx index e546bef9..edb499f8 100644 --- a/docs/deploy/daytona.mdx +++ b/docs/deploy/daytona.mdx @@ -50,14 +50,44 @@ The `daytona` provider uses the `rivetdev/sandbox-agent:0.4.2-full` image by def ```typescript import { Daytona, Image } from "@daytonaio/sdk"; +import { SandboxAgent } from "sandbox-agent"; +import { daytona } from "sandbox-agent/daytona"; + +class DaytonaSnapshotNotFoundError extends Error { + constructor(snapshotName: string, options?: ErrorOptions) { + super(`daytona snapshot not found: ${snapshotName}`, options); + this.name = "DaytonaSnapshotNotFoundError"; + } +} -const daytona = new Daytona(); +async function ensureDaytonaSnapshotExists(client: Daytona, snapshotName: string): Promise { + try { + await client.snapshot.get(snapshotName); + } catch (error) { + if (typeof error === "object" && error !== null && "statusCode" in error && error.statusCode === 404) { + throw new DaytonaSnapshotNotFoundError(snapshotName, { cause: error }); + } + throw error; + } +} + +const client = new Daytona(); const SNAPSHOT = "sandbox-agent-ready"; +const envVars: Record = {}; + +if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; +if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY; -const hasSnapshot = await daytona.snapshot.get(SNAPSHOT).then(() => true, () => false); +const hasSnapshot = await ensureDaytonaSnapshotExists(client, SNAPSHOT).then( + () => true, + (error) => { + if (error instanceof DaytonaSnapshotNotFoundError) return false; + throw error; + }, +); if (!hasSnapshot) { - await daytona.snapshot.create({ + await client.snapshot.create({ name: SNAPSHOT, image: Image.base("ubuntu:22.04").runCommands( "apt-get update && apt-get install -y curl ca-certificates", @@ -67,4 +97,15 @@ if (!hasSnapshot) { ), }); } + +const sdk = await SandboxAgent.start({ + sandbox: daytona({ + create: { + snapshot: SNAPSHOT, + envVars, + }, + }), +}); ``` + +When `create.snapshot` is provided, the Daytona provider starts from that existing snapshot instead of forcing the default image. diff --git a/examples/daytona/src/daytona.ts b/examples/daytona/src/daytona.ts index ccffc94e..abb812b0 100644 --- a/examples/daytona/src/daytona.ts +++ b/examples/daytona/src/daytona.ts @@ -1,6 +1,27 @@ +import { Daytona, Image } from "@daytonaio/sdk"; import { SandboxAgent } from "sandbox-agent"; import { daytona } from "sandbox-agent/daytona"; +const SNAPSHOT = "sandbox-agent-ready"; + +class DaytonaSnapshotNotFoundError extends Error { + constructor(snapshotName: string, options?: ErrorOptions) { + super(`daytona snapshot not found: ${snapshotName}`, options); + this.name = "DaytonaSnapshotNotFoundError"; + } +} + +async function ensureDaytonaSnapshotExists(client: Daytona, snapshotName: string): Promise { + try { + await client.snapshot.get(snapshotName); + } catch (error) { + if (typeof error === "object" && error !== null && "statusCode" in error && error.statusCode === 404) { + throw new DaytonaSnapshotNotFoundError(snapshotName, { cause: error }); + } + throw error; + } +} + function collectEnvVars(): Record { const envVars: Record = {}; if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -18,9 +39,30 @@ export async function setupDaytonaSandboxAgent(): Promise<{ extraHeaders?: Record; cleanup: () => Promise; }> { + const daytonaClient = new Daytona(); + const hasSnapshot = await ensureDaytonaSnapshotExists(daytonaClient, SNAPSHOT).then( + () => true, + (error) => { + if (error instanceof DaytonaSnapshotNotFoundError) return false; + throw error; + }, + ); + + if (!hasSnapshot) { + await daytonaClient.snapshot.create({ + name: SNAPSHOT, + image: Image.base("ubuntu:22.04").runCommands( + "apt-get update && apt-get install -y curl ca-certificates", + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.4.x/install.sh | sh", + "sandbox-agent install-agent claude", + "sandbox-agent install-agent codex", + ), + }); + } + const client = await SandboxAgent.start({ sandbox: daytona({ - create: { envVars: collectEnvVars() }, + create: { snapshot: SNAPSHOT, envVars: collectEnvVars() }, }), }); diff --git a/examples/daytona/src/index.ts b/examples/daytona/src/index.ts index 9c4cf85b..3be78bd6 100644 --- a/examples/daytona/src/index.ts +++ b/examples/daytona/src/index.ts @@ -1,14 +1,55 @@ +import { Daytona, Image } from "@daytonaio/sdk"; import { SandboxAgent } from "sandbox-agent"; import { daytona } from "sandbox-agent/daytona"; import { detectAgent } from "@sandbox-agent/example-shared"; +const SNAPSHOT = "sandbox-agent-ready"; const envVars: Record = {}; if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY; +class DaytonaSnapshotNotFoundError extends Error { + constructor(snapshotName: string, options?: ErrorOptions) { + super(`daytona snapshot not found: ${snapshotName}`, options); + this.name = "DaytonaSnapshotNotFoundError"; + } +} + +async function ensureDaytonaSnapshotExists(client: Daytona, snapshotName: string): Promise { + try { + await client.snapshot.get(snapshotName); + } catch (error) { + if (typeof error === "object" && error !== null && "statusCode" in error && error.statusCode === 404) { + throw new DaytonaSnapshotNotFoundError(snapshotName, { cause: error }); + } + throw error; + } +} + +const daytonaClient = new Daytona(); +const hasSnapshot = await ensureDaytonaSnapshotExists(daytonaClient, SNAPSHOT).then( + () => true, + (error) => { + if (error instanceof DaytonaSnapshotNotFoundError) return false; + throw error; + }, +); + +if (!hasSnapshot) { + await daytonaClient.snapshot.create({ + name: SNAPSHOT, + image: Image.base("ubuntu:22.04").runCommands( + "apt-get update && apt-get install -y curl ca-certificates", + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.4.x/install.sh | sh", + "sandbox-agent install-agent claude", + "sandbox-agent install-agent codex", + ), + }); +} + const client = await SandboxAgent.start({ sandbox: daytona({ - create: { envVars }, + create: { snapshot: SNAPSHOT, envVars }, }), }); diff --git a/sdks/typescript/src/providers/daytona.ts b/sdks/typescript/src/providers/daytona.ts index 7df740cd..42cacf94 100644 --- a/sdks/typescript/src/providers/daytona.ts +++ b/sdks/typescript/src/providers/daytona.ts @@ -1,4 +1,4 @@ -import { Daytona } from "@daytonaio/sdk"; +import { Daytona, type CreateSandboxFromImageParams, type CreateSandboxFromSnapshotParams } from "@daytonaio/sdk"; import type { SandboxProvider } from "./types.ts"; import { DEFAULT_SANDBOX_AGENT_IMAGE, buildServerStartCommand } from "./shared.ts"; @@ -6,13 +6,11 @@ const DEFAULT_AGENT_PORT = 3000; const DEFAULT_PREVIEW_TTL_SECONDS = 4 * 60 * 60; const DEFAULT_CWD = "/home/sandbox"; -type DaytonaCreateParams = NonNullable[0]>; - -type DaytonaCreateOverrides = Partial; +type DaytonaCreateOverrides = Partial; export interface DaytonaProviderOptions { create?: DaytonaCreateOverrides | (() => DaytonaCreateOverrides | Promise); - image?: DaytonaCreateParams["image"]; + image?: CreateSandboxFromImageParams["image"]; agentPort?: number; cwd?: string; previewTtlSeconds?: number; @@ -25,6 +23,14 @@ async function resolveCreateOptions(value: DaytonaProviderOptions["create"]): Pr return value; } +function getSnapshotName(createOpts: DaytonaCreateOverrides | undefined): string | undefined { + if (!createOpts || !Object.prototype.hasOwnProperty.call(createOpts, "snapshot")) { + return undefined; + } + const snapshot = (createOpts as CreateSandboxFromSnapshotParams).snapshot; + return typeof snapshot === "string" && snapshot.length > 0 ? snapshot : undefined; +} + export function daytona(options: DaytonaProviderOptions = {}): SandboxProvider { const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT; const image = options.image ?? DEFAULT_SANDBOX_AGENT_IMAGE; @@ -37,11 +43,24 @@ export function daytona(options: DaytonaProviderOptions = {}): SandboxProvider { defaultCwd: cwd, async create(): Promise { const createOpts = await resolveCreateOptions(options.create); - const sandbox = await client.create({ - image, - autoStopInterval: 0, - ...createOpts, - } as DaytonaCreateParams); + const snapshot = getSnapshotName(createOpts); + const hasSnapshot = snapshot !== undefined; + const createImage = createOpts && "image" in createOpts ? createOpts.image : undefined; + + if (hasSnapshot && (options.image !== undefined || createImage !== undefined)) { + throw new Error("daytona provider does not support combining snapshot with image; pass only create.snapshot or image"); + } + + const sandbox = hasSnapshot + ? await client.create({ + autoStopInterval: 0, + ...createOpts, + } as CreateSandboxFromSnapshotParams) + : await client.create({ + image, + autoStopInterval: 0, + ...createOpts, + } as CreateSandboxFromImageParams); await sandbox.process.executeCommand(buildServerStartCommand(agentPort)); return sandbox.id; }, diff --git a/sdks/typescript/tests/provider-lifecycle.test.ts b/sdks/typescript/tests/provider-lifecycle.test.ts index b9905152..82fbf0d4 100644 --- a/sdks/typescript/tests/provider-lifecycle.test.ts +++ b/sdks/typescript/tests/provider-lifecycle.test.ts @@ -29,6 +29,11 @@ const computeSdkMocks = vi.hoisted(() => ({ getById: vi.fn(), })); +const daytonaMocks = vi.hoisted(() => ({ + create: vi.fn(), + get: vi.fn(), +})); + const spritesMocks = vi.hoisted(() => ({ createSprite: vi.fn(), getSprite: vi.fn(), @@ -64,6 +69,13 @@ vi.mock("computesdk", () => ({ }, })); +vi.mock("@daytonaio/sdk", () => ({ + Daytona: class MockDaytona { + create = daytonaMocks.create; + get = daytonaMocks.get; + }, +})); + vi.mock("@fly/sprites", () => ({ SpritesClient: class MockSpritesClient { readonly token: string; @@ -85,6 +97,7 @@ import { modal } from "../src/providers/modal.ts"; import { computesdk } from "../src/providers/computesdk.ts"; import { agentcomputer } from "../src/providers/agentcomputer.ts"; import { sprites } from "../src/providers/sprites.ts"; +import { daytona } from "../src/providers/daytona.ts"; function createFetch(): typeof fetch { return async () => new Response(null, { status: 200 }); @@ -135,6 +148,17 @@ function createMockModalImage() { }; } +function createMockDaytonaSandbox() { + return { + id: "daytona-123", + process: { + executeCommand: vi.fn(async () => ({ result: "" })), + }, + delete: vi.fn(async () => undefined), + getSignedPreviewUrl: vi.fn(async () => ({ url: "https://daytona.example" })), + }; +} + beforeEach(() => { e2bMocks.betaCreate.mockReset(); e2bMocks.connect.mockReset(); @@ -145,6 +169,8 @@ beforeEach(() => { modalMocks.sandboxFromId.mockReset(); computeSdkMocks.create.mockReset(); computeSdkMocks.getById.mockReset(); + daytonaMocks.create.mockReset(); + daytonaMocks.get.mockReset(); spritesMocks.createSprite.mockReset(); spritesMocks.getSprite.mockReset(); spritesMocks.deleteSprite.mockReset(); @@ -342,6 +368,61 @@ describe("e2b provider", () => { }); }); +describe("daytona provider", () => { + it("creates sandboxes from snapshots without injecting the default image", async () => { + const sandbox = createMockDaytonaSandbox(); + daytonaMocks.create.mockResolvedValue(sandbox); + + const provider = daytona({ + create: { + snapshot: "sandbox-agent-ready", + envVars: { ANTHROPIC_API_KEY: "test" }, + }, + }); + + await expect(provider.create()).resolves.toBe("daytona-123"); + + expect(daytonaMocks.create).toHaveBeenCalledWith({ + autoStopInterval: 0, + snapshot: "sandbox-agent-ready", + envVars: { ANTHROPIC_API_KEY: "test" }, + }); + }); + + it("creates sandboxes from the configured image when no snapshot is provided", async () => { + const sandbox = createMockDaytonaSandbox(); + daytonaMocks.create.mockResolvedValue(sandbox); + + const provider = daytona({ + image: "ghcr.io/example/custom-image:latest", + create: { + envVars: { OPENAI_API_KEY: "test" }, + }, + }); + + await provider.create(); + + expect(daytonaMocks.create).toHaveBeenCalledWith({ + image: "ghcr.io/example/custom-image:latest", + autoStopInterval: 0, + envVars: { OPENAI_API_KEY: "test" }, + }); + }); + + it("rejects combining snapshot and image inputs", async () => { + const provider = daytona({ + image: "ghcr.io/example/custom-image:latest", + create: { + snapshot: "sandbox-agent-ready", + }, + }); + + await expect(provider.create()).rejects.toThrow("daytona provider does not support combining snapshot with image; pass only create.snapshot or image"); + + expect(daytonaMocks.create).not.toHaveBeenCalled(); + }); +}); + describe("modal provider", () => { it("uses the configured base image when building the sandbox image", async () => { const app = { appId: "app-123" };