mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
feat(eve): allow authored sandbox stops (#1801)
Signed-off-by: Casey Gowrie <ctgowrie@gmail.com>
This commit is contained in:
@@ -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
@@ -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`:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user