Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/swift-drives-mount.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": minor
---

Expose Vercel Sandbox Drives and allow authors to mount them when creating live session sandboxes.
1 change: 1 addition & 0 deletions packages/eve/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@
"@vercel/oidc": "3.8.0",
"@vercel/otel": "catalog:",
"@vercel/sandbox": "catalog:",
"@vercel/sandbox-drives": "catalog:",
"@vercel/sdk": "1.28.8",
"@workflow/core": "5.0.0-beta.41",
"@workflow/errors": "5.0.0-beta.16",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import vercelSandbox from "./sandbox.mjs";

/** Preserve extension contract declarations while Drives uses the beta runtime. */
export default {
...vercelSandbox,
packageName: "@vercel/sandbox",
compiledPath: "@vercel/sandbox-stable",
typeOnly: true,
};
3 changes: 2 additions & 1 deletion packages/eve/scripts/vendor-compiled/@vercel/sandbox.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ async function discoverDeclarationFiles({ distDir }) {
* or local stubs, while Node built-ins remain external.
*/
export default {
packageName: "@vercel/sandbox",
packageName: "@vercel/sandbox-drives",
packageJsonName: "@vercel/sandbox",
compiledPath: "@vercel/sandbox",
plugins: [createOptionalNativeStubPlugin(["fsevents"])],
copyDeclarations: createDeclarationCopier({
Expand Down
19 changes: 14 additions & 5 deletions packages/eve/scripts/vendor-compiled/_shared.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* ```
* {
* packageName: string, // npm package to resolve via require
* packageJsonName?: string, // original npm name when packageName is an alias
* compiledPath: string, // subdir under compiledRoot to write into
*
* // Declaration emission (pick one)
Expand Down Expand Up @@ -513,7 +514,7 @@ async function pathExists(path) {
}
}

async function findPackageJson(packageName, packageRoot) {
async function findPackageJson(packageName, packageRoot, packageJsonName = packageName) {
let currentPath;
try {
currentPath = dirname(require.resolve(packageName, { paths: [packageRoot] }));
Expand All @@ -527,7 +528,7 @@ async function findPackageJson(packageName, packageRoot) {

if (await pathExists(packageJsonPath)) {
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
if (packageJson.name === packageName) {
if (packageJson.name === packageJsonName) {
return {
packageJson,
packageJsonPath,
Expand All @@ -542,7 +543,7 @@ async function findPackageJson(packageName, packageRoot) {
const packageJsonPath = join(currentPath, "package.json");
if (await pathExists(packageJsonPath)) {
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
if (packageJson.name === packageName) {
if (packageJson.name === packageJsonName) {
return {
packageJson,
packageJsonPath,
Expand All @@ -567,7 +568,11 @@ async function copyLicense(sourceRoot, destinationRoot) {

async function prepareCompiledModule({ module, compiledRoot, packageRoot }) {
const destinationRoot = join(compiledRoot, module.compiledPath);
const packageInfo = await findPackageJson(module.packageName, packageRoot);
const packageInfo = await findPackageJson(
module.packageName,
packageRoot,
module.packageJsonName,
);

await rm(destinationRoot, { recursive: true, force: true });
await mkdir(destinationRoot, { recursive: true });
Expand Down Expand Up @@ -809,7 +814,11 @@ async function computeStamp({ scriptFiles, modules, packageRoot }) {

const moduleVersions = {};
for (const module of modules) {
const { packageJson } = await findPackageJson(module.packageName, packageRoot);
const { packageJson } = await findPackageJson(
module.packageName,
packageRoot,
module.packageJsonName,
);
moduleVersions[module.packageName] = packageJson.version ?? "0.0.0";
}

Expand Down
2 changes: 2 additions & 0 deletions packages/eve/scripts/vendor-compiled/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import vercelDetectAgent from "./@vercel/detect-agent.mjs";
import vercelOidc from "./@vercel/oidc.mjs";
import vercelOtel from "./@vercel/otel.mjs";
import vercelSandbox from "./@vercel/sandbox.mjs";
import vercelSandboxStable from "./@vercel/sandbox-stable.mjs";
import workflowCore from "./@workflow/core.mjs";
import workflowErrors from "./@workflow/errors.mjs";
import workflowSerde from "./@workflow/serde.mjs";
Expand Down Expand Up @@ -84,6 +85,7 @@ export const MODULES = [
vercelOidc,
vercelOtel,
vercelSandbox,
vercelSandboxStable,
workflowCore,
workflowErrors,
workflowSerde,
Expand Down
57 changes: 57 additions & 0 deletions packages/eve/src/execution/sandbox/bindings/vercel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,63 @@ describe("createVercelSandbox", () => {
expect(templateSandbox.update).toHaveBeenCalledWith({ networkPolicy: "deny-all" });
});

it("resolves mounts only for a fresh live session sandbox", async () => {
const templateSandbox = createMockSandbox({ name: "template" });
const sessionSandbox = createMockSandbox({ name: "session" });
const create = vi
.fn()
.mockResolvedValueOnce(templateSandbox)
.mockResolvedValueOnce(sessionSandbox);
const resolveSessionCreateOptions = vi.fn(({ session }) => ({
mounts: { "/workspace/repos": { drive: `e0-${session.id}` } },
}));
const backend = createTestVercelSandbox({
loadSandboxModule: async () =>
({ Sandbox: { create, get: vi.fn().mockResolvedValue(null) } }) as never,
resolveSessionCreateOptions,
});

await backend.prewarm({
runtimeContext: { appRoot: "/tmp/test-app-root" },
seedFiles: [],
templateKey: "template-key",
});
await backend.create({
runtimeContext: { appRoot: "/tmp/test-app-root" },
sessionKey: "session-key",
tags: { sessionId: "parent-session" },
templateKey: "template-key",
});

expect(resolveSessionCreateOptions).toHaveBeenCalledWith({
session: { id: "parent-session" },
});
expect(create.mock.calls[0]?.[0]).not.toHaveProperty("mounts");
expect(create.mock.calls[1]?.[0]).toMatchObject({
mounts: { "/workspace/repos": { drive: "e0-parent-session" } },
});
});

it("does not resolve session create options when resuming a sandbox", async () => {
const existing = createMockSandbox({ name: "session-key" });
const create = vi.fn();
const resolveSessionCreateOptions = vi.fn();
const backend = createTestVercelSandbox({
loadSandboxModule: async () =>
({ Sandbox: { create, get: vi.fn().mockResolvedValue(existing) } }) as never,
resolveSessionCreateOptions,
});

await backend.create({
runtimeContext: { appRoot: "/tmp/test-app-root" },
sessionKey: "session-key",
templateKey: null,
});

expect(resolveSessionCreateOptions).not.toHaveBeenCalled();
expect(create).not.toHaveBeenCalled();
});

it("forwards author source to template create as the base layer", async () => {
/*
* The real Vercel SDK pre-populates `currentSnapshotId` on a
Expand Down
24 changes: 19 additions & 5 deletions packages/eve/src/execution/sandbox/bindings/vercel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import type {
import { SandboxTemplateNotProvisionedError } from "#public/definitions/sandbox-backend.js";
import type {
VercelSandboxBootstrapUseOptions,
VercelSandboxSessionCreateContext,
VercelSandboxSessionCreateOptions,
VercelSandboxSessionUseOptions,
} from "#public/sandbox/vercel-sandbox.js";
import { WORKSPACE_ROOT } from "#runtime/workspace/types.js";
Expand Down Expand Up @@ -54,6 +56,9 @@ export interface CreateVercelSandboxInput {
readonly createSandbox?: CreateVercelSandbox;
readonly createOptions?: VercelCreateOptions;
readonly loadSandboxModule?: () => Promise<VercelModule>;
readonly resolveSessionCreateOptions?: (
context: VercelSandboxSessionCreateContext,
) => Promise<VercelSandboxSessionCreateOptions> | VercelSandboxSessionCreateOptions;
}
/**
* Creates the Vercel-backed sandbox backend.
Expand Down Expand Up @@ -101,7 +106,9 @@ export function createVercelSandbox(
createOptions,
createSandbox,
existingMetadata: createInput.existingMetadata,
resolveSessionCreateOptions: input.resolveSessionCreateOptions,
sandboxModule,
sessionId: createInput.tags?.sessionId ?? createInput.sessionKey,
sessionKey: createInput.sessionKey,
snapshotId: template?.snapshotId,
tags,
Expand Down Expand Up @@ -357,7 +364,9 @@ interface EnsureSessionInput {
readonly createOptions: VercelCreateOptions;
readonly createSandbox: CreateVercelSandbox;
readonly existingMetadata?: Record<string, unknown>;
readonly resolveSessionCreateOptions?: CreateVercelSandboxInput["resolveSessionCreateOptions"];
readonly sandboxModule: VercelModule;
readonly sessionId: string;
readonly sessionKey: string;
readonly snapshotId?: string;
readonly tags: Record<string, string> | undefined;
Expand All @@ -381,7 +390,10 @@ async function ensureSession(input: EnsureSessionInput): Promise<VercelSandboxSe
return { created: false, sandbox: existing };
}

const createParams = createSessionCreateParams(input, sandboxName);
const sessionCreateOptions = await input.resolveSessionCreateOptions?.({
session: { id: input.sessionId },
});
const createParams = createSessionCreateParams(input, sandboxName, sessionCreateOptions);
if (input.tags !== undefined) {
createParams.tags = input.tags;
}
Expand All @@ -398,10 +410,12 @@ async function ensureSession(input: EnsureSessionInput): Promise<VercelSandboxSe
function createSessionCreateParams(
input: EnsureSessionInput,
sandboxName: string,
sessionCreateOptions: VercelSandboxSessionCreateOptions = {},
): VercelSandboxCreateParams {
const createOptions = { ...input.createOptions, ...sessionCreateOptions } as VercelCreateOptions;
if (input.snapshotId === undefined) {
return withBaseSetupNetworkPolicy({
...input.createOptions,
...createOptions,
name: sandboxName,
persistent: true,
});
Expand All @@ -417,12 +431,12 @@ function createSessionCreateParams(
image: _image,
runtime: _runtime,
source: _source,
...sessionCreateOptions
} = input.createOptions as VercelCreateOptions &
...baseSessionCreateOptions
} = createOptions as VercelCreateOptions &
Partial<Record<"image" | "runtime" | "source", unknown>>;

return {
...sessionCreateOptions,
...baseSessionCreateOptions,
name: sandboxName,
persistent: true,
source: { snapshotId: input.snapshotId, type: "snapshot" as const },
Expand Down
15 changes: 10 additions & 5 deletions packages/eve/src/public/sandbox/backends/vercel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createVercelSandbox } from "#execution/sandbox/bindings/vercel.js";
import type { SandboxBackend } from "#public/definitions/sandbox-backend.js";
import type {
VercelSandboxBootstrapUseOptions,
VercelSandboxCreateOptions,
VercelSandboxOptions,
VercelSandboxSessionUseOptions,
} from "#public/sandbox/vercel-sandbox.js";

Expand All @@ -12,7 +12,7 @@ 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
* The optional provider creation fields are 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
Expand All @@ -30,10 +30,15 @@ import type {
* `sandbox.update(...)`; those settings persist into the snapshot.
* `onSession({ use })` applies its options to the live session via the
* SDK's `update` under the hood, overriding any overlapping field
* from `opts`.
* from `opts`. `sessionCreateOptions`, when provided, is resolved only
* when creating a fresh live session and can attach session-specific Drives.
*/
export function vercel(
opts?: VercelSandboxCreateOptions,
opts?: VercelSandboxOptions,
): SandboxBackend<VercelSandboxBootstrapUseOptions, VercelSandboxSessionUseOptions> {
return createVercelSandbox({ createOptions: opts });
const { sessionCreateOptions, ...createOptions } = opts ?? {};
return createVercelSandbox({
createOptions,
resolveSessionCreateOptions: sessionCreateOptions,
});
}
32 changes: 31 additions & 1 deletion packages/eve/src/public/sandbox/vercel-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ type VercelSandboxInternalCreateOptions = {
};

type VercelSandboxAuthorCreateOptions<T> = T extends unknown
? Omit<T, "name" | "onResume" | "persistent" | "runtime" | "signal"> &
? Omit<T, "mounts" | "name" | "onResume" | "persistent" | "runtime" | "signal"> &
VercelSandboxInternalCreateOptions
: never;

Expand Down Expand Up @@ -44,6 +44,36 @@ type VercelSandboxAuthorCreateOptions<T> = T extends unknown
*/
export type VercelSandboxCreateOptions = VercelSandboxAuthorCreateOptions<VercelCreateOptions>;

/** Access mode for a Drive mounted into a Vercel Sandbox. */
export type VercelSandboxMountMode = Vercel.SandboxMountMode;

/** A Drive mounted at one absolute path in a Vercel Sandbox. */
export type VercelSandboxMount = Vercel.SandboxMounts[string];

/** Drive mounts keyed by absolute sandbox path. */
export type VercelSandboxMounts = Vercel.SandboxMounts;

/** Options resolved when eve creates a fresh live session sandbox. */
export interface VercelSandboxSessionCreateOptions {
readonly mounts?: VercelSandboxMounts;
}

/** Context available while resolving fresh live-session creation options. */
export interface VercelSandboxSessionCreateContext {
readonly session: { readonly id: string };
}

/** Options accepted by `vercel(opts)`. */
export type VercelSandboxOptions = VercelSandboxCreateOptions & {
/**
* Resolves options that apply only to fresh live sessions. It is not called
* while prewarming templates or resuming an existing sandbox.
*/
readonly sessionCreateOptions?: (
context: VercelSandboxSessionCreateContext,
) => Promise<VercelSandboxSessionCreateOptions> | VercelSandboxSessionCreateOptions;
};

/**
* Options accepted by the Vercel backend's `bootstrap({ use })` hook.
* Tracks the Vercel SDK's `Sandbox.update(...)` parameter because bootstrap
Expand Down
27 changes: 27 additions & 0 deletions packages/eve/src/public/sandbox/vercel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, expectTypeOf, it } from "vitest";

import {
Drive,
vercel,
type VercelSandboxOptions,
type VercelSandboxSessionCreateOptions,
} from "#public/sandbox/vercel.js";

describe("vercel", () => {
it("exposes Drive and accepts session-scoped mounts", () => {
const options = {
sessionCreateOptions: ({ session }) => ({
mounts: {
"/workspace": { drive: `repo-${session.id}`, mode: "read-write" },
},
}),
} satisfies VercelSandboxOptions;

expect(vercel(options).name).toBe("vercel");
expectTypeOf(Drive.getOrCreate).toBeFunction();
expectTypeOf(
options.sessionCreateOptions({ session: { id: "acme" } }),
).toMatchTypeOf<VercelSandboxSessionCreateOptions>();
expectTypeOf<VercelSandboxOptions>().not.toHaveProperty("mounts");
});
});
7 changes: 7 additions & 0 deletions packages/eve/src/public/sandbox/vercel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
export { vercel } from "#public/sandbox/backends/vercel.js";
export { Drive } from "#compiled/@vercel/sandbox/index.js";
export type {
VercelSandboxBootstrapUseOptions,
VercelSandboxCreateOptions,
VercelSandboxMount,
VercelSandboxMountMode,
VercelSandboxMounts,
VercelSandboxOptions,
VercelSandboxSessionCreateContext,
VercelSandboxSessionCreateOptions,
VercelSandboxSessionUseOptions,
} from "#public/sandbox/vercel-sandbox.js";
2 changes: 1 addition & 1 deletion packages/eve/src/shared/sandbox-network-policy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type * as Vercel from "#compiled/@vercel/sandbox/index.js";
import type * as Vercel from "#compiled/@vercel/sandbox-stable/index.js";

/**
* Firewall network policy applied to a live sandbox session.
Expand Down
Loading
Loading