feat(eve): allow authored sandbox stops (#1801)

Signed-off-by: Casey Gowrie <ctgowrie@gmail.com>
This commit is contained in:
Casey Gowrie
2026-08-10 14:43:49 -04:00
committed by GitHub
parent 7449917cac
commit cbe71057bf
54 changed files with 558 additions and 32 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"eve": minor
---
Allow authored hooks, tools, and channel callbacks to stop their active sandbox through `ctx.getSandbox().stop()`. Every built-in backend preserves the durable session for a later callback, and custom sandbox backend handles must now implement `stop()`.
+25 -3
View File
@@ -30,16 +30,38 @@ A hook file declares stream-event subscribers under the `events` map, keyed by e
## Hook structure and context
Every handler receives the same `HookContext`:
Every handler receives the same `HookContext`, including the shared session
helpers documented in [Session context](./session-context):
```ts
interface HookContext {
interface HookContext extends SessionContext {
readonly agent: { readonly name: string; readonly nodeId?: string };
readonly channel: { readonly kind?: string; readonly continuationToken?: string };
readonly session: { readonly id: string };
}
```
That means a hook can access the current sandbox and release its backing
compute at an application-defined boundary:
```ts title="agent/hooks/stop-after-turn.ts"
import { defineHook } from "eve/hooks";
export default defineHook({
events: {
async "turn.completed"(_event, ctx) {
const sandbox = await ctx.getSandbox();
await sandbox.stop();
},
},
});
```
Every built-in backend stops its underlying compute while preserving the
durable session and filesystem for the next callback. On Vercel, the current
handle can also automatically resume on later I/O. A hook failure, including a
failed stop, follows the normal
[hook failure behavior](#what-happens-when-a-hook-throws).
### Narrowing tool results
`toolResultFrom` narrows an `action.result` event to a specific authored tool or MCP connection and returns typed output. Import it from `eve/tools`:
+18
View File
@@ -69,6 +69,24 @@ Behavior:
- It is async because eve binds or restores sandbox state lazily.
- It only works when sandbox access is attached to the active runtime path.
- Visibility is node-local. A subagent sees its own sandbox, not the parent's.
- The returned `RuntimeSandboxSession` extends the ordinary sandbox I/O surface
with `stop()`. It is exported from `eve/sandbox`.
Call `stop()` to release sandbox compute while preserving the durable session
and its filesystem:
```ts
const sandbox = await ctx.getSandbox();
await sandbox.stop();
```
Each backend implements this with its native lifecycle operation. Treat the
stop as the end of sandbox work in the current callback; a later callback calls
`ctx.getSandbox()` normally and eve reopens the same durable session. Vercel
also supports using the same handle again: its next command or file operation
automatically resumes the sandbox, just as it would after an inactivity
timeout. No separate eve reconnect state is created, and provider failures
reject the returned promise.
`SandboxSession` also exposes `resolvePath(path)`, which returns the live backend-native path for a logical `/workspace/...` location. Use it when authored code needs that path before passing it to shell code or a child process.
+1 -1
View File
@@ -61,7 +61,7 @@ A few non-`define*` helpers round out the set: `disableTool`, `experimental_work
| Member | Use |
| --------------------------- | ---------------------------------------------------------------------------- |
| `ctx.session` | Current session, turn, auth, and optional parent lineage (read-only) |
| `ctx.getSandbox()` | Live sandbox handle for the current agent |
| `ctx.getSandbox()` | Live sandbox handle; `stop()` releases compute but preserves durable state |
| `ctx.getSkill(identifier)` | Handle for a named skill visible to the current agent |
| `ctx.getToken(provider)` | Resolve a bearer token for an inline auth provider such as `connect("...")` |
| `ctx.requireAuth(provider)` | Evict and re-authorize an inline provider, commonly after a downstream `401` |
+19 -1
View File
@@ -185,9 +185,27 @@ export default defineSandbox({
Sessions are persistent, and how the underlying runtime idles out depends on the backend. On the Vercel backend, the VM times out after a period of inactivity (default 30 minutes); eve preserves the filesystem and resumes the sandbox on the next message as if nothing happened, even days later. The Docker backend keeps a long-lived container per durable session and persists `/workspace` across turns without that timeout, and the just-bash backend stores its virtual filesystem under `.eve/sandbox-cache/`. In every case, `/workspace` survives between turns for the same session.
Authored runtime callbacks can stop compute sooner through the handle returned
by `ctx.getSandbox()`:
```ts
const sandbox = await ctx.getSandbox();
await sandbox.stop();
```
Every built-in backend uses its native lifecycle operation without deleting the
durable session. Treat the stop as the end of sandbox work in the current
callback. On the next callback, `ctx.getSandbox()` reopens the same Docker
container, microsandbox VM or snapshot, or just-bash filesystem and environment.
Vercel can also automatically resume the same handle on its next I/O operation,
just as it would after an inactivity timeout. No separate reconnect step or
stop-specific state is needed. Lifecycle `use()` calls return the I/O-only
`SandboxSession` because bootstrap and session initialization do not own runtime
teardown.
Session sandboxes are keyed per durable session, not per deployment, so redeploying your app does not discard them. A session gets a replacement sandbox only when the sandbox definition itself changes — the authored sandbox source, workspace seed content, or `revalidationKey` — in which case the next turn starts from the rebuilt template and `onSession` runs again.
When the eve server stops, no sandbox compute outlives it. `eve dev` stops the sandboxes it started when the dev server closes, and a self-hosted production server stops every open sandbox on shutdown (`SIGTERM`/`SIGINT`). Session state persists across the stop — the next server start reattaches each durable session from its stopped container, VM, or snapshot. Custom `SandboxBackend` adapters participate through the handle's `shutdown()` method: stop the underlying compute, keeping the session reattachable from persisted state where the backend supports it.
When the eve server stops, no sandbox compute outlives it. `eve dev` stops the sandboxes it started when the dev server closes, and a self-hosted production server stops every open sandbox on shutdown (`SIGTERM`/`SIGINT`). Session state persists across the stop — the next server start reattaches each durable session from its stopped container, VM, or snapshot. Custom `SandboxBackend` adapters implement `stop()` for authored runtime calls and `shutdown()` for server teardown. Both stop the underlying compute while keeping the session reattachable from persisted state where the backend supports it; authored `stop()` failures reject, while process-wide shutdown collects and logs failures without blocking teardown.
## Network policy
@@ -0,0 +1,19 @@
import { defineHook } from "eve/hooks";
const STOP_SANDBOX_TOKEN = "sandbox-stop-hook-ready-R7V";
const STOP_SANDBOX_MARKER_PATH = "/workspace/stopped-by-hook.txt";
export default defineHook({
events: {
async "message.completed"(event, ctx) {
if (!event.data.message?.includes(STOP_SANDBOX_TOKEN)) return;
const sandbox = await ctx.getSandbox();
await sandbox.writeTextFile({
content: STOP_SANDBOX_TOKEN,
path: STOP_SANDBOX_MARKER_PATH,
});
await sandbox.stop();
},
},
});
@@ -0,0 +1,25 @@
import { defineEval } from "eve/evals";
import { equals } from "eve/evals/expect";
const STOP_SANDBOX_TOKEN = "sandbox-stop-hook-ready-R7V";
const STOP_SANDBOX_MARKER_PATH = "/workspace/stopped-by-hook.txt";
// The first response activates an authored hook that writes a marker and
// stops compute. Reading that marker on the next turn proves the configured
// backend reopens the same durable sandbox state.
export default defineEval({
description: "Sandbox: an authored hook can stop compute and the next turn reopens it.",
async test(t) {
const first = await t.send(`Reply with this exact token: ${STOP_SANDBOX_TOKEN}`);
first.expectOk();
const second = await t.send(
`Run the bash command \`cat ${STOP_SANDBOX_MARKER_PATH}\` and reply with the file contents verbatim.`,
);
await t.require(second.sessionId, equals(first.sessionId));
t.succeeded();
t.calledTool("bash", { output: new RegExp(STOP_SANDBOX_TOKEN) });
t.messageIncludes(STOP_SANDBOX_TOKEN);
},
});
@@ -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",
});
@@ -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}` }),
}),
},
});
@@ -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,
});
},
},
});
@@ -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 }));
}
@@ -0,0 +1,11 @@
import { z as z3 } from "zod/v3";
import { defineTool } from "#public/tools/index.js";
export default defineTool({
description: "Look up a report.",
inputSchema: z3.object({ reportId: z3.string() }),
execute(input, ctx) {
return { callId: ctx.callId, reportId: input.reportId };
},
});
@@ -0,0 +1,15 @@
{
"kind": "eve-extension-capability-contract",
"capability": "connection",
"epoch": 4,
"sha256": "b2c48c160b9c57b8661c661398a2931c30a08e5f43c41acf9ccc678922f550fa",
"exports": [
"ConnectionAuthorizationFailedError",
"ConnectionAuthorizationRequiredError",
"defineInteractiveAuthorization",
"defineMcpClientConnection",
"defineOpenAPIConnection",
"isConnectionAuthorizationFailedError",
"isConnectionAuthorizationRequiredError"
]
}
@@ -0,0 +1,13 @@
{
"kind": "eve-extension-capability-contract",
"capability": "dynamicTool",
"epoch": 12,
"sha256": "d0113501077e6a36820fc997121992d5c1039410c821752623d66fa5dd8f0812",
"exports": [
"DynamicToolEntry",
"DynamicToolEvents",
"DynamicToolResult",
"DynamicToolSet",
"defineDynamic"
]
}
@@ -0,0 +1,7 @@
{
"kind": "eve-extension-capability-contract",
"capability": "hook",
"epoch": 9,
"sha256": "afb6444059c947d8d2a864b46a7f3c6bccf18343e4718ffefeeda1b9e60f9195",
"exports": ["defineHook"]
}
@@ -0,0 +1,15 @@
{
"kind": "eve-extension-capability-contract",
"capability": "state",
"epoch": 3,
"sha256": "09122994045bc69eef9e795c372a37b41afd0e9094e0dca68419321bacedabaa",
"exports": [
"Session",
"SessionAuth",
"SessionAuthContext",
"SessionContext",
"SessionParent",
"SessionTurn",
"defineState"
]
}
@@ -0,0 +1,22 @@
{
"kind": "eve-extension-capability-contract",
"capability": "tool",
"epoch": 11,
"sha256": "94c69226291735f01403a42e52fb2dca4cfb1f98581d233c297defc3fa051786",
"exports": [
"defineBashTool",
"defineGlobTool",
"defineGrepTool",
"defineReadFileTool",
"defineTool",
"defineWriteFileTool",
"disableTool",
"experimental_workflow",
"isDisabledToolSentinel",
"isExperimentalWorkflowToolDefinition",
"toolOutput",
"toolOutputPart",
"toolResultFrom",
"webSearch"
]
}
@@ -21,16 +21,16 @@ interface ExtensionCapabilityContract {
const EXTENSION_CAPABILITY_CONTRACTS = {
extension: { current: 1, supported: [1], dropped: {} },
tool: { current: 10, supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], dropped: {} },
dynamicTool: { current: 11, supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], dropped: {} },
connection: { current: 3, supported: [1, 2, 3], dropped: {} },
hook: { current: 8, supported: [1, 2, 3, 4, 5, 6, 7, 8], dropped: {} },
tool: { current: 11, supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], dropped: {} },
dynamicTool: { current: 12, supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], dropped: {} },
connection: { current: 4, supported: [1, 2, 3, 4], dropped: {} },
hook: { current: 9, supported: [1, 2, 3, 4, 5, 6, 7, 8, 9], dropped: {} },
skill: { current: 1, supported: [1], dropped: {} },
dynamicSkill: { current: 7, supported: [1, 2, 3, 4, 5, 6, 7], dropped: {} },
instructions: { current: 1, supported: [1], dropped: {} },
dynamicInstructions: { current: 7, supported: [1, 2, 3, 4, 5, 6, 7], dropped: {} },
config: { current: 1, supported: [1], dropped: {} },
state: { current: 2, supported: [1, 2], dropped: {} },
state: { current: 3, supported: [1, 2, 3], dropped: {} },
} as const satisfies Record<string, ExtensionCapabilityContract>;
/** One independently versioned extension-facing contract. */
@@ -4,7 +4,7 @@ import { buildCallbackContext } from "#context/build-callback-context.js";
import { createTestRuntime } from "#internal/testing/app-harness.js";
import { mockSandbox } from "#internal/testing/mocks/mock-sandbox.js";
import { mockSkill } from "#internal/testing/mocks/mock-skill.js";
import type { SandboxSession } from "#public/definitions/sandbox.js";
import type { RuntimeSandboxSession, SandboxSession } from "#public/definitions/sandbox.js";
/**
* Integration coverage for {@link buildCallbackContext} the single
@@ -122,6 +122,23 @@ describe("buildCallbackContext getSandbox", () => {
expect(sandbox.removedPaths).toEqual(["/workspace/note.txt"]);
expect(sandbox.files.has("/workspace/note.txt")).toBe(false);
});
it("stops the active sandbox through the runtime session", async () => {
let stops = 0;
const sandbox = mockSandbox({
stop: () => {
stops += 1;
},
});
const runtime = createTestRuntime();
await runtime.runAsSession({ sandbox }, async () => {
const live: RuntimeSandboxSession = await buildCallbackContext().getSandbox();
await live.stop();
});
expect(stops).toBe(1);
});
});
describe("buildCallbackContext getSkill", () => {
@@ -1,6 +1,6 @@
import type { SessionContext } from "#public/definitions/callback-context.js";
import type { SkillHandle } from "#execution/skills/types.js";
import type { SandboxSession } from "#shared/sandbox-session.js";
import type { RuntimeSandboxSession, SandboxSession } from "#shared/sandbox-session.js";
import { createSandboxSkillHandle } from "#runtime/skills/sandbox-access.js";
import { loadContext } from "#context/container.js";
import { SandboxKey, SessionKey } from "#context/keys.js";
@@ -23,7 +23,7 @@ export function buildCallbackContext(): SessionContext {
parent: session.parent,
},
getSandbox(): Promise<SandboxSession> {
getSandbox(): Promise<RuntimeSandboxSession> {
const access = ctx.get(SandboxKey);
if (access === undefined) {
throw new Error(
@@ -35,7 +35,7 @@ export function buildCallbackContext(): SessionContext {
if (sandbox === null) {
throw new Error("The sandbox is not available in the current authored runtime context.");
}
return sandbox;
return withRuntimeSandboxStop(sandbox, async () => await access.stop());
});
},
@@ -51,3 +51,24 @@ export function buildCallbackContext(): SessionContext {
},
};
}
function withRuntimeSandboxStop(
sandbox: SandboxSession,
stop: () => Promise<void>,
): RuntimeSandboxSession {
return {
id: sandbox.id,
readBinaryFile: (options) => sandbox.readBinaryFile(options),
readFile: (options) => sandbox.readFile(options),
readTextFile: (options) => sandbox.readTextFile(options),
removePath: (options) => sandbox.removePath(options),
resolvePath: (path) => sandbox.resolvePath(path),
run: (options) => sandbox.run(options),
setNetworkPolicy: (policy) => sandbox.setNetworkPolicy(policy),
spawn: (options) => sandbox.spawn(options),
stop,
writeBinaryFile: (options) => sandbox.writeBinaryFile(options),
writeFile: (options) => sandbox.writeFile(options),
writeTextFile: (options) => sandbox.writeTextFile(options),
};
}
@@ -60,6 +60,7 @@ describe("sandboxProvider", () => {
vi.mocked(ensureSandboxAccess).mockResolvedValue({
captureState: vi.fn().mockResolvedValue({ initialized: false, session: null }),
get: vi.fn().mockResolvedValue(null),
stop: vi.fn().mockResolvedValue(undefined),
});
});
@@ -15,10 +15,10 @@ import type {
* Returns a sandbox session that applies `abortSignal` to every operation.
* Per-call signals are composed with the bound signal.
*/
export function bindSandboxAbortSignal(
session: SandboxSession,
export function bindSandboxAbortSignal<TSession extends SandboxSession>(
session: TSession,
abortSignal: AbortSignal,
): SandboxSession {
): TSession {
const compose = (callSignal: AbortSignal | undefined): AbortSignal =>
AbortSignal.any(callSignal === undefined ? [abortSignal] : [abortSignal, callSignal]);
@@ -42,5 +42,5 @@ export function bindSandboxAbortSignal(
session.writeTextFile({ ...options, abortSignal: compose(options.abortSignal) }),
removePath: (options: SandboxRemovePathOptions) =>
session.removePath({ ...options, abortSignal: compose(options.abortSignal) }),
};
} as TSession;
}
@@ -379,9 +379,9 @@ describe("createDockerSandboxBackend create", () => {
sessionKey: SESSION_KEY,
});
// Server shutdown stops the container; filesystem state survives
// An authored stop releases the container; filesystem state survives
// for the next `create` to restart from.
await handle.shutdown();
await handle.stop();
expect(findCall(calls, (args) => args[0] === "stop")?.args).toEqual([
"stop",
"-t",
@@ -276,6 +276,9 @@ export function createDockerSandboxBackend(
sessionKey: createInput.sessionKey,
};
},
async stop() {
await stopDockerContainerIfRunning(cli, containerName);
},
// Session state lives in the container filesystem, so a stopped
// container restarts with state intact on the next `create`.
async shutdown() {
@@ -219,6 +219,9 @@ export function createJustBashHandle(
sessionKey: sandbox.sessionKey,
};
},
async stop() {
await sandbox.dispose();
},
// The interpreter lives in this process, so stopping it is all the
// shutdown a just-bash sandbox needs.
async shutdown() {
@@ -308,6 +308,7 @@ describe("just-bash sandbox file API", () => {
path: "persisted.txt",
});
await firstHandle.stop();
const state = await firstHandle.captureState();
expect(state.metadata).toEqual({
@@ -178,6 +178,36 @@ describe("createMicrosandboxHandle", () => {
expect(runtimeMocks.createPreparedMicrosandbox).toHaveBeenCalledTimes(2);
});
it("stops the VM and evicts the active-session cache on an authored stop", 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.stop();
expect(vm.stop).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"),
@@ -292,6 +322,7 @@ function createFakeMicrosandboxVm(sessionKey: string) {
};
},
async detach() {},
stop: vi.fn(async () => {}),
shutdown: vi.fn(async () => {}),
async readFileBytes(path: string) {
return files.get(path) ?? null;
@@ -308,6 +308,10 @@ function createHandle(
sessionKey: sandbox.id,
};
},
async stop() {
await sandbox.stop();
onShutdown?.();
},
async shutdown() {
onShutdown?.();
await sandbox.shutdown();
@@ -193,6 +193,28 @@ describe.skipIf(process.platform === "win32")("connectMicrosandbox", () => {
});
describe.skipIf(process.platform === "win32")("MicrosandboxVm", () => {
it("propagates an authored stop failure", async () => {
const sandbox = {
detach: vi.fn(async () => {}),
stop: vi.fn(async () => {
throw new Error("stop failed");
}),
};
const vm = new MicrosandboxVm(
{
module: {} as never,
options: resolveMicrosandboxOptions({ image: MICROSANDBOX_DEFAULT_IMAGE }),
sessionKey: "session-key",
},
sandbox as never,
"sandbox-name",
undefined,
);
await expect(vm.stop()).rejects.toThrow("stop failed");
expect(sandbox.detach).not.toHaveBeenCalled();
});
it("stops the VM before detaching the SDK client on shutdown", async () => {
const sandbox = {
detach: vi.fn(async () => {}),
@@ -152,6 +152,11 @@ export class MicrosandboxVm {
await this.detach();
}
async stop(): Promise<void> {
await this.#sandbox.stop();
await this.detach();
}
async readFileBytes(path: string): Promise<Buffer | null> {
try {
const fs = this.#sandbox.fs();
@@ -1026,6 +1026,20 @@ describe("createVercelSandbox", () => {
expect(sessionSandbox.stop).toHaveBeenCalledTimes(1);
});
it("stops authored compute and keeps the Vercel session handle usable", async () => {
const { handle, sessionSandbox } = await createTestVercelSession();
vi.mocked(sessionSandbox.runCommand).mockResolvedValue(createMockDetachedCommand() as never);
vi.mocked(sessionSandbox.runCommand).mockClear();
await handle.stop();
await handle.session.run({ command: "printf resumed" });
expect(sessionSandbox.stop).toHaveBeenCalledTimes(1);
expect(sessionSandbox.runCommand).toHaveBeenCalledWith(
expect.objectContaining({ args: ["-lc", "printf resumed"], cmd: "bash" }),
);
});
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" });
@@ -1057,6 +1071,13 @@ describe("createVercelSandbox", () => {
expect(sessionSandbox.stop).not.toHaveBeenCalled();
});
it("surfaces an authored session stop failure", async () => {
const { handle, sessionSandbox } = await createTestVercelSession();
sessionSandbox.stop.mockRejectedValueOnce(new Error("provider unreachable"));
await expect(handle.stop()).rejects.toThrow("provider unreachable");
});
it("falls back to creating a new session when the persisted sandbox no longer exists", async () => {
const templateSandbox = createMockSandbox({
name: "template-key",
@@ -460,11 +460,16 @@ function createHandle(
sessionKey,
};
},
// Session sandboxes are persistent, so the SDK resumes a stopped
// sandbox on the next command after reattach.
async shutdown() {
async stop() {
await stopVercelSandbox(sandbox);
},
async shutdown() {
try {
await stopVercelSandbox(sandbox);
} catch {
// Provider-side timeout is the backstop when the sandbox is unreachable.
}
},
};
}
@@ -472,12 +477,7 @@ async function stopVercelSandbox(sandbox: VercelSandbox): Promise<void> {
if (sandbox.status !== "running" && sandbox.status !== "pending") {
return;
}
try {
await sandbox.stop();
} catch {
// Best-effort: an unreachable or already-stopped sandbox must not
// block server shutdown; the provider-side timeout is the backstop.
}
await sandbox.stop();
}
function createVercelNetworkPolicySetter(
@@ -70,6 +70,7 @@ function createBackend(): SandboxBackend {
metadata: {},
sessionKey: input.sessionKey,
}),
stop: vi.fn(async () => {}),
useSessionFn: async () => sandbox.session,
shutdown: async () => {},
session: sandbox.session,
@@ -382,6 +383,17 @@ describe("ensureSandboxAccess", () => {
await shutdownActiveSandboxHandles();
expect(countActiveSandboxHandles()).toBe(0);
});
it("delegates authored stops to the backend handle", async () => {
const backend = createBackend();
const registry = createTestRegistry({}, backend);
const access = await ensure({ registry });
await access.stop();
const handle = await vi.mocked(backend.create).mock.results[0]?.value;
expect(handle?.stop).toHaveBeenCalledTimes(1);
});
});
function createDeferred<T>() {
@@ -180,6 +180,13 @@ export async function ensureSandboxAccess(input: EnsureSandboxAccessInput): Prom
const handle = await getHandle();
return handle?.session ?? null;
},
async stop(): Promise<void> {
const handle = await getHandle();
if (handle === null) {
throw new Error("The sandbox is not available in the current authored runtime context.");
}
await handle.stop();
},
};
}
@@ -77,6 +77,7 @@ function createSessionSandboxHarness() {
sessionKey: input.sessionKey,
}),
session: sandbox.session,
stop: async () => {},
shutdown: async () => {},
useSessionFn: async () => sandbox.session,
};
@@ -52,6 +52,8 @@ export interface MockSandboxInput {
readonly run?: (
options: SandboxRunOptions,
) => Promise<SandboxCommandResult> | SandboxCommandResult;
/** Callback invoked when authored runtime code stops this sandbox. */
readonly stop?: () => Promise<void> | void;
}
/**
@@ -174,7 +176,7 @@ export function mockSandbox(input: MockSandboxInput = {}): MockSandbox {
}
}
const session: SandboxSession = {
const baseSession: SandboxSession = {
id: sandboxId,
resolvePath(path: string): string {
return resolveWorkspacePath(path);
@@ -241,6 +243,7 @@ export function mockSandbox(input: MockSandboxInput = {}): MockSandbox {
fileBytes.set(resolved, Buffer.from(options.content, "utf8"));
},
};
const session = baseSession;
const access: SandboxAccess = {
async captureState(): Promise<SandboxState> {
@@ -252,6 +255,9 @@ export function mockSandbox(input: MockSandboxInput = {}): MockSandbox {
async get(): Promise<SandboxSession> {
return session;
},
async stop(): Promise<void> {
await input.stop?.();
},
};
return {
@@ -1,5 +1,5 @@
import type { SkillHandle } from "#execution/skills/types.js";
import type { SandboxSession } from "#shared/sandbox-session.js";
import type { RuntimeSandboxSession } from "#shared/sandbox-session.js";
import type { SessionAuth, SessionParent, SessionTurn } from "#context/keys.js";
export type { SessionAuth, SessionParent, SessionTurn };
@@ -27,7 +27,7 @@ export interface SessionContext {
* Resolves the session's sandbox. Throws when no sandbox is available
* in the current authored runtime context.
*/
getSandbox(): Promise<SandboxSession>;
getSandbox(): Promise<RuntimeSandboxSession>;
/**
* Returns a {@link SkillHandle} for the named authored skill.
@@ -13,6 +13,7 @@ export type {
SandboxReadTextFileOptions,
SandboxRunOptions,
SandboxSession,
RuntimeSandboxSession,
SandboxSpawnOptions,
SandboxWriteBinaryFileOptions,
SandboxWriteFileOptions,
+1
View File
@@ -15,6 +15,7 @@ export {
type SandboxRevalidationKeyFn,
type SandboxRunOptions,
type SandboxSession,
type RuntimeSandboxSession,
type SandboxSpawnOptions,
type SandboxSessionContext,
type SandboxSessionUseFn,
@@ -71,6 +71,7 @@ describe("load_skill executor", () => {
const access = {
captureState: vi.fn(async () => ({ initialized: false, session: null })),
get,
stop: vi.fn(async () => {}),
};
const ctx = new ContextContainer();
ctx.set(SandboxKey, access);
+1
View File
@@ -32,4 +32,5 @@ export interface SandboxState {
export interface SandboxAccess {
captureState(): Promise<SandboxState>;
get(): Promise<SandboxSession | null>;
stop(): Promise<void>;
}
@@ -12,6 +12,12 @@ export interface SandboxBackendHandle<SO = Record<string, never>> {
readonly session: SandboxSession;
readonly useSessionFn: SandboxSessionUseFn<SO>;
captureState(): Promise<SandboxBackendSessionState>;
/**
* Stops the underlying compute at an authored runtime boundary while
* preserving any backend state needed to reopen the durable session.
* Provider errors must reject this call.
*/
stop(): Promise<void>;
/**
* Stops the underlying compute because the eve server is shutting
* down; nothing may be left running afterwards. The session must
@@ -143,6 +143,22 @@ export interface SandboxSession extends Pick<
removePath(options: SandboxRemovePathOptions): Promise<void>;
}
/**
* Sandbox session exposed to authored runtime callbacks through
* `ctx.getSandbox()`.
*
* Unlike the I/O-only session used during sandbox initialization, this handle
* always exposes a provider-backed `stop()` method.
*/
export interface RuntimeSandboxSession extends SandboxSession {
/**
* Stops the backing sandbox compute while preserving the durable session.
* A later runtime callback reopens the session through its configured
* backend. Providers may also support resuming the same handle.
*/
stop(): Promise<void>;
}
/**
* Internal sandbox session, used to construct the public {@link SandboxSession}.
*
@@ -43,6 +43,8 @@ describe("defineBashTool", () => {
};
},
async stop() {},
async get() {
return {
id: "test-bash-sandbox",
@@ -100,6 +102,8 @@ describe("defineBashTool", () => {
};
},
async stop() {},
async get() {
return null;
},
@@ -133,6 +137,8 @@ describe("defineBashTool", () => {
return { initialized: false, session: null };
},
async stop() {},
async get() {
return {
id: "test-bash-sandbox-large",
@@ -16,6 +16,8 @@ function createFakeAccess(
return { initialized: false, session: null };
},
async stop() {},
async get() {
const callHandler = handler;
if (callHandler === null) return null;
@@ -104,6 +106,8 @@ describe("defineGlobTool", () => {
return { initialized: false, session: null };
},
async stop() {},
async get() {
return null;
},
@@ -16,6 +16,8 @@ function createFakeAccess(
return { initialized: false, session: null };
},
async stop() {},
async get() {
const callHandler = handler;
if (callHandler === null) return null;
@@ -104,6 +106,8 @@ describe("defineGrepTool", () => {
return { initialized: false, session: null };
},
async stop() {},
async get() {
return null;
},
@@ -15,6 +15,8 @@ function createFakeAccess(files: Record<string, string>): SandboxAccess {
return { initialized: false, session: null };
},
async stop() {},
async get() {
return {
id: "test-read-file-sandbox",
@@ -100,6 +102,8 @@ describe("defineReadFileTool", () => {
return { initialized: false, session: null };
},
async stop() {},
async get() {
return null;
},
@@ -18,6 +18,8 @@ function createFakeAccess(files: Record<string, string>): SandboxAccess {
return { initialized: false, session: null };
},
async stop() {},
async get() {
return {
id: "test-write-file-sandbox",
@@ -110,6 +112,8 @@ describe("defineWriteFileTool", () => {
return { initialized: false, session: null };
},
async stop() {},
async get() {
return null;
},
@@ -56,6 +56,7 @@ function createFakeAccess(files: Record<string, string>): {
async captureState() {
return { initialized: false, session: null };
},
async stop() {},
async get() {
return session;
},
+2
View File
@@ -39,6 +39,8 @@ function createFakeAccess(
return { initialized: false, session: null };
},
async stop() {},
async get() {
return {
// Use a fresh id per fake session so the ripgrep-probe cache
+2
View File
@@ -39,6 +39,8 @@ function createFakeAccess(
return { initialized: false, session: null };
},
async stop() {},
async get() {
return {
// Use a fresh id per fake session so the ripgrep-probe cache
+2
View File
@@ -22,6 +22,8 @@ function createFakeAccess(files: Record<string, string | null>): SandboxAccess {
return { initialized: false, session: null };
},
async stop() {},
async get() {
return {
id: "test-read-file-sandbox",
@@ -65,6 +65,7 @@ function createFakeAccess(files: Record<string, string>): {
async captureState() {
return { initialized: false, session: null };
},
async stop() {},
async get() {
return session;
},
+77
View File
@@ -0,0 +1,77 @@
---
issue: https://github.com/vercel/eve/issues/1709
status: implemented
last_updated: "2026-08-07"
---
# Authored sandbox stop
## Summary
Authored runtime callbacks can access their session sandbox through
`ctx.getSandbox()`, but they cannot release its backing compute. This prevents
hooks from stopping compute when a turn or session reaches an
application-defined boundary and keeps authored tools from satisfying sandbox
consumers that require an explicit stop operation.
Return a `RuntimeSandboxSession` from `ctx.getSandbox()`. It extends the
existing `SandboxSession` I/O surface with `stop()`, an eve-owned operation
implemented by every sandbox backend through its native lifecycle primitive.
## Authoring API
```ts
import { defineHook } from "eve/hooks";
export default defineHook({
events: {
async "turn.completed"(_event, ctx) {
const sandbox = await ctx.getSandbox();
await sandbox.stop();
},
},
});
```
`RuntimeSandboxSession` is exported from `eve/sandbox`. Sandbox lifecycle
`bootstrap({ use })` and `onSession({ use })` keep returning `SandboxSession`:
template and session initialization do not own runtime teardown.
## Semantics
```mermaid
flowchart LR
Hook["Authored callback"] --> Get["ctx.getSandbox()"]
Get --> Stop["sandbox.stop()"]
Stop --> Native["Provider stop"]
Native --> Parked["Durable sandbox stopped"]
Parked --> Resume["Later callback reopens"]
```
- Each built-in backend maps `stop()` to its native lifecycle operation:
Vercel stops its persistent sandbox, Docker stops its session container,
microsandbox stops and detaches its VM, and just-bash disposes its interpreter.
- A resolved stop preserves the durable session state. A later callback opens
the same session through the normal backend `create()` path. Vercel also
automatically resumes the same handle on later I/O, matching its inactivity
timeout behavior.
- eve does not create stop-specific reconnect state. Ordinary step persistence
continues recording the backend's existing reconnect metadata.
- A provider stop failure rejects the authored call. Server-shutdown cleanup
remains a separate best-effort lifecycle path.
- Custom `SandboxBackend` handles implement `stop()` alongside `shutdown()` so
the runtime session contract is supported by every provider.
## Scope
This change does not destroy sandbox state, terminate the durable eve session,
or expose a native provider handle. Ports and public port URLs from the broader
issue remain separate work.
## Validation
- Provider coverage proves each built-in backend delegates authored stops to
its native lifecycle operation and authored stop failures propagate.
- Integration coverage proves `ctx.getSandbox()` exposes `stop()`.
- The sandbox fixture stops from an authored hook, then reads a persisted file
after the configured backend reopens it on the next turn.