diff --git a/.changeset/destroy-sandbox.md b/.changeset/destroy-sandbox.md new file mode 100644 index 0000000000..6b1dce8d4a --- /dev/null +++ b/.changeset/destroy-sandbox.md @@ -0,0 +1,5 @@ +--- +"eve": minor +--- + +Add `ctx.sandbox.destroy()` for permanently deleting the current session sandbox and reprovisioning it on the next access. diff --git a/docs/guides/session-context.md b/docs/guides/session-context.md index d39fc94e9c..4a3c2635d4 100644 --- a/docs/guides/session-context.md +++ b/docs/guides/session-context.md @@ -9,6 +9,7 @@ eve passes a runtime `ctx` to tool executors, hook handlers, channel event handl | ---------------------------- | --------------------------------------------------------- | ----------------------------------------------- | | `ctx.session` | Session identity, turn metadata, auth, and parent lineage | This page | | `ctx.getSandbox()` | The current agent's live sandbox handle | [Sandbox](../sandbox) | +| `ctx.sandbox.destroy()` | Permanent deletion of the current session sandbox | [Sandbox](../sandbox#destroy-a-sandbox) | | `ctx.getSkill(identifier)` | A handle for a skill visible to the current agent | [Skills](../skills#read-skill-files-at-runtime) | | `defineState(name, initial)` | Durable typed state shared by runtime code in one session | [State](../concepts/state) | @@ -61,6 +62,16 @@ const result = await sandbox.run({ command: "npm test" }); The accessor is asynchronous because eve may need to bind or restore the sandbox. A subagent sees its own sandbox, not its parent's. The returned runtime handle can also stop compute while preserving the durable sandbox state. See [Sandbox](../sandbox#using-the-sandbox) for the I/O API and lifecycle. +## `ctx.sandbox.destroy()` + +Call `ctx.sandbox.destroy()` to permanently remove the current session sandbox: + +```ts +await ctx.sandbox.destroy(); +``` + +eve stops compute, deletes the session's physical sandbox and persisted state, then clears reconnect state. The next sandbox access provisions a fresh workspace and runs `onSession` again. Reusable template state remains available. Only the owning session can destroy a shared sandbox. See [Destroy a sandbox](../sandbox#destroy-a-sandbox) for backend behavior and shared sandbox ownership. + ## `ctx.getSkill(identifier)` Call `ctx.getSkill(identifier)` to read a packaged skill's supporting files: diff --git a/docs/sandbox.mdx b/docs/sandbox.mdx index 91a819633b..86315cec21 100644 --- a/docs/sandbox.mdx +++ b/docs/sandbox.mdx @@ -228,6 +228,26 @@ stop-specific state is needed. Lifecycle `use()` calls return the I/O-only `SandboxSession` because bootstrap and session initialization do not own runtime teardown. +### Destroy a sandbox + +Permanently destroy the current session sandbox from an authored runtime callback: + +```ts +await ctx.sandbox.destroy(); +``` + +eve stops compute first, deletes the physical sandbox and every snapshot owned by that session sandbox, and clears the saved reconnect state. It preserves the reusable template snapshot that contains `bootstrap` and seeded workspace files. + +The durable eve session remains active. The next call to `ctx.getSandbox()` provisions a fresh workspace from the current sandbox definition and runs `onSession` again. Files and other workspace changes from the deleted sandbox are not restored. + +Backend behavior differs: + +- **Vercel Sandbox**: stops the persistent sandbox, deletes every created session snapshot, then deletes the sandbox record +- **microsandbox**: stops and removes the session VM and its persisted state snapshot +- **Docker and just-bash**: stop compute and discard their session runtime state + +Only the session that owns a shared sandbox can destroy it. Destroying the owner's sandbox affects every parent or child currently using it. Provider failures reject the call and preserve eve's current reconnect state so you can retry. + Session sandboxes are keyed per durable session, not per deployment, so redeploying your app does not by itself discard them. A definition change to the authored sandbox source, workspace seed content, or `revalidationKey` replaces the sandbox on the next turn and runs `onSession` again. Reattachment still depends on the backend retaining its physical sandbox state. If a persisted Vercel sandbox is no longer available, eve creates a replacement, using the current template when one is configured. Files and other changes made after the original sandbox was created are not restored automatically. Because the durable session still has the same sandbox key, `onSession` does not run again for this replacement. Persist important artifacts outside the sandbox, and do not rely on `onSession` as the only place that applies security-critical configuration. diff --git a/packages/eve/extension-contracts/compatibility/channel/v7.ts b/packages/eve/extension-contracts/compatibility/channel/v7.ts new file mode 100644 index 0000000000..b4c7a5fd2c --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/channel/v7.ts @@ -0,0 +1,17 @@ +import { defineChannel, POST } from "#public/channels/index.js"; + +export default defineChannel({ + state: { threadId: null as string | null }, + metadata(state) { + return { threadId: state.threadId }; + }, + routes: [ + POST("/input", async (_request, { from }) => { + await from("thread-1").respond([{ optionId: "approve", requestId: "approval-1" }], { + auth: null, + }); + return new Response("ok"); + }), + ], + turnPolicy: "queue", +}); diff --git a/packages/eve/extension-contracts/compatibility/connection/v5.ts b/packages/eve/extension-contracts/compatibility/connection/v5.ts new file mode 100644 index 0000000000..def585931a --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/connection/v5.ts @@ -0,0 +1,11 @@ +import { defineMcpClientConnection } from "#public/connections/index.js"; + +export default defineMcpClientConnection({ + description: "Tenant-aware MCP service", + toolCall: { + providedArguments: { + tenantId: ({ session, toolName }) => `${session.id}:${toolName}`, + }, + }, + url: "https://example.com/mcp", +}); diff --git a/packages/eve/extension-contracts/compatibility/dynamicTool/v18.ts b/packages/eve/extension-contracts/compatibility/dynamicTool/v18.ts new file mode 100644 index 0000000000..76b54cd925 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/dynamicTool/v18.ts @@ -0,0 +1,14 @@ +import { z as z3 } from "zod/v3"; + +import { defineDynamic, defineTool } from "#public/tools/index.js"; + +export default defineDynamic({ + events: { + "session.started": (_event, ctx) => + defineTool({ + description: "Return the active session identifier.", + inputSchema: z3.object({ prefix: z3.string() }), + execute: ({ prefix }) => ({ sessionId: `${prefix}:${ctx.session.id}` }), + }), + }, +}); diff --git a/packages/eve/extension-contracts/compatibility/hook/v14.ts b/packages/eve/extension-contracts/compatibility/hook/v14.ts new file mode 100644 index 0000000000..38ecc7e065 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/hook/v14.ts @@ -0,0 +1,13 @@ +import { defineHook } from "#public/hooks/index.js"; + +export default defineHook({ + events: { + "subagent.completed"(event, ctx) { + console.info("subagent completed", { + output: event.data.output, + sessionId: ctx.session.id, + subagentName: event.data.subagentName, + }); + }, + }, +}); diff --git a/packages/eve/extension-contracts/compatibility/state/v3.ts b/packages/eve/extension-contracts/compatibility/state/v3.ts new file mode 100644 index 0000000000..ce89177800 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/state/v3.ts @@ -0,0 +1,10 @@ +import { defineState } from "#public/context/index.js"; + +export const budget = defineState("compatibility.budget", () => ({ + count: 0, + limit: 10, +})); + +export function recordUsage(): void { + budget.update((current) => ({ ...current, count: current.count + 1 })); +} diff --git a/packages/eve/extension-contracts/compatibility/tool/v17.ts b/packages/eve/extension-contracts/compatibility/tool/v17.ts new file mode 100644 index 0000000000..45936c09b9 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/tool/v17.ts @@ -0,0 +1,9 @@ +import { z as z3 } from "zod/v3"; + +import { defineTool } from "#public/tools/index.js"; + +export default defineTool({ + description: "Return the active session identifier.", + inputSchema: z3.object({ prefix: z3.string() }), + execute: ({ prefix }, ctx) => ({ sessionId: `${prefix}:${ctx.session.id}` }), +}); diff --git a/packages/eve/extension-contracts/reports/channel/v8.json b/packages/eve/extension-contracts/reports/channel/v8.json new file mode 100644 index 0000000000..c555f5d173 --- /dev/null +++ b/packages/eve/extension-contracts/reports/channel/v8.json @@ -0,0 +1,17 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "channel", + "epoch": 8, + "sha256": "9f43be2f28964ba62389c450917e1563f5733708d8cfc08cd259684ddd3df356", + "exports": [ + "DELETE", + "GET", + "PATCH", + "POST", + "PUT", + "WS", + "createWebSocketUpgradeServer", + "defineChannel", + "isChannel" + ] +} diff --git a/packages/eve/extension-contracts/reports/connection/v6.json b/packages/eve/extension-contracts/reports/connection/v6.json new file mode 100644 index 0000000000..30e46b812b --- /dev/null +++ b/packages/eve/extension-contracts/reports/connection/v6.json @@ -0,0 +1,15 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "connection", + "epoch": 6, + "sha256": "44e8dfc8afb66629ec2b6d8118a32f0c85a1fa907be2fe402b1605a12a214d48", + "exports": [ + "ConnectionAuthorizationFailedError", + "ConnectionAuthorizationRequiredError", + "defineInteractiveAuthorization", + "defineMcpClientConnection", + "defineOpenAPIConnection", + "isConnectionAuthorizationFailedError", + "isConnectionAuthorizationRequiredError" + ] +} diff --git a/packages/eve/extension-contracts/reports/dynamicTool/v19.json b/packages/eve/extension-contracts/reports/dynamicTool/v19.json new file mode 100644 index 0000000000..4f19876bc2 --- /dev/null +++ b/packages/eve/extension-contracts/reports/dynamicTool/v19.json @@ -0,0 +1,13 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "dynamicTool", + "epoch": 19, + "sha256": "d68548fdb9e99e591e9d4e81308d5f551e4a46dbf508bc1645c2409cf015dba7", + "exports": [ + "DynamicToolEntry", + "DynamicToolEvents", + "DynamicToolResult", + "DynamicToolSet", + "defineDynamic" + ] +} diff --git a/packages/eve/extension-contracts/reports/hook/v15.json b/packages/eve/extension-contracts/reports/hook/v15.json new file mode 100644 index 0000000000..79459dd8d9 --- /dev/null +++ b/packages/eve/extension-contracts/reports/hook/v15.json @@ -0,0 +1,7 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "hook", + "epoch": 15, + "sha256": "35b28df922e0f6c57c5a4278ff9538dfac245bff80e32d0e73c3aa1d3f4fc522", + "exports": ["defineHook"] +} diff --git a/packages/eve/extension-contracts/reports/state/v4.json b/packages/eve/extension-contracts/reports/state/v4.json new file mode 100644 index 0000000000..dbdcc79997 --- /dev/null +++ b/packages/eve/extension-contracts/reports/state/v4.json @@ -0,0 +1,15 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "state", + "epoch": 4, + "sha256": "1e0b68d9e907f8bd54c06ca780c48968f8231f225703b4a68b09a89be68edd26", + "exports": [ + "Session", + "SessionAuth", + "SessionAuthContext", + "SessionContext", + "SessionParent", + "SessionTurn", + "defineState" + ] +} diff --git a/packages/eve/extension-contracts/reports/tool/v18.json b/packages/eve/extension-contracts/reports/tool/v18.json new file mode 100644 index 0000000000..64f6e9df1e --- /dev/null +++ b/packages/eve/extension-contracts/reports/tool/v18.json @@ -0,0 +1,22 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "tool", + "epoch": 18, + "sha256": "927b23a1f7f1176526f333fbfaf9af468f2d2ab8eb00d0b22b9395dcf02a83a1", + "exports": [ + "defineBashTool", + "defineGlobTool", + "defineGrepTool", + "defineReadFileTool", + "defineTool", + "defineWriteFileTool", + "disableTool", + "experimental_workflow", + "isDisabledToolSentinel", + "isExperimentalWorkflowToolDefinition", + "toolOutput", + "toolOutputPart", + "toolResultFrom", + "webSearch" + ] +} diff --git a/packages/eve/src/compiler/extension-compatibility.ts b/packages/eve/src/compiler/extension-compatibility.ts index 29c5e0554a..29bad7a589 100644 --- a/packages/eve/src/compiler/extension-compatibility.ts +++ b/packages/eve/src/compiler/extension-compatibility.ts @@ -22,22 +22,22 @@ interface ExtensionCapabilityContract { const EXTENSION_CAPABILITY_CONTRACTS = { extension: { current: 1, supported: [1], dropped: {} }, tool: { - current: 17, - supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17], + current: 18, + supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18], dropped: { 15: "TaskExec replaces stageEffect with send" }, }, dynamicTool: { - current: 18, - supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18], + current: 19, + supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], dropped: {}, }, - channel: { current: 7, supported: [1, 2, 3, 4, 5, 6, 7], dropped: {} }, + channel: { current: 8, supported: [1, 2, 3, 4, 5, 6, 7, 8], dropped: {} }, schedule: { current: 3, supported: [1, 2, 3], dropped: {} }, subagent: { current: 2, supported: [1, 2], dropped: {} }, - connection: { current: 5, supported: [1, 2, 3, 4, 5], dropped: {} }, + connection: { current: 6, supported: [1, 2, 3, 4, 5, 6], dropped: {} }, hook: { - current: 14, - supported: [10, 11, 12, 13, 14], + current: 15, + supported: [10, 11, 12, 13, 14, 15], dropped: { 1: "Model identity moved from session.started runtime metadata to step.started call attribution.", 2: "Model identity moved from session.started runtime metadata to step.started call attribution.", @@ -59,7 +59,7 @@ const EXTENSION_CAPABILITY_CONTRACTS = { dropped: {}, }, config: { current: 1, supported: [1], dropped: {} }, - state: { current: 3, supported: [1, 2, 3], dropped: {} }, + state: { current: 4, supported: [1, 2, 3, 4], dropped: {} }, } as const satisfies Record; /** One independently versioned extension-facing contract. */ diff --git a/packages/eve/src/context/accessors.integration.test.ts b/packages/eve/src/context/accessors.integration.test.ts index 518a64f8d8..1dcfca91c4 100644 --- a/packages/eve/src/context/accessors.integration.test.ts +++ b/packages/eve/src/context/accessors.integration.test.ts @@ -139,6 +139,30 @@ describe("buildCallbackContext – getSandbox", () => { expect(stops).toBe(1); }); + + it("destroys the active sandbox through the callback lifecycle facade", async () => { + let destructions = 0; + const sandbox = mockSandbox({ + destroy: () => { + destructions += 1; + }, + }); + const runtime = createTestRuntime(); + + await runtime.runAsSession({ sandbox }, async () => { + await buildCallbackContext().sandbox.destroy(); + }); + + expect(destructions).toBe(1); + }); + + it("rejects destruction when sandbox access is unavailable", async () => { + const runtime = createTestRuntime(); + + await expect( + runtime.runAsSession({}, async () => await buildCallbackContext().sandbox.destroy()), + ).rejects.toThrow("Call ctx.sandbox.destroy() only from authored runtime functions"); + }); }); describe("buildCallbackContext – getSkill", () => { diff --git a/packages/eve/src/context/build-callback-context.ts b/packages/eve/src/context/build-callback-context.ts index 463e5ca4a0..02ed751c91 100644 --- a/packages/eve/src/context/build-callback-context.ts +++ b/packages/eve/src/context/build-callback-context.ts @@ -23,6 +23,22 @@ export function buildCallbackContext(): SessionContext { parent: session.parent, }, + sandbox: { + async destroy(options) { + const access = ctx.get(SandboxKey); + if (access === undefined) { + throw new Error( + "eve sandbox runtime access is unavailable in the current async context. " + + "Call ctx.sandbox.destroy() only from authored runtime functions such as tools, hooks, and channel events.", + ); + } + if (access.destroy === undefined) { + throw new Error("The active sandbox runtime does not support destruction."); + } + await access.destroy(options); + }, + }, + getSandbox(): Promise { const access = ctx.get(SandboxKey); if (access === undefined) { diff --git a/packages/eve/src/context/dynamic-tool-lifecycle.test.ts b/packages/eve/src/context/dynamic-tool-lifecycle.test.ts index c8cbac0a9d..6097c156d5 100644 --- a/packages/eve/src/context/dynamic-tool-lifecycle.test.ts +++ b/packages/eve/src/context/dynamic-tool-lifecycle.test.ts @@ -335,6 +335,7 @@ function createApprovalContext(input: { callId: "call_1", getSandbox: vi.fn(), getSkill: vi.fn(), + sandbox: { destroy: vi.fn() }, session: { auth: { current: null, initiator: null }, id: "test-session", diff --git a/packages/eve/src/context/providers/sandbox.test.ts b/packages/eve/src/context/providers/sandbox.test.ts index e1a77bcd07..e5449e90e6 100644 --- a/packages/eve/src/context/providers/sandbox.test.ts +++ b/packages/eve/src/context/providers/sandbox.test.ts @@ -80,6 +80,7 @@ describe("sandboxProvider", () => { expect(ensureSandboxAccess).toHaveBeenCalledWith( expect.objectContaining({ + ownsSandbox: false, sessionId: "root-sandbox-session", state: parentSandboxState, }), @@ -98,6 +99,7 @@ describe("sandboxProvider", () => { expect(ensureSandboxAccess).toHaveBeenCalledWith( expect.objectContaining({ + ownsSandbox: true, tags: { agent: "weather-agent", channel: "slack", diff --git a/packages/eve/src/context/providers/sandbox.ts b/packages/eve/src/context/providers/sandbox.ts index 22ab893664..c1f7ad81bf 100644 --- a/packages/eve/src/context/providers/sandbox.ts +++ b/packages/eve/src/context/providers/sandbox.ts @@ -34,6 +34,7 @@ export const sandboxProvider: FrameworkContextProvider = { value: await ensureSandboxAccess({ compiledArtifactsSource: bundle.compiledArtifactsSource, nodeId: node.nodeId, + ownsSandbox: !sharesSandbox, registry, runOnSession: async (callback) => await contextStorage.run(ctx, callback), sessionId: sandboxSessionId, diff --git a/packages/eve/src/execution/dispatch-runtime-actions-step.integration.test.ts b/packages/eve/src/execution/dispatch-runtime-actions-step.integration.test.ts index aba14dc282..d1dcdd88ac 100644 --- a/packages/eve/src/execution/dispatch-runtime-actions-step.integration.test.ts +++ b/packages/eve/src/execution/dispatch-runtime-actions-step.integration.test.ts @@ -1315,6 +1315,7 @@ function createSandboxBackend() { metadata: {}, sessionKey: input.sessionKey, }), + destroy: async () => {}, session: sandbox.session, shutdown: async () => {}, stop: async () => {}, diff --git a/packages/eve/src/execution/sandbox/bindings/docker.test.ts b/packages/eve/src/execution/sandbox/bindings/docker.test.ts new file mode 100644 index 0000000000..c3a165a2c4 --- /dev/null +++ b/packages/eve/src/execution/sandbox/bindings/docker.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createDockerSandboxBackend } from "#execution/sandbox/bindings/docker.js"; +import type { DockerCli } from "#execution/sandbox/bindings/docker-cli.js"; + +describe("Docker sandbox destruction", () => { + it("stops and removes the session container", async () => { + const run: DockerCli["run"] = vi.fn(async (args: readonly string[]) => ({ + exitCode: 0, + stderr: "", + stdout: args[0] === "container" ? "true\n" : "", + stdoutBytes: Buffer.alloc(0), + })); + const dockerCli: DockerCli = { + run, + stream() { + throw new Error("stream is not used by this test"); + }, + }; + const backend = createDockerSandboxBackend({ dockerCli }); + const handle = await backend.create({ + runtimeContext: { appRoot: "/tmp/eve-app" }, + sessionKey: "session-key", + templateKey: null, + }); + vi.mocked(run).mockClear(); + + await handle.destroy(); + + expect(run).toHaveBeenNthCalledWith(1, ["stop", "-t", "0", "session-key"]); + expect(run).toHaveBeenNthCalledWith(2, ["rm", "-f", "session-key"]); + }); +}); diff --git a/packages/eve/src/execution/sandbox/bindings/docker.ts b/packages/eve/src/execution/sandbox/bindings/docker.ts index d763595048..1b2add41a5 100644 --- a/packages/eve/src/execution/sandbox/bindings/docker.ts +++ b/packages/eve/src/execution/sandbox/bindings/docker.ts @@ -276,6 +276,13 @@ export function createDockerSandboxBackend( sessionKey: createInput.sessionKey, }; }, + async destroy() { + await stopDockerContainerIfRunning(cli, containerName); + expectDockerSuccess( + await cli.run(["rm", "-f", containerName]), + `delete sandbox session container "${containerName}"`, + ); + }, async stop() { await stopDockerContainerIfRunning(cli, containerName); }, diff --git a/packages/eve/src/execution/sandbox/bindings/just-bash-runtime.integration.test.ts b/packages/eve/src/execution/sandbox/bindings/just-bash-runtime.integration.test.ts new file mode 100644 index 0000000000..86642006a7 --- /dev/null +++ b/packages/eve/src/execution/sandbox/bindings/just-bash-runtime.integration.test.ts @@ -0,0 +1,36 @@ +import { access, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { + createJustBashHandle, + type BashSandbox, +} from "#execution/sandbox/bindings/just-bash-runtime.js"; + +describe("just-bash sandbox destruction", () => { + it("disposes the interpreter and removes the session root", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "eve-just-bash-destroy-")); + await writeFile(join(rootPath, "state.txt"), "persisted"); + const dispose = vi.fn(async () => {}); + const sandbox = { + captureState: vi.fn(async () => null), + dispose, + readFileBytes: vi.fn(async () => null), + removePath: vi.fn(async () => {}), + rootPath, + sessionKey: "session-key", + async spawn() { + throw new Error("spawn is not used by this test"); + }, + writeFiles: vi.fn(async () => {}), + } satisfies BashSandbox; + const handle = createJustBashHandle(sandbox, "just-bash"); + + await handle.destroy(); + + expect(dispose).toHaveBeenCalledTimes(1); + await expect(access(rootPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/packages/eve/src/execution/sandbox/bindings/just-bash-runtime.ts b/packages/eve/src/execution/sandbox/bindings/just-bash-runtime.ts index 72ee3c564a..0435e2c1d7 100644 --- a/packages/eve/src/execution/sandbox/bindings/just-bash-runtime.ts +++ b/packages/eve/src/execution/sandbox/bindings/just-bash-runtime.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import type { IFileSystem } from "just-bash"; @@ -221,6 +221,10 @@ export function createJustBashHandle( sessionKey: sandbox.sessionKey, }; }, + async destroy() { + await sandbox.dispose(); + await rm(sandbox.rootPath, { force: true, recursive: true }); + }, async stop() { await sandbox.dispose(); }, diff --git a/packages/eve/src/execution/sandbox/bindings/microsandbox-lifecycle.test.ts b/packages/eve/src/execution/sandbox/bindings/microsandbox-lifecycle.test.ts index 82923d712d..359a255f25 100644 --- a/packages/eve/src/execution/sandbox/bindings/microsandbox-lifecycle.test.ts +++ b/packages/eve/src/execution/sandbox/bindings/microsandbox-lifecycle.test.ts @@ -208,6 +208,37 @@ describe("createMicrosandboxHandle", () => { expect(runtimeMocks.createPreparedMicrosandbox).toHaveBeenCalledTimes(2); }); + it("destroys persisted state and evicts the active-session cache", async () => { + const vm = createFakeMicrosandboxVm("session-key"); + runtimeMocks.createPreparedMicrosandbox.mockResolvedValue(vm); + const options = resolveMicrosandboxOptions({ image: MICROSANDBOX_DEFAULT_IMAGE }); + const createInput = { + runtimeContext: { appRoot: "/tmp/eve-app" }, + sessionKey: "session-key", + templateKey: "template-key", + }; + + const handle = await createMicrosandboxHandle({ + backendName: "microsandbox", + createInput, + options, + optionsHash: "options-hash", + }); + await handle.destroy(); + + expect(vm.shutdown).toHaveBeenCalledTimes(1); + expect(vm.removePersisted).toHaveBeenCalledTimes(1); + + const nextHandle = await createMicrosandboxHandle({ + backendName: "microsandbox", + createInput, + options, + optionsHash: "options-hash", + }); + expect(nextHandle).not.toBe(handle); + expect(runtimeMocks.createPreparedMicrosandbox).toHaveBeenCalledTimes(2); + }); + it("reports a missing template snapshot race as not provisioned", async () => { runtimeMocks.createPreparedMicrosandbox.mockRejectedValueOnce( new Error("snapshot template-snapshot not found"), @@ -330,7 +361,7 @@ function createFakeMicrosandboxVm(sessionKey: string) { async removePath({ path }: { readonly path: string }) { files.delete(path); }, - async removePersisted() {}, + removePersisted: vi.fn(async () => {}), async setNetworkPolicy() {}, async spawn() { throw new Error("spawn is not used by this test."); diff --git a/packages/eve/src/execution/sandbox/bindings/microsandbox-lifecycle.ts b/packages/eve/src/execution/sandbox/bindings/microsandbox-lifecycle.ts index 7095b22259..8bfe5604ed 100644 --- a/packages/eve/src/execution/sandbox/bindings/microsandbox-lifecycle.ts +++ b/packages/eve/src/execution/sandbox/bindings/microsandbox-lifecycle.ts @@ -308,6 +308,11 @@ function createHandle( sessionKey: sandbox.id, }; }, + async destroy() { + await sandbox.shutdown(); + await sandbox.removePersisted(); + onShutdown?.(); + }, async stop() { await sandbox.stop(); onShutdown?.(); diff --git a/packages/eve/src/execution/sandbox/bindings/microsandbox-runtime.ts b/packages/eve/src/execution/sandbox/bindings/microsandbox-runtime.ts index f0bcfcc3b9..f697452fa5 100644 --- a/packages/eve/src/execution/sandbox/bindings/microsandbox-runtime.ts +++ b/packages/eve/src/execution/sandbox/bindings/microsandbox-runtime.ts @@ -181,9 +181,12 @@ export class MicrosandboxVm { async removePersisted(): Promise { await removeSandboxIfExists(this.#input.module, this.#sandboxName); - if (this.#stateSnapshotName !== undefined) { - await removeSnapshotIfExists(this.#input.module, this.#stateSnapshotName); + const stateSnapshotName = this.#stateSnapshotName; + if (stateSnapshotName === undefined) { + return; } + await removeSnapshotIfExists(this.#input.module, stateSnapshotName); + this.#stateSnapshotName = undefined; } async setNetworkPolicy(policy: SandboxNetworkPolicy): Promise { diff --git a/packages/eve/src/execution/sandbox/bindings/vercel.test.ts b/packages/eve/src/execution/sandbox/bindings/vercel.test.ts index cab2e0fe45..8051a2f220 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel.test.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel.test.ts @@ -45,6 +45,7 @@ function createMockDetachedCommand( function createMockSandbox(input: { name: string; + sourceSnapshotId?: string; snapshotId?: string; status?: string; tags?: Record; @@ -58,6 +59,13 @@ function createMockSandbox(input: { rm: vi.fn().mockResolvedValue(undefined), unlink: vi.fn().mockResolvedValue(undefined), }, + listSnapshots: vi.fn( + async (): Promise<{ + [Symbol.asyncIterator](): AsyncGenerator<{ id: string; status: string }>; + }> => ({ + async *[Symbol.asyncIterator]() {}, + }), + ), name: input.name, readFile: vi.fn(async (file: { path: string }): Promise => { const content = files.get(file.path); @@ -65,6 +73,7 @@ function createMockSandbox(input: { }), runCommand: vi.fn().mockResolvedValue(createMockCommandResult()), snapshot: vi.fn().mockResolvedValue({ snapshotId: `${input.name}-snapshot` }), + sourceSnapshotId: input.sourceSnapshotId, status: input.status ?? "running", stop: vi.fn().mockResolvedValue(undefined), get tags() { @@ -1097,6 +1106,85 @@ describe("createVercelSandbox", () => { ); }); + it("stops compute and deletes every session snapshot before deleting the sandbox", async () => { + const templateSandbox = createMockSandbox({ name: "template" }); + const sessionSandbox = createMockSandbox({ + name: "session", + sourceSnapshotId: "template-source", + }); + const snapshots = [ + { id: "snapshot-1", status: "created" }, + { id: "template-source", status: "created" }, + { id: "snapshot-deleted", status: "deleted" }, + { id: "snapshot-2", status: "created" }, + ]; + const order: string[] = []; + sessionSandbox.stop.mockImplementation(async () => { + order.push("stop"); + }); + sessionSandbox.listSnapshots.mockImplementation(async () => { + order.push("list"); + return { + async *[Symbol.asyncIterator]() { + yield* snapshots; + }, + }; + }); + sessionSandbox.delete.mockImplementation(async () => { + order.push("sandbox-delete"); + }); + const snapshotDelete = vi.fn(async ({ signal }: { signal?: AbortSignal }) => { + expect(signal).toBe(abortSignal); + }); + const snapshotGet = vi.fn(async ({ snapshotId }: { snapshotId: string }) => ({ + delete: vi.fn(async (options: { signal?: AbortSignal }) => { + await snapshotDelete(options); + order.push(`delete:${snapshotId}`); + }), + })); + const sandboxModule = { + Sandbox: { + create: vi + .fn() + .mockResolvedValueOnce(templateSandbox) + .mockResolvedValueOnce(sessionSandbox), + get: vi.fn().mockResolvedValue(null), + }, + Snapshot: { get: snapshotGet }, + }; + const backend = createTestVercelSandbox({ + loadSandboxModule: async () => sandboxModule as never, + }); + await backend.prewarm({ + runtimeContext: { appRoot: "/tmp/test-app-root" }, + seedFiles: [], + templateKey: "template-key", + }); + const handle = await backend.create({ + runtimeContext: { appRoot: "/tmp/test-app-root" }, + sessionKey: "session-key", + templateKey: "template-key", + }); + const abortSignal = new AbortController().signal; + + await expect(handle.destroy({ abortSignal })).resolves.toBeUndefined(); + + expect(order).toEqual([ + "stop", + "list", + "delete:snapshot-1", + "delete:snapshot-2", + "sandbox-delete", + ]); + expect(sessionSandbox.listSnapshots).toHaveBeenCalledWith({ limit: 100, signal: abortSignal }); + expect(snapshotGet).toHaveBeenCalledTimes(2); + expect(snapshotGet).toHaveBeenCalledWith( + expect.objectContaining({ signal: abortSignal, snapshotId: "snapshot-1" }), + ); + expect(snapshotDelete).toHaveBeenCalledTimes(2); + expect(sessionSandbox.delete).toHaveBeenCalledWith({ signal: abortSignal }); + }); + it("skips the stop call on shutdown when the sandbox is not running", async () => { const templateSandbox = createMockSandbox({ name: "template" }); const sessionSandbox = createMockSandbox({ name: "session", status: "stopped" }); diff --git a/packages/eve/src/execution/sandbox/bindings/vercel.ts b/packages/eve/src/execution/sandbox/bindings/vercel.ts index 2ce7c5e653..33bbb9c9c4 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel.ts @@ -34,6 +34,11 @@ import { createLoggingSandboxSession } from "#execution/sandbox/logging-session. import { adaptMultiplexedCommandToSandboxProcess } from "#execution/sandbox/multiplexed-command.js"; import { buildSandboxSession } from "#execution/sandbox/session.js"; import { streamToBuffer } from "#execution/sandbox/stream-utils.js"; +import { + getVercelSandboxCredentials, + getVercelSandboxFetch, + type VercelSandboxCredentials, +} from "#execution/sandbox/bindings/vercel-credentials.js"; import { createVercelEveImageSandbox, type CreateVercelSandbox, @@ -141,7 +146,12 @@ export function createVercelSandbox( await applyInitialVercelNetworkPolicy(session.sandbox, createOptions.networkPolicy); } - return createHandle(session.sandbox, createInput.sessionKey); + return createHandle({ + createOptions, + sandbox: session.sandbox, + sandboxModule, + sessionKey: createInput.sessionKey, + }); }, async prewarm( prewarmInput: SandboxBackendPrewarmInput, @@ -449,10 +459,13 @@ function withBaseSetupNetworkPolicy( return { ...createOptions, networkPolicy: "allow-all" }; } -function createHandle( - sandbox: VercelSandbox, - sessionKey: string, -): SandboxBackendHandle { +function createHandle(input: { + readonly createOptions: VercelCreateOptions; + readonly sandbox: VercelSandbox; + readonly sandboxModule: VercelModule; + readonly sessionKey: string; +}): SandboxBackendHandle { + const { sandbox, sessionKey } = input; return { session: buildSandboxSession( createVercelInternalSandboxSession(sandbox, sessionKey), @@ -474,6 +487,30 @@ function createHandle( sessionKey, }; }, + async destroy(options) { + await stopVercelSandbox(sandbox); + const snapshots = await sandbox.listSnapshots({ + limit: 100, + signal: options?.abortSignal, + }); + const credentials = await resolveVercelSnapshotCredentials(input.createOptions); + for await (const metadata of snapshots) { + if ( + metadata.status !== "created" || + (sandbox.sourceSnapshotId !== undefined && metadata.id === sandbox.sourceSnapshotId) + ) { + continue; + } + const snapshot = await input.sandboxModule.Snapshot.get({ + ...credentials, + fetch: getVercelSandboxFetch(input.createOptions), + signal: options?.abortSignal, + snapshotId: metadata.id, + }); + await snapshot.delete({ signal: options?.abortSignal }); + } + await sandbox.delete({ signal: options?.abortSignal }); + }, async stop() { await stopVercelSandbox(sandbox); }, @@ -487,6 +524,16 @@ function createHandle( }; } +async function resolveVercelSnapshotCredentials( + createOptions: VercelCreateOptions, +): Promise> { + try { + return await getVercelSandboxCredentials(createOptions); + } catch { + return {}; + } +} + async function stopVercelSandbox(sandbox: VercelSandbox): Promise { if (sandbox.status !== "running" && sandbox.status !== "pending") { return; diff --git a/packages/eve/src/execution/sandbox/ensure.test.ts b/packages/eve/src/execution/sandbox/ensure.test.ts index 78987a02fd..c08f793431 100644 --- a/packages/eve/src/execution/sandbox/ensure.test.ts +++ b/packages/eve/src/execution/sandbox/ensure.test.ts @@ -61,7 +61,7 @@ function createTestRegistry( }; } -function createBackend(): SandboxBackend { +function createBackend(options?: { readonly destroy?: () => Promise }): SandboxBackend { const sandbox = mockSandbox({ id: "sbx_session_auth" }); const create = vi.fn(async (input: SandboxBackendCreateInput) => { return { @@ -70,6 +70,7 @@ function createBackend(): SandboxBackend { metadata: {}, sessionKey: input.sessionKey, }), + destroy: vi.fn(options?.destroy ?? (async () => {})), stop: vi.fn(async () => {}), useSessionFn: async () => sandbox.session, shutdown: async () => {}, @@ -82,6 +83,7 @@ function createBackend(): SandboxBackend { async function ensure(input: { readonly compiledArtifactsSource?: RuntimeCompiledArtifactsSource; + readonly ownsSandbox?: boolean; readonly runOnSession?: (callback: () => Promise) => Promise; readonly registry: RuntimeSandboxRegistry; readonly state?: SandboxState; @@ -91,6 +93,7 @@ async function ensure(input: { compiledArtifactsSource: input.compiledArtifactsSource ?? createBundledRuntimeCompiledArtifactsSource(), nodeId: "__root__", + ownsSandbox: input.ownsSandbox, registry: input.registry, runOnSession: input.runOnSession, sessionId: "session_1", @@ -427,6 +430,55 @@ describe("ensureSandboxAccess", () => { const handle = await vi.mocked(backend.create).mock.results[0]?.value; expect(handle?.stop).toHaveBeenCalledTimes(1); }); + + it("destroys the sandbox and reprovisions a fresh handle on the next access", async () => { + const ctx = new ContextContainer(); + ctx.set(SessionKey, createSession()); + const onSession = vi.fn(); + const backend = createBackend(); + const access = await ensure({ + registry: createTestRegistry({ onSession }, backend), + runOnSession: async (callback) => await contextStorage.run(ctx, callback), + }); + + await expect(access.destroy!()).resolves.toBeUndefined(); + await expect(access.captureState()).resolves.toEqual({ initialized: false, session: null }); + await access.get(); + + expect(backend.create).toHaveBeenCalledTimes(2); + expect(onSession).toHaveBeenCalledTimes(2); + const firstHandle = await vi.mocked(backend.create).mock.results[0]!.value; + expect(firstHandle.destroy).toHaveBeenCalledTimes(1); + }); + + it("preserves the current handle and state when destruction fails", async () => { + const backend = createBackend({ + destroy: async () => { + throw new Error("provider unreachable"); + }, + }); + const access = await ensure({ registry: createTestRegistry({}, backend) }); + + await expect(access.destroy!()).rejects.toThrow("provider unreachable"); + await access.get(); + + expect(backend.create).toHaveBeenCalledTimes(1); + await expect(access.captureState()).resolves.toMatchObject({ + initialized: true, + session: { backendName: "test" }, + }); + }); + + it("rejects destruction from a session that does not own the shared sandbox", async () => { + const backend = createBackend(); + const access = await ensure({ ownsSandbox: false, registry: createTestRegistry({}, backend) }); + + await expect(access.destroy!()).rejects.toThrow( + "Only the owning session can destroy a shared sandbox", + ); + + expect(backend.create).not.toHaveBeenCalled(); + }); }); function createDeferred() { diff --git a/packages/eve/src/execution/sandbox/ensure.ts b/packages/eve/src/execution/sandbox/ensure.ts index 51e6a5e1ce..d1f11e3b1f 100644 --- a/packages/eve/src/execution/sandbox/ensure.ts +++ b/packages/eve/src/execution/sandbox/ensure.ts @@ -27,6 +27,8 @@ import type { SandboxAccess, SandboxSessionState, SandboxState } from "#sandbox/ export interface EnsureSandboxAccessInput { readonly compiledArtifactsSource: RuntimeCompiledArtifactsSource; readonly nodeId: string; + /** Whether this durable session owns the sandbox lifecycle. */ + readonly ownsSandbox?: boolean; readonly registry: RuntimeSandboxRegistry; readonly sessionId: string; readonly runOnSession?: (callback: () => Promise) => Promise; @@ -177,6 +179,21 @@ export async function ensureSandboxAccess(input: EnsureSandboxAccessInput): Prom session: persistedSession, }; }, + async destroy(options) { + if (input.ownsSandbox === false) { + throw new Error( + "Only the owning session can destroy a shared sandbox. Destroy it from the parent session instead.", + ); + } + const handle = await getHandle(); + if (handle === null) { + throw new Error("The sandbox is not available in the current authored runtime context."); + } + await handle.destroy(options); + handlePromise = undefined; + initialized = false; + persistedSession = null; + }, async get(): Promise { const handle = await getHandle(); if (handle === null) return null; diff --git a/packages/eve/src/execution/session-reset.integration.test.ts b/packages/eve/src/execution/session-reset.integration.test.ts index 6f8371f80f..eb39118de4 100644 --- a/packages/eve/src/execution/session-reset.integration.test.ts +++ b/packages/eve/src/execution/session-reset.integration.test.ts @@ -76,6 +76,7 @@ function createSessionSandboxHarness() { metadata: {}, sessionKey: input.sessionKey, }), + destroy: async () => {}, session: sandbox.session, stop: async () => {}, shutdown: async () => {}, diff --git a/packages/eve/src/execution/tasks/parent/tool-execution.integration.test.ts b/packages/eve/src/execution/tasks/parent/tool-execution.integration.test.ts index f3518605ce..1374335e3b 100644 --- a/packages/eve/src/execution/tasks/parent/tool-execution.integration.test.ts +++ b/packages/eve/src/execution/tasks/parent/tool-execution.integration.test.ts @@ -85,6 +85,7 @@ describe("background subagent tool execution", () => { metadata: {}, sessionKey: input.sessionKey, }), + destroy: async () => {}, session: sandbox.session, shutdown: async () => {}, stop: async () => {}, diff --git a/packages/eve/src/internal/testing/mocks/mock-sandbox.ts b/packages/eve/src/internal/testing/mocks/mock-sandbox.ts index 2f6ff4c9b0..41b4a2f5ec 100644 --- a/packages/eve/src/internal/testing/mocks/mock-sandbox.ts +++ b/packages/eve/src/internal/testing/mocks/mock-sandbox.ts @@ -27,6 +27,8 @@ import { bufferToStream, streamToBuffer } from "#execution/sandbox/stream-utils. * or file contents without touching disk or spawning processes. */ export interface MockSandboxInput { + /** Callback invoked when authored runtime code destroys this sandbox. */ + readonly destroy?: () => Promise | void; /** * Stable sandbox identifier. Defaults to `"sbx_mock"`. */ @@ -252,6 +254,9 @@ export function mockSandbox(input: MockSandboxInput = {}): MockSandbox { session: null, }; }, + async destroy(): Promise { + await input.destroy?.(); + }, async get(): Promise { return session; }, diff --git a/packages/eve/src/public/channels/linear/defaults.test.ts b/packages/eve/src/public/channels/linear/defaults.test.ts index 83666c2f69..9bc1d5c1e4 100644 --- a/packages/eve/src/public/channels/linear/defaults.test.ts +++ b/packages/eve/src/public/channels/linear/defaults.test.ts @@ -11,6 +11,7 @@ function sessionContext(): SessionContext { return { getSandbox: vi.fn(), getSkill: vi.fn(), + sandbox: { destroy: vi.fn() }, session: { auth: { current: { diff --git a/packages/eve/src/public/channels/slack/defaults.test.ts b/packages/eve/src/public/channels/slack/defaults.test.ts index 84b0dc8660..935459bf6d 100644 --- a/packages/eve/src/public/channels/slack/defaults.test.ts +++ b/packages/eve/src/public/channels/slack/defaults.test.ts @@ -10,6 +10,7 @@ function sessionContext( return { getSandbox: vi.fn(), getSkill: vi.fn(), + sandbox: { destroy: vi.fn() }, session: { auth: { current, initiator: null }, id: "test-session", diff --git a/packages/eve/src/public/definitions/callback-context.ts b/packages/eve/src/public/definitions/callback-context.ts index 48a4ac1567..6f8ab9542c 100644 --- a/packages/eve/src/public/definitions/callback-context.ts +++ b/packages/eve/src/public/definitions/callback-context.ts @@ -1,9 +1,16 @@ import type { SkillHandle } from "#execution/skills/types.js"; import type { RuntimeSandboxSession } from "#shared/sandbox-session.js"; +import type { SandboxDestroyOptions } from "#shared/sandbox-backend.js"; import type { SessionAuth, SessionParent, SessionTurn } from "#context/keys.js"; export type { SessionAuth, SessionParent, SessionTurn }; +/** Authored lifecycle operations for the current session's sandbox. */ +export interface SessionSandboxLifecycle { + /** Permanently destroys this sandbox and its session-owned snapshots. */ + destroy(options?: SandboxDestroyOptions): Promise; +} + /** * Shared runtime context available to all authored callbacks that run * inside the ALS-scoped harness step (tools, hooks, channel events). @@ -23,6 +30,9 @@ export interface SessionContext { readonly parent?: SessionParent; }; + /** Lazy lifecycle operations for the current session's sandbox. */ + readonly sandbox: SessionSandboxLifecycle; + /** * Resolves the session's sandbox. Throws when no sandbox is available * in the current authored runtime context. diff --git a/packages/eve/src/public/definitions/sandbox-backend.ts b/packages/eve/src/public/definitions/sandbox-backend.ts index 34b870420e..6d854dab96 100644 --- a/packages/eve/src/public/definitions/sandbox-backend.ts +++ b/packages/eve/src/public/definitions/sandbox-backend.ts @@ -1,6 +1,7 @@ export type { SandboxBackendHandle, SandboxBackendSessionState, + SandboxDestroyOptions, SandboxSeedFile, SandboxBackendTags, SandboxBackendRuntimeContext, diff --git a/packages/eve/src/public/sandbox/index.ts b/packages/eve/src/public/sandbox/index.ts index 8007696945..f694d7a0a5 100644 --- a/packages/eve/src/public/sandbox/index.ts +++ b/packages/eve/src/public/sandbox/index.ts @@ -34,6 +34,7 @@ export type { SandboxBackendPrewarmInput, SandboxBackendRuntimeContext, SandboxBackendSessionState, + SandboxDestroyOptions, SandboxSeedFile, } from "#public/definitions/sandbox-backend.js"; export type { SandboxNetworkPolicy } from "#shared/sandbox-network-policy.js"; diff --git a/packages/eve/src/sandbox/state.ts b/packages/eve/src/sandbox/state.ts index 3d64152fa0..757701f45c 100644 --- a/packages/eve/src/sandbox/state.ts +++ b/packages/eve/src/sandbox/state.ts @@ -1,5 +1,6 @@ import type { SandboxSession } from "#public/definitions/sandbox.js"; import type { SandboxBackendSessionState } from "#public/definitions/sandbox-backend.js"; +import type { SandboxDestroyOptions } from "#public/definitions/sandbox-backend.js"; /** * Serializable sandbox reconnect record stored on the harness session. @@ -31,6 +32,7 @@ export interface SandboxState { */ export interface SandboxAccess { captureState(): Promise; + destroy?(options?: SandboxDestroyOptions): Promise; get(): Promise; stop(): Promise; } diff --git a/packages/eve/src/shared/sandbox-backend.ts b/packages/eve/src/shared/sandbox-backend.ts index a727d86f18..1d925b02c3 100644 --- a/packages/eve/src/shared/sandbox-backend.ts +++ b/packages/eve/src/shared/sandbox-backend.ts @@ -12,6 +12,11 @@ export interface SandboxBackendHandle> { readonly session: SandboxSession; readonly useSessionFn: SandboxSessionUseFn; captureState(): Promise; + /** + * Permanently destroys this sandbox and its session-owned snapshots. + * Reusable template snapshots must not be deleted. + */ + destroy(options?: SandboxDestroyOptions): Promise; /** * Stops the underlying compute at an authored runtime boundary while * preserving any backend state needed to reopen the durable session. @@ -27,6 +32,11 @@ export interface SandboxBackendHandle> { shutdown(): Promise; } +/** Options for permanently destroying one sandbox. */ +export interface SandboxDestroyOptions { + readonly abortSignal?: AbortSignal; +} + /** * Serializable per-sandbox reconnect record stored on the harness session. *