diff --git a/.changeset/fork-vercel-sandbox-sessions.md b/.changeset/fork-vercel-sandbox-sessions.md new file mode 100644 index 000000000..306ec1b6e --- /dev/null +++ b/.changeset/fork-vercel-sandbox-sessions.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Create template-backed Vercel Sandbox sessions with the platform fork API, so eve no longer needs to pass template snapshot IDs into new sessions. diff --git a/packages/eve/src/execution/sandbox/bindings/vercel-sdk-types.ts b/packages/eve/src/execution/sandbox/bindings/vercel-sdk-types.ts index b5f67d252..8c1901672 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel-sdk-types.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel-sdk-types.ts @@ -2,6 +2,8 @@ import type * as Vercel from "#compiled/@vercel/sandbox/index.js"; export type VercelCreateOptions = NonNullable[0]>; +export type VercelForkOptions = Parameters[0]; + export type VercelGetOptions = Parameters[0]; export type VercelModule = typeof Vercel; diff --git a/packages/eve/src/execution/sandbox/bindings/vercel.test.ts b/packages/eve/src/execution/sandbox/bindings/vercel.test.ts index 1a1f85928..ab9795930 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel.test.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel.test.ts @@ -75,10 +75,27 @@ function createMockSandbox(input: { } function createTestVercelSandbox(input: Parameters[0] = {}) { + const originalLoadSandboxModule = input.loadSandboxModule; + const loadSandboxModule = + originalLoadSandboxModule === undefined + ? undefined + : async () => { + const sandboxModule = await originalLoadSandboxModule(); + return { + ...sandboxModule, + Sandbox: { + ...sandboxModule.Sandbox, + fork: async (forkOptions: unknown) => + await sandboxModule.Sandbox.create(forkOptions as never), + }, + } as never; + }; + return createVercelSandbox({ ...input, createSandbox: async ({ createOptions, sandboxModule }) => await sandboxModule.Sandbox.create(createOptions), + loadSandboxModule, }); } @@ -465,6 +482,30 @@ describe("createVercelSandbox", () => { expect(sandboxModule.Sandbox.create).not.toHaveBeenCalled(); }); + it("rejects an unprovisioned template instead of forking from its base image", async () => { + const templateSandbox = createMockSandbox({ name: "template-key" }); + const sandboxModule = { + Sandbox: { + create: vi.fn(), + fork: vi.fn(), + get: vi.fn().mockResolvedValue(templateSandbox), + }, + }; + const backend = createVercelSandbox({ + loadSandboxModule: async () => sandboxModule as never, + }); + + await expect( + backend.create({ + runtimeContext: { appRoot: "/tmp/test-app-root" }, + sessionKey: "session-key", + templateKey: "template-key", + }), + ).rejects.toBeInstanceOf(SandboxTemplateNotProvisionedError); + + expect(sandboxModule.Sandbox.fork).not.toHaveBeenCalled(); + }); + it("removes paths through the sandbox filesystem API", async () => { const templateSandbox = createMockSandbox({ name: "template" }); const sessionSandbox = createMockSandbox({ name: "session" }); @@ -504,7 +545,7 @@ describe("createVercelSandbox", () => { expect(sessionSandbox.runCommand).not.toHaveBeenCalled(); }); - it("applies a 30-minute default timeout to Sandbox.create", async () => { + it("applies a 30-minute default timeout to template creation and session forks", async () => { const templateSandbox = createMockSandbox({ name: "template" }); const sessionSandbox = createMockSandbox({ name: "session" }); const create = vi @@ -540,21 +581,20 @@ describe("createVercelSandbox", () => { expect(sessionArgs?.[0]).toMatchObject({ timeout: 30 * 60 * 1_000 }); }); - it("applies framework defaults to Sandbox.create when no createOptions are supplied", async () => { - const templateSandbox = createMockSandbox({ name: "template" }); + it("creates template-backed sessions through Sandbox.fork", async () => { + const templateSandbox = createMockSandbox({ name: "template-key" }); const sessionSandbox = createMockSandbox({ name: "session" }); - const create = vi - .fn() - .mockResolvedValueOnce(templateSandbox) - .mockResolvedValueOnce(sessionSandbox); + const create = vi.fn().mockResolvedValueOnce(templateSandbox); + const fork = vi.fn().mockResolvedValueOnce(sessionSandbox); const sandboxModule = { Sandbox: { create, + fork, get: vi.fn().mockResolvedValue(null), }, }; - const backend = createTestVercelSandbox({ + const backend = createVercelSandbox({ loadSandboxModule: async () => sandboxModule as never, }); @@ -570,9 +610,12 @@ describe("createVercelSandbox", () => { templateKey: "template-key", }); - expect(create).toHaveBeenCalledTimes(2); - const [templateArgs, sessionArgs] = create.mock.calls; + expect(create).toHaveBeenCalledTimes(1); + expect(fork).toHaveBeenCalledTimes(1); + const [templateArgs] = create.mock.calls; + const [sessionArgs] = fork.mock.calls; expect(templateArgs?.[0]).toMatchObject({ + image: "vercel/eve:latest", name: "template-key", persistent: false, timeout: 30 * 60 * 1_000, @@ -580,9 +623,12 @@ describe("createVercelSandbox", () => { expect(sessionArgs?.[0]).toMatchObject({ name: "session-key", persistent: true, + sourceSandbox: "template-key", timeout: 30 * 60 * 1_000, - source: { snapshotId: "template-snapshot", type: "snapshot" }, }); + expect(sessionArgs?.[0]).not.toHaveProperty("image"); + expect(sessionArgs?.[0]).not.toHaveProperty("runtime"); + expect(sessionArgs?.[0]).not.toHaveProperty("source"); }); it("creates a fresh session without reading or snapshotting a template when templateKey is null", async () => { @@ -655,7 +701,7 @@ describe("createVercelSandbox", () => { expect(sessionSandbox.snapshot).not.toHaveBeenCalled(); }); - it("forwards factory createOptions to both template and session Sandbox.create", async () => { + it("forwards factory createOptions to template creation and session forks", async () => { const templateSandbox = createMockSandbox({ name: "template" }); const sessionSandbox = createMockSandbox({ name: "session" }); const create = vi @@ -707,7 +753,7 @@ describe("createVercelSandbox", () => { persistent: true, ports: [3000, 4000], resources: { vcpus: 2 }, - source: { snapshotId: "template-snapshot", type: "snapshot" }, + sourceSandbox: "template", timeout: 600_000, }); expect(templateSandbox.update).toHaveBeenCalledWith({ networkPolicy: "deny-all" }); @@ -765,7 +811,7 @@ describe("createVercelSandbox", () => { }); expect(templateSandbox.snapshot).toHaveBeenCalledTimes(1); expect(sessionArgs?.[0]).toMatchObject({ - source: { snapshotId: "template-snapshot", type: "snapshot" }, + sourceSandbox: "template", }); }); @@ -813,7 +859,7 @@ describe("createVercelSandbox", () => { expect(existingTemplate.snapshot).toHaveBeenCalledTimes(1); expect(create).toHaveBeenCalledTimes(1); expect(create.mock.calls[0]?.[0]).toMatchObject({ - source: { snapshotId: "template-key-snapshot", type: "snapshot" }, + sourceSandbox: "template-key", }); }); @@ -886,7 +932,7 @@ describe("createVercelSandbox", () => { expect(create).toHaveBeenCalledTimes(3); expect(create.mock.calls[0]?.[0]).toMatchObject({ name: "session-key", - source: { snapshotId: "expired-template-snapshot", type: "snapshot" }, + sourceSandbox: "template-key", }); expect(create.mock.calls[1]?.[0]).toMatchObject({ name: "template-key", @@ -894,7 +940,7 @@ describe("createVercelSandbox", () => { }); expect(create.mock.calls[2]?.[0]).toMatchObject({ name: "session-key", - source: { snapshotId: "template-key-snapshot", type: "snapshot" }, + sourceSandbox: "template-key", }); }); @@ -1067,7 +1113,7 @@ describe("createVercelSandbox", () => { expect.objectContaining({ name: "deleted-sandbox", persistent: true, - source: { snapshotId: "template-snapshot", type: "snapshot" }, + sourceSandbox: "template-key", }), ); expect(handle.session).toBeDefined(); diff --git a/packages/eve/src/execution/sandbox/bindings/vercel.ts b/packages/eve/src/execution/sandbox/bindings/vercel.ts index d9714f8a9..48bd37af8 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel.ts @@ -36,6 +36,7 @@ import { type CreateVercelSandbox, type VercelSandboxCreateParams, } from "#execution/sandbox/bindings/vercel-create-sdk.js"; +import { getVercelSandboxFetch } from "#execution/sandbox/bindings/vercel-credentials.js"; import { isVercelSandboxMissingError, isVercelSnapshotUnavailableError, @@ -45,6 +46,7 @@ import { normalizeVercelReadStream } from "#execution/sandbox/bindings/vercel-re import { writeSandboxSeedFiles } from "#execution/sandbox/bindings/local-backend-utils.js"; import type { VercelCreateOptions, + VercelForkOptions, VercelModule, VercelSandbox, } from "#execution/sandbox/bindings/vercel-sdk-types.js"; @@ -57,10 +59,9 @@ export interface CreateVercelSandboxInput { /** * Creates the Vercel-backed sandbox backend. * - * Any author-supplied `createOptions` are forwarded to Vercel's sandbox - * create API for every fresh sandbox the framework creates (template at - * prewarm time, session at first-time session-create). On resume - * (`Sandbox.get`) no create happens, so they are not re-applied. + * Any author-supplied `createOptions` are forwarded to Vercel when the + * framework creates a template or forks a fresh session from one. On + * resume (`Sandbox.get`) they are not re-applied. */ export function createVercelSandbox( input: CreateVercelSandboxInput = {}, @@ -102,8 +103,8 @@ export function createVercelSandbox( existingMetadata: createInput.existingMetadata, sandboxModule, sessionKey: createInput.sessionKey, - snapshotId: template?.snapshotId, tags, + templateSandboxName: template?.sandboxName, }); } catch (error) { if ( @@ -163,7 +164,6 @@ export function createVercelSandbox( interface VercelSandboxTemplateRecord { readonly sandboxName: string; - readonly snapshotId: string; readonly templateKey: string; } @@ -217,7 +217,10 @@ async function readTemplate(input: { sandboxName: input.templateKey, }); - if (sandbox === null || typeof sandbox.currentSnapshotId !== "string") { + if ( + sandbox === null || + !hasFrameworkSnapshot(sandbox, extractAuthorSnapshotId(input.createOptions)) + ) { throw new SandboxTemplateNotProvisionedError({ backendName: "vercel", templateKey: input.templateKey, @@ -226,7 +229,6 @@ async function readTemplate(input: { return { sandboxName: sandbox.name, - snapshotId: sandbox.currentSnapshotId, templateKey: input.templateKey, }; } @@ -252,9 +254,9 @@ async function readTemplateForCreate(input: { /** * Creates or refreshes one named Vercel sandbox template and returns the - * resulting snapshot metadata along with whether an existing snapshot - * was reused. Internal — exposed only to the prewarm pipeline through - * the backend's `prewarm` method. + * template record along with whether an existing snapshot was reused. + * Internal — exposed only to the prewarm pipeline through the backend's + * `prewarm` method. */ async function ensureTemplate(input: EnsureTemplateInput): Promise { const sandboxModule = await input.loadSandboxModule(); @@ -295,17 +297,11 @@ async function ensureTemplate(input: EnsureTemplateInput): Promise 0 && - sandbox.currentSnapshotId !== authorSnapshotId; - - if (hasFrameworkSnapshot) { + if (hasFrameworkSnapshot(sandbox, authorSnapshotId)) { return { reused: true, template: { sandboxName: sandbox.name, - snapshotId: sandbox.currentSnapshotId as string, templateKey: input.templateKey, }, }; @@ -337,12 +333,11 @@ async function ensureTemplate(input: EnsureTemplateInput): Promise; readonly sandboxModule: VercelModule; readonly sessionKey: string; - readonly snapshotId?: string; readonly tags: Record | undefined; + readonly templateSandboxName?: string; } interface VercelSandboxSessionCreateResult { @@ -376,17 +371,20 @@ async function ensureSession(input: EnsureSessionInput): Promise>; - return { - ...sessionCreateOptions, + const forkParams: VercelForkOptions = { + ...forkOptions, name: sandboxName, persistent: true, - source: { snapshotId: input.snapshotId, type: "snapshot" as const }, + sourceSandbox: templateSandboxName, }; + if (input.tags !== undefined) { + forkParams.tags = input.tags; + } + return forkParams; } function withBaseSetupNetworkPolicy( @@ -546,6 +555,17 @@ function isUnprovisionedTerminalTemplateSandbox( ); } +function hasFrameworkSnapshot( + sandbox: VercelSandbox, + authorSnapshotId: string | undefined, +): boolean { + return ( + typeof sandbox.currentSnapshotId === "string" && + sandbox.currentSnapshotId.length > 0 && + sandbox.currentSnapshotId !== authorSnapshotId + ); +} + /** * Pulls the snapshotId out of an author-supplied `source: { type: * "snapshot", ... }`. Returns undefined for git/tarball sources or when diff --git a/packages/eve/src/public/sandbox/backends/vercel.ts b/packages/eve/src/public/sandbox/backends/vercel.ts index b797a9a52..78edba027 100644 --- a/packages/eve/src/public/sandbox/backends/vercel.ts +++ b/packages/eve/src/public/sandbox/backends/vercel.ts @@ -12,18 +12,17 @@ import type { * including for local development, where it creates real hosted * sandboxes (requires Vercel credentials). * - * The optional `opts` parameter is forwarded to Vercel Sandbox creation - * for every fresh sandbox the framework creates (template at prewarm, - * session at first-time create). On resume (`Sandbox.get`), no create - * happens, so opts are not re-applied. `networkPolicy` is applied after - * framework-owned base setup for fresh templates and template-less - * sessions, before authored bootstrap code runs. + * The optional `opts` parameter is forwarded to Vercel when eve creates + * a template or forks a fresh session from one. On resume + * (`Sandbox.get`), opts are not re-applied. `networkPolicy` is applied + * after framework-owned base setup for fresh templates and + * template-less sessions, before authored bootstrap code runs. * * `opts.source`, if supplied, is used only on the template create: * the author's snapshot, git revision, or tarball becomes the base * layer of the template. Bootstrap, seed files, and framework setup - * still run on top, and every session derives from the resulting - * eve-owned snapshot. `source` is stripped from session creates so the + * still run on top, and every session forks from the resulting + * eve-owned snapshot. `source` is stripped from session forks so the * framework's snapshot always wins. * * `bootstrap({ use })` applies its options to the template via diff --git a/packages/eve/src/public/sandbox/vercel-sandbox.ts b/packages/eve/src/public/sandbox/vercel-sandbox.ts index 999535abf..fb7e42025 100644 --- a/packages/eve/src/public/sandbox/vercel-sandbox.ts +++ b/packages/eve/src/public/sandbox/vercel-sandbox.ts @@ -14,16 +14,15 @@ type VercelSandboxAuthorCreateOptions = T extends unknown : never; /** - * Options accepted by `vercel(opts)`. Forwarded to Vercel - * Sandbox creation for every fresh sandbox the framework creates - * (template at prewarm time, session at first-time session-create). - * Skipped on resume (`Sandbox.get`) since no create happens there. + * Options accepted by `vercel(opts)`. Forwarded to Vercel when eve + * creates a template or forks a fresh session from one. Skipped on + * resume (`Sandbox.get`). * * `networkPolicy` is deferred until after framework-owned base setup * for fresh templates and template-less sessions, so eve can install * required packages before authored bootstrap code runs. Template-backed - * session creates receive it at creation time because the template - * already contains the prepared base runtime. + * session forks receive it as an override because the template already + * contains the prepared base runtime. * * Framework-injected fields (`name`, `onResume`, `persistent`, `signal`) * are excluded: the framework owns those and overrides any @@ -37,7 +36,7 @@ type VercelSandboxAuthorCreateOptions = T extends unknown * base layer for the template. Framework setup, bootstrap, and seed * files all run on top, and the resulting * framework-owned snapshot is what every later session derives from, - * so `source` is stripped from the session-create path. eve does not + * so `source` is stripped from the session-fork path. eve does not * detect external snapshot changes; to pick up a rebuilt external * snapshot, force a template rebuild (e.g. by changing the sandbox * definition so its template key changes). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53cd8500a..bfd7aa9e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,8 +40,8 @@ catalogs: specifier: 0.4.2 version: 0.4.2 '@vercel/sandbox': - specifier: 2.8.0 - version: 2.8.0 + specifier: 2.9.0 + version: 2.9.0 ai: specifier: ^7.0.38 version: 7.0.38 @@ -257,7 +257,7 @@ importers: devDependencies: '@agent-browser/eve': specifier: ^0.33.0 - version: 0.33.0(@vercel/sandbox@2.8.0)(eve@packages+eve) + version: 0.33.0(@vercel/sandbox@2.9.0)(eve@packages+eve) '@braintrust/otel': specifier: ^0.3.0 version: 0.3.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.221.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(braintrust@3.14.0(@aws-sdk/credential-provider-web-identity@3.972.49)(supports-color@10.2.2)(zod@4.4.3)) @@ -1202,7 +1202,7 @@ importers: version: 3.8.0 '@vercel/sandbox': specifier: 'catalog:' - version: 2.8.0 + version: 2.9.0 '@vercel/sdk': specifier: 1.28.8 version: 1.28.8 @@ -8031,8 +8031,8 @@ packages: '@vercel/sandbox@2.1.1': resolution: {integrity: sha512-gKhW+YlvU15Qxya7jQKByB+sqA1dWat5zx/rvxT52E3Ryg9MAIXgqD5wd1d+CoJDbdHL26gIOcksTZY5sFpplA==} - '@vercel/sandbox@2.8.0': - resolution: {integrity: sha512-1gmIXWgueeBBwBitVl1xqOsYmVv7xr7qX1qXeqwLGZTKGG1SkkpYkDzpWZ8LIDN0PmPGCqE6lvYao3zP8iz5uw==} + '@vercel/sandbox@2.9.0': + resolution: {integrity: sha512-AMnzmOUG765PJnNyrj12Ed4XD+M2ns8C+o9UTBERuZeUQxbRo2izJTKPezaeDCl+KEpyw+JXepRCKIxSsylsMQ==} '@vercel/sdk@1.28.8': resolution: {integrity: sha512-uAl+pEZZnFYRFzE/43ihP3aYTJ+4v0+coiMYobYMqtaERWknYKuRZWEj9Dk9r87yWXfxclsQugXkN42kaOcySw==} @@ -14945,17 +14945,17 @@ packages: snapshots: - '@agent-browser/eve@0.33.0(@vercel/sandbox@2.8.0)(eve@packages+eve)': + '@agent-browser/eve@0.33.0(@vercel/sandbox@2.9.0)(eve@packages+eve)': dependencies: - '@agent-browser/sandbox': 0.33.0(@vercel/sandbox@2.8.0) + '@agent-browser/sandbox': 0.33.0(@vercel/sandbox@2.9.0) eve: link:packages/eve zod: 4.4.3 transitivePeerDependencies: - '@vercel/sandbox' - '@agent-browser/sandbox@0.33.0(@vercel/sandbox@2.8.0)': + '@agent-browser/sandbox@0.33.0(@vercel/sandbox@2.9.0)': optionalDependencies: - '@vercel/sandbox': 2.8.0 + '@vercel/sandbox': 2.9.0 '@ai-sdk/amazon-bedrock@3.0.110(zod@4.4.3)': dependencies: @@ -21759,7 +21759,7 @@ snapshots: - bare-abort-controller - react-native-b4a - '@vercel/sandbox@2.8.0': + '@vercel/sandbox@2.9.0': dependencies: '@vercel/oidc': 3.2.0 '@workflow/serde': 4.1.0-beta.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0b9b5e22d..552634a99 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -34,7 +34,7 @@ catalog: "@types/react": "19.2.15" "@types/react-dom": "19.2.3" "@vercel/connect": "0.4.2" - "@vercel/sandbox": "2.8.0" + "@vercel/sandbox": "2.9.0" ai: "^7.0.38" marked: "17.0.6" next: "16.3.0-preview.6"