Skip to content
Draft
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/destroy-sandbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": minor
---

Add `ctx.sandbox.destroy()` for permanently deleting the current session sandbox and reprovisioning it on the next access.
11 changes: 11 additions & 0 deletions docs/guides/session-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions docs/sandbox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions packages/eve/extension-contracts/compatibility/channel/v7.ts
Original file line number Diff line number Diff line change
@@ -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",
});
11 changes: 11 additions & 0 deletions packages/eve/extension-contracts/compatibility/connection/v5.ts
Original file line number Diff line number Diff line change
@@ -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",
});
14 changes: 14 additions & 0 deletions packages/eve/extension-contracts/compatibility/dynamicTool/v18.ts
Original file line number Diff line number Diff line change
@@ -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}` }),
}),
},
});
13 changes: 13 additions & 0 deletions packages/eve/extension-contracts/compatibility/hook/v14.ts
Original file line number Diff line number Diff line change
@@ -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,
});
},
},
});
10 changes: 10 additions & 0 deletions packages/eve/extension-contracts/compatibility/state/v3.ts
Original file line number Diff line number Diff line change
@@ -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 }));
}
9 changes: 9 additions & 0 deletions packages/eve/extension-contracts/compatibility/tool/v17.ts
Original file line number Diff line number Diff line change
@@ -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}` }),
});
17 changes: 17 additions & 0 deletions packages/eve/extension-contracts/reports/channel/v8.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
15 changes: 15 additions & 0 deletions packages/eve/extension-contracts/reports/connection/v6.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"kind": "eve-extension-capability-contract",
"capability": "connection",
"epoch": 6,
"sha256": "44e8dfc8afb66629ec2b6d8118a32f0c85a1fa907be2fe402b1605a12a214d48",
"exports": [
"ConnectionAuthorizationFailedError",
"ConnectionAuthorizationRequiredError",
"defineInteractiveAuthorization",
"defineMcpClientConnection",
"defineOpenAPIConnection",
"isConnectionAuthorizationFailedError",
"isConnectionAuthorizationRequiredError"
]
}
13 changes: 13 additions & 0 deletions packages/eve/extension-contracts/reports/dynamicTool/v19.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"kind": "eve-extension-capability-contract",
"capability": "dynamicTool",
"epoch": 19,
"sha256": "d68548fdb9e99e591e9d4e81308d5f551e4a46dbf508bc1645c2409cf015dba7",
"exports": [
"DynamicToolEntry",
"DynamicToolEvents",
"DynamicToolResult",
"DynamicToolSet",
"defineDynamic"
]
}
7 changes: 7 additions & 0 deletions packages/eve/extension-contracts/reports/hook/v15.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"kind": "eve-extension-capability-contract",
"capability": "hook",
"epoch": 15,
"sha256": "35b28df922e0f6c57c5a4278ff9538dfac245bff80e32d0e73c3aa1d3f4fc522",
"exports": ["defineHook"]
}
15 changes: 15 additions & 0 deletions packages/eve/extension-contracts/reports/state/v4.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"kind": "eve-extension-capability-contract",
"capability": "state",
"epoch": 4,
"sha256": "1e0b68d9e907f8bd54c06ca780c48968f8231f225703b4a68b09a89be68edd26",
"exports": [
"Session",
"SessionAuth",
"SessionAuthContext",
"SessionContext",
"SessionParent",
"SessionTurn",
"defineState"
]
}
22 changes: 22 additions & 0 deletions packages/eve/extension-contracts/reports/tool/v18.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
18 changes: 9 additions & 9 deletions packages/eve/src/compiler/extension-compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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<string, ExtensionCapabilityContract>;

/** One independently versioned extension-facing contract. */
Expand Down
24 changes: 24 additions & 0 deletions packages/eve/src/context/accessors.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
16 changes: 16 additions & 0 deletions packages/eve/src/context/build-callback-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeSandboxSession> {
const access = ctx.get(SandboxKey);
if (access === undefined) {
Expand Down
1 change: 1 addition & 0 deletions packages/eve/src/context/dynamic-tool-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/eve/src/context/providers/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ describe("sandboxProvider", () => {

expect(ensureSandboxAccess).toHaveBeenCalledWith(
expect.objectContaining({
ownsSandbox: false,
sessionId: "root-sandbox-session",
state: parentSandboxState,
}),
Expand All @@ -98,6 +99,7 @@ describe("sandboxProvider", () => {

expect(ensureSandboxAccess).toHaveBeenCalledWith(
expect.objectContaining({
ownsSandbox: true,
tags: {
agent: "weather-agent",
channel: "slack",
Expand Down
1 change: 1 addition & 0 deletions packages/eve/src/context/providers/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const sandboxProvider: FrameworkContextProvider<SandboxAccess> = {
value: await ensureSandboxAccess({
compiledArtifactsSource: bundle.compiledArtifactsSource,
nodeId: node.nodeId,
ownsSandbox: !sharesSandbox,
registry,
runOnSession: async (callback) => await contextStorage.run(ctx, callback),
sessionId: sandboxSessionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,7 @@ function createSandboxBackend() {
metadata: {},
sessionKey: input.sessionKey,
}),
destroy: async () => {},
session: sandbox.session,
shutdown: async () => {},
stop: async () => {},
Expand Down
Loading
Loading