diff --git a/.changeset/quiet-sandboxes-stop.md b/.changeset/quiet-sandboxes-stop.md new file mode 100644 index 0000000000..4ea51ad07b --- /dev/null +++ b/.changeset/quiet-sandboxes-stop.md @@ -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()`. diff --git a/docs/guides/hooks.md b/docs/guides/hooks.md index d99a54d185..a01efb6cd1 100644 --- a/docs/guides/hooks.md +++ b/docs/guides/hooks.md @@ -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`: diff --git a/docs/guides/session-context.md b/docs/guides/session-context.md index 044c820d75..af88515e58 100644 --- a/docs/guides/session-context.md +++ b/docs/guides/session-context.md @@ -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. diff --git a/docs/reference/typescript-api.md b/docs/reference/typescript-api.md index d136a0dde4..2d2ee26aaf 100644 --- a/docs/reference/typescript-api.md +++ b/docs/reference/typescript-api.md @@ -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` | diff --git a/docs/sandbox.mdx b/docs/sandbox.mdx index 0a1a96bf6c..d17ffa755b 100644 --- a/docs/sandbox.mdx +++ b/docs/sandbox.mdx @@ -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 diff --git a/e2e/fixtures/agent-tools-sandbox/agent/hooks/stop-sandbox.ts b/e2e/fixtures/agent-tools-sandbox/agent/hooks/stop-sandbox.ts new file mode 100644 index 0000000000..be1e9891e0 --- /dev/null +++ b/e2e/fixtures/agent-tools-sandbox/agent/hooks/stop-sandbox.ts @@ -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(); + }, + }, +}); diff --git a/e2e/fixtures/agent-tools-sandbox/evals/sandbox/hook-stop.eval.ts b/e2e/fixtures/agent-tools-sandbox/evals/sandbox/hook-stop.eval.ts new file mode 100644 index 0000000000..13fd72cacd --- /dev/null +++ b/e2e/fixtures/agent-tools-sandbox/evals/sandbox/hook-stop.eval.ts @@ -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); + }, +}); diff --git a/packages/eve/extension-contracts/compatibility/connection/v3.ts b/packages/eve/extension-contracts/compatibility/connection/v3.ts new file mode 100644 index 0000000000..def585931a --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/connection/v3.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/v11.ts b/packages/eve/extension-contracts/compatibility/dynamicTool/v11.ts new file mode 100644 index 0000000000..76b54cd925 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/dynamicTool/v11.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/v8.ts b/packages/eve/extension-contracts/compatibility/hook/v8.ts new file mode 100644 index 0000000000..38ecc7e065 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/hook/v8.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/v2.ts b/packages/eve/extension-contracts/compatibility/state/v2.ts new file mode 100644 index 0000000000..ce89177800 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/state/v2.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/v10.ts b/packages/eve/extension-contracts/compatibility/tool/v10.ts new file mode 100644 index 0000000000..eae0f5b7e1 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/tool/v10.ts @@ -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 }; + }, +}); diff --git a/packages/eve/extension-contracts/reports/connection/v4.json b/packages/eve/extension-contracts/reports/connection/v4.json new file mode 100644 index 0000000000..11d95a9d88 --- /dev/null +++ b/packages/eve/extension-contracts/reports/connection/v4.json @@ -0,0 +1,15 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "connection", + "epoch": 4, + "sha256": "b2c48c160b9c57b8661c661398a2931c30a08e5f43c41acf9ccc678922f550fa", + "exports": [ + "ConnectionAuthorizationFailedError", + "ConnectionAuthorizationRequiredError", + "defineInteractiveAuthorization", + "defineMcpClientConnection", + "defineOpenAPIConnection", + "isConnectionAuthorizationFailedError", + "isConnectionAuthorizationRequiredError" + ] +} diff --git a/packages/eve/extension-contracts/reports/dynamicTool/v12.json b/packages/eve/extension-contracts/reports/dynamicTool/v12.json new file mode 100644 index 0000000000..3ef63d9a14 --- /dev/null +++ b/packages/eve/extension-contracts/reports/dynamicTool/v12.json @@ -0,0 +1,13 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "dynamicTool", + "epoch": 12, + "sha256": "d0113501077e6a36820fc997121992d5c1039410c821752623d66fa5dd8f0812", + "exports": [ + "DynamicToolEntry", + "DynamicToolEvents", + "DynamicToolResult", + "DynamicToolSet", + "defineDynamic" + ] +} diff --git a/packages/eve/extension-contracts/reports/hook/v9.json b/packages/eve/extension-contracts/reports/hook/v9.json new file mode 100644 index 0000000000..a05c7783bb --- /dev/null +++ b/packages/eve/extension-contracts/reports/hook/v9.json @@ -0,0 +1,7 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "hook", + "epoch": 9, + "sha256": "afb6444059c947d8d2a864b46a7f3c6bccf18343e4718ffefeeda1b9e60f9195", + "exports": ["defineHook"] +} diff --git a/packages/eve/extension-contracts/reports/state/v3.json b/packages/eve/extension-contracts/reports/state/v3.json new file mode 100644 index 0000000000..fae24531ed --- /dev/null +++ b/packages/eve/extension-contracts/reports/state/v3.json @@ -0,0 +1,15 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "state", + "epoch": 3, + "sha256": "09122994045bc69eef9e795c372a37b41afd0e9094e0dca68419321bacedabaa", + "exports": [ + "Session", + "SessionAuth", + "SessionAuthContext", + "SessionContext", + "SessionParent", + "SessionTurn", + "defineState" + ] +} diff --git a/packages/eve/extension-contracts/reports/tool/v11.json b/packages/eve/extension-contracts/reports/tool/v11.json new file mode 100644 index 0000000000..de834a2dd8 --- /dev/null +++ b/packages/eve/extension-contracts/reports/tool/v11.json @@ -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" + ] +} diff --git a/packages/eve/src/compiler/extension-compatibility.ts b/packages/eve/src/compiler/extension-compatibility.ts index 910f46948e..2dac0730de 100644 --- a/packages/eve/src/compiler/extension-compatibility.ts +++ b/packages/eve/src/compiler/extension-compatibility.ts @@ -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; /** 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 1f9b321343..518a64f8d8 100644 --- a/packages/eve/src/context/accessors.integration.test.ts +++ b/packages/eve/src/context/accessors.integration.test.ts @@ -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", () => { diff --git a/packages/eve/src/context/build-callback-context.ts b/packages/eve/src/context/build-callback-context.ts index 74defda285..463e5ca4a0 100644 --- a/packages/eve/src/context/build-callback-context.ts +++ b/packages/eve/src/context/build-callback-context.ts @@ -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 { + getSandbox(): Promise { 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, +): 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), + }; +} diff --git a/packages/eve/src/context/providers/sandbox.test.ts b/packages/eve/src/context/providers/sandbox.test.ts index 79c6bbd7e9..ca3e7acf70 100644 --- a/packages/eve/src/context/providers/sandbox.test.ts +++ b/packages/eve/src/context/providers/sandbox.test.ts @@ -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), }); }); diff --git a/packages/eve/src/execution/sandbox/abort-bound-session.ts b/packages/eve/src/execution/sandbox/abort-bound-session.ts index 95af8fb373..8c32c73eb7 100644 --- a/packages/eve/src/execution/sandbox/abort-bound-session.ts +++ b/packages/eve/src/execution/sandbox/abort-bound-session.ts @@ -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( + 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; } diff --git a/packages/eve/src/execution/sandbox/bindings/docker.integration.test.ts b/packages/eve/src/execution/sandbox/bindings/docker.integration.test.ts index c53a3aadb8..1f5486ae8f 100644 --- a/packages/eve/src/execution/sandbox/bindings/docker.integration.test.ts +++ b/packages/eve/src/execution/sandbox/bindings/docker.integration.test.ts @@ -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", diff --git a/packages/eve/src/execution/sandbox/bindings/docker.ts b/packages/eve/src/execution/sandbox/bindings/docker.ts index 107c6fa501..d763595048 100644 --- a/packages/eve/src/execution/sandbox/bindings/docker.ts +++ b/packages/eve/src/execution/sandbox/bindings/docker.ts @@ -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() { 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 fc9765c979..d861a50eb4 100644 --- a/packages/eve/src/execution/sandbox/bindings/just-bash-runtime.ts +++ b/packages/eve/src/execution/sandbox/bindings/just-bash-runtime.ts @@ -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() { diff --git a/packages/eve/src/execution/sandbox/bindings/just-bash.scenario.test.ts b/packages/eve/src/execution/sandbox/bindings/just-bash.scenario.test.ts index e36ba53b2c..573996220a 100644 --- a/packages/eve/src/execution/sandbox/bindings/just-bash.scenario.test.ts +++ b/packages/eve/src/execution/sandbox/bindings/just-bash.scenario.test.ts @@ -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({ 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 d79cdcdd84..82923d712d 100644 --- a/packages/eve/src/execution/sandbox/bindings/microsandbox-lifecycle.test.ts +++ b/packages/eve/src/execution/sandbox/bindings/microsandbox-lifecycle.test.ts @@ -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; diff --git a/packages/eve/src/execution/sandbox/bindings/microsandbox-lifecycle.ts b/packages/eve/src/execution/sandbox/bindings/microsandbox-lifecycle.ts index 01a35aeb8e..7095b22259 100644 --- a/packages/eve/src/execution/sandbox/bindings/microsandbox-lifecycle.ts +++ b/packages/eve/src/execution/sandbox/bindings/microsandbox-lifecycle.ts @@ -308,6 +308,10 @@ function createHandle( sessionKey: sandbox.id, }; }, + async stop() { + await sandbox.stop(); + onShutdown?.(); + }, async shutdown() { onShutdown?.(); await sandbox.shutdown(); diff --git a/packages/eve/src/execution/sandbox/bindings/microsandbox-runtime.test.ts b/packages/eve/src/execution/sandbox/bindings/microsandbox-runtime.test.ts index 6c4df4171f..cb72e887d5 100644 --- a/packages/eve/src/execution/sandbox/bindings/microsandbox-runtime.test.ts +++ b/packages/eve/src/execution/sandbox/bindings/microsandbox-runtime.test.ts @@ -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 () => {}), diff --git a/packages/eve/src/execution/sandbox/bindings/microsandbox-runtime.ts b/packages/eve/src/execution/sandbox/bindings/microsandbox-runtime.ts index 4894fa8d52..f0bcfcc3b9 100644 --- a/packages/eve/src/execution/sandbox/bindings/microsandbox-runtime.ts +++ b/packages/eve/src/execution/sandbox/bindings/microsandbox-runtime.ts @@ -152,6 +152,11 @@ export class MicrosandboxVm { await this.detach(); } + async stop(): Promise { + await this.#sandbox.stop(); + await this.detach(); + } + async readFileBytes(path: string): Promise { try { const fs = this.#sandbox.fs(); diff --git a/packages/eve/src/execution/sandbox/bindings/vercel.test.ts b/packages/eve/src/execution/sandbox/bindings/vercel.test.ts index 91a9e548a5..7986221c43 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel.test.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel.test.ts @@ -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", diff --git a/packages/eve/src/execution/sandbox/bindings/vercel.ts b/packages/eve/src/execution/sandbox/bindings/vercel.ts index 7bbebccbd9..95b838375d 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel.ts @@ -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 { 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( diff --git a/packages/eve/src/execution/sandbox/ensure.test.ts b/packages/eve/src/execution/sandbox/ensure.test.ts index e5b0c8c94c..28a3c181e4 100644 --- a/packages/eve/src/execution/sandbox/ensure.test.ts +++ b/packages/eve/src/execution/sandbox/ensure.test.ts @@ -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() { diff --git a/packages/eve/src/execution/sandbox/ensure.ts b/packages/eve/src/execution/sandbox/ensure.ts index 3659a74f69..db42c96a36 100644 --- a/packages/eve/src/execution/sandbox/ensure.ts +++ b/packages/eve/src/execution/sandbox/ensure.ts @@ -180,6 +180,13 @@ export async function ensureSandboxAccess(input: EnsureSandboxAccessInput): Prom const handle = await getHandle(); return handle?.session ?? null; }, + async stop(): Promise { + const handle = await getHandle(); + if (handle === null) { + throw new Error("The sandbox is not available in the current authored runtime context."); + } + await handle.stop(); + }, }; } diff --git a/packages/eve/src/execution/session-reset.integration.test.ts b/packages/eve/src/execution/session-reset.integration.test.ts index 4a99f0c02a..6f8371f80f 100644 --- a/packages/eve/src/execution/session-reset.integration.test.ts +++ b/packages/eve/src/execution/session-reset.integration.test.ts @@ -77,6 +77,7 @@ function createSessionSandboxHarness() { sessionKey: input.sessionKey, }), session: sandbox.session, + stop: async () => {}, shutdown: async () => {}, useSessionFn: async () => sandbox.session, }; diff --git a/packages/eve/src/internal/testing/mocks/mock-sandbox.ts b/packages/eve/src/internal/testing/mocks/mock-sandbox.ts index aba6060a1d..2f6ff4c9b0 100644 --- a/packages/eve/src/internal/testing/mocks/mock-sandbox.ts +++ b/packages/eve/src/internal/testing/mocks/mock-sandbox.ts @@ -52,6 +52,8 @@ export interface MockSandboxInput { readonly run?: ( options: SandboxRunOptions, ) => Promise | SandboxCommandResult; + /** Callback invoked when authored runtime code stops this sandbox. */ + readonly stop?: () => Promise | 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 { @@ -252,6 +255,9 @@ export function mockSandbox(input: MockSandboxInput = {}): MockSandbox { async get(): Promise { return session; }, + async stop(): Promise { + await input.stop?.(); + }, }; return { diff --git a/packages/eve/src/public/definitions/callback-context.ts b/packages/eve/src/public/definitions/callback-context.ts index 21137a3e35..48a4ac1567 100644 --- a/packages/eve/src/public/definitions/callback-context.ts +++ b/packages/eve/src/public/definitions/callback-context.ts @@ -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; + getSandbox(): Promise; /** * Returns a {@link SkillHandle} for the named authored skill. diff --git a/packages/eve/src/public/definitions/sandbox.ts b/packages/eve/src/public/definitions/sandbox.ts index 66897acea0..07aa3b890f 100644 --- a/packages/eve/src/public/definitions/sandbox.ts +++ b/packages/eve/src/public/definitions/sandbox.ts @@ -13,6 +13,7 @@ export type { SandboxReadTextFileOptions, SandboxRunOptions, SandboxSession, + RuntimeSandboxSession, SandboxSpawnOptions, SandboxWriteBinaryFileOptions, SandboxWriteFileOptions, diff --git a/packages/eve/src/public/sandbox/index.ts b/packages/eve/src/public/sandbox/index.ts index 10b81b766d..febea59cb4 100644 --- a/packages/eve/src/public/sandbox/index.ts +++ b/packages/eve/src/public/sandbox/index.ts @@ -15,6 +15,7 @@ export { type SandboxRevalidationKeyFn, type SandboxRunOptions, type SandboxSession, + type RuntimeSandboxSession, type SandboxSpawnOptions, type SandboxSessionContext, type SandboxSessionUseFn, diff --git a/packages/eve/src/runtime/framework-tools/skill.test.ts b/packages/eve/src/runtime/framework-tools/skill.test.ts index 9221c23452..f6809e87ea 100644 --- a/packages/eve/src/runtime/framework-tools/skill.test.ts +++ b/packages/eve/src/runtime/framework-tools/skill.test.ts @@ -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); diff --git a/packages/eve/src/sandbox/state.ts b/packages/eve/src/sandbox/state.ts index 5b9fb3e4f4..3d64152fa0 100644 --- a/packages/eve/src/sandbox/state.ts +++ b/packages/eve/src/sandbox/state.ts @@ -32,4 +32,5 @@ export interface SandboxState { export interface SandboxAccess { captureState(): Promise; get(): Promise; + stop(): Promise; } diff --git a/packages/eve/src/shared/sandbox-backend.ts b/packages/eve/src/shared/sandbox-backend.ts index 2898e58ef4..a727d86f18 100644 --- a/packages/eve/src/shared/sandbox-backend.ts +++ b/packages/eve/src/shared/sandbox-backend.ts @@ -12,6 +12,12 @@ export interface SandboxBackendHandle> { readonly session: SandboxSession; readonly useSessionFn: SandboxSessionUseFn; captureState(): Promise; + /** + * 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; /** * Stops the underlying compute because the eve server is shutting * down; nothing may be left running afterwards. The session must diff --git a/packages/eve/src/shared/sandbox-session.ts b/packages/eve/src/shared/sandbox-session.ts index bd5a71c97d..6194ddc928 100644 --- a/packages/eve/src/shared/sandbox-session.ts +++ b/packages/eve/src/shared/sandbox-session.ts @@ -143,6 +143,22 @@ export interface SandboxSession extends Pick< removePath(options: SandboxRemovePathOptions): Promise; } +/** + * 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; +} + /** * Internal sandbox session, used to construct the public {@link SandboxSession}. * diff --git a/packages/eve/test/define-bash-tool.test.ts b/packages/eve/test/define-bash-tool.test.ts index 2429251f7a..a605dd9c93 100644 --- a/packages/eve/test/define-bash-tool.test.ts +++ b/packages/eve/test/define-bash-tool.test.ts @@ -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", diff --git a/packages/eve/test/define-glob-tool.test.ts b/packages/eve/test/define-glob-tool.test.ts index 6e106c8091..eb04629df8 100644 --- a/packages/eve/test/define-glob-tool.test.ts +++ b/packages/eve/test/define-glob-tool.test.ts @@ -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; }, diff --git a/packages/eve/test/define-grep-tool.test.ts b/packages/eve/test/define-grep-tool.test.ts index 3e0c05c883..93ce2eda58 100644 --- a/packages/eve/test/define-grep-tool.test.ts +++ b/packages/eve/test/define-grep-tool.test.ts @@ -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; }, diff --git a/packages/eve/test/define-read-file-tool.test.ts b/packages/eve/test/define-read-file-tool.test.ts index 811110b40b..ab442ced4a 100644 --- a/packages/eve/test/define-read-file-tool.test.ts +++ b/packages/eve/test/define-read-file-tool.test.ts @@ -15,6 +15,8 @@ function createFakeAccess(files: Record): 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; }, diff --git a/packages/eve/test/define-write-file-tool.test.ts b/packages/eve/test/define-write-file-tool.test.ts index dbc3944459..4f69b7b078 100644 --- a/packages/eve/test/define-write-file-tool.test.ts +++ b/packages/eve/test/define-write-file-tool.test.ts @@ -18,6 +18,8 @@ function createFakeAccess(files: Record): 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; }, diff --git a/packages/eve/test/file-tool-compaction.test.ts b/packages/eve/test/file-tool-compaction.test.ts index bca03b30cf..9f50dbbe49 100644 --- a/packages/eve/test/file-tool-compaction.test.ts +++ b/packages/eve/test/file-tool-compaction.test.ts @@ -56,6 +56,7 @@ function createFakeAccess(files: Record): { async captureState() { return { initialized: false, session: null }; }, + async stop() {}, async get() { return session; }, diff --git a/packages/eve/test/glob-tool.test.ts b/packages/eve/test/glob-tool.test.ts index aadbeb4969..cc3eb6e210 100644 --- a/packages/eve/test/glob-tool.test.ts +++ b/packages/eve/test/glob-tool.test.ts @@ -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 diff --git a/packages/eve/test/grep-tool.test.ts b/packages/eve/test/grep-tool.test.ts index bdf0680af9..f1f08f0ade 100644 --- a/packages/eve/test/grep-tool.test.ts +++ b/packages/eve/test/grep-tool.test.ts @@ -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 diff --git a/packages/eve/test/read-file-tool.test.ts b/packages/eve/test/read-file-tool.test.ts index 80ae51b651..57175cb730 100644 --- a/packages/eve/test/read-file-tool.test.ts +++ b/packages/eve/test/read-file-tool.test.ts @@ -22,6 +22,8 @@ function createFakeAccess(files: Record): SandboxAccess { return { initialized: false, session: null }; }, + async stop() {}, + async get() { return { id: "test-read-file-sandbox", diff --git a/packages/eve/test/write-file-tool.test.ts b/packages/eve/test/write-file-tool.test.ts index fa23912820..d818957bb3 100644 --- a/packages/eve/test/write-file-tool.test.ts +++ b/packages/eve/test/write-file-tool.test.ts @@ -65,6 +65,7 @@ function createFakeAccess(files: Record): { async captureState() { return { initialized: false, session: null }; }, + async stop() {}, async get() { return session; }, diff --git a/research/authored-sandbox-stop.md b/research/authored-sandbox-stop.md new file mode 100644 index 0000000000..239b12905c --- /dev/null +++ b/research/authored-sandbox-stop.md @@ -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.