@evelandhq/sandbox-bwrap 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # @evelandhq/sandbox-bwrap
2
2
 
3
- A [bubblewrap](https://github.com/containers/bubblewrap)-based `SandboxBackend` for
4
- [eve](https://www.npmjs.com/package/eve) agents. It gives agent-executed code a real
3
+ A [bubblewrap](https://github.com/containers/bubblewrap)-based sandbox for
4
+ [eve](https://www.npmjs.com/package/eve) agents: a sandbox provider for eve 0.64 and later,
5
+ and a `SandboxBackend` for eve 0.62 and 0.63. It gives agent-executed code a real
5
6
  Linux sandbox — actual binaries, isolated filesystem, coarse network control — without
6
7
  requiring a Docker daemon or KVM.
7
8
 
@@ -16,7 +17,8 @@ namespaces.
16
17
 
17
18
  ## Usage
18
19
 
19
- **Deployed on eveland:** you do nothing. eveland's Docker and systemd runtimes generate
20
+ **Deployed on eveland:** you do nothing. (What follows describes the integration for eve
21
+ 0.63 and earlier.) eveland's Docker and systemd runtimes generate
20
22
  the sandbox module into the release directory at build time — `agent/sandbox.js` for a flat
21
23
  agent, or `agent/sandbox/sandbox.js` when a sandbox folder exists, recursively for every
22
24
  subagent — and vendors this package's built output beside it, so agent projects never declare
@@ -35,7 +37,47 @@ the eveland build pipeline, so it falls back to eve's default backend chain (usu
35
37
  build log looks like and what happens when the sandbox does not work on the host.
36
38
 
37
39
  **Standalone use of this package** (outside eveland, or in any project that manages its
38
- own `agent/sandbox.ts`) still works the manual way:
40
+ own `agent/sandbox.ts`) depends on which sandbox API your eve has.
41
+
42
+ ### eve 0.64 and later: `BwrapSandbox`
43
+
44
+ eve 0.64 replaced sandbox backends with providers. Import the provider from the
45
+ `/provider` entry point and export its environment:
46
+
47
+ ```ts
48
+ // agent/sandbox.ts
49
+ import { defineSandbox } from "eve/sandbox";
50
+ import { BwrapSandbox } from "@evelandhq/sandbox-bwrap/provider";
51
+
52
+ export const environment = BwrapSandbox.environment({
53
+ // Optional: setup every new sandbox inherits. Runs once, during `eve build`.
54
+ prepare: async (sandbox) => {
55
+ const result = await sandbox.run({ command: "pip install --user requests" });
56
+ if (result.exitCode !== 0) throw new Error(result.stderr);
57
+ },
58
+ });
59
+
60
+ export default defineSandbox(() => environment.open());
61
+ ```
62
+
63
+ - `eve build` prepares the template: it writes the workspace and skill trees, then runs
64
+ `prepare` inside bwrap, so a build that runs `prepare` needs bwrap on the build host. The
65
+ template lives under `.eve/sandbox-cache/bwrap/templates` in the built app, and eve records
66
+ its absolute path. Serve the build from where it was built, or rebuild on the deploy host;
67
+ a missing template fails the first sandbox access with a rebuild message.
68
+ - eve records which provider prepared each sandbox and refuses to open it with a different
69
+ one. Choose the provider from something that is the same at build and run time, not from
70
+ `isBwrapAvailable()` on hosts that differ.
71
+ - `environment.open({ networkPolicy: "deny-all" })` sets a session's initial network policy.
72
+ It is recorded in the session state and applied again after a restart.
73
+ - eve resumes a session from its recorded state alone. The template is not needed, so with a
74
+ shared `cacheDir` a session keeps its workspace when it moves to a newer deployment.
75
+ - The `/provider` entry point imports `eve/sandbox/provider`, which exists only from eve
76
+ 0.64 on. The package root imports nothing from eve at runtime and also exports the plain
77
+ provider definition, `createBwrapSandboxProviderDefinition()`, for callers that call eve's
78
+ `defineSandboxProvider` themselves.
79
+
80
+ ### eve 0.62 and 0.63: `bwrap()`
39
81
 
40
82
  ```ts
41
83
  // agent/sandbox.ts
@@ -50,20 +92,23 @@ export default defineSandbox({
50
92
 
51
93
  ### eve version requirement
52
94
 
53
- This package requires `eve` `>=0.27.0 <1.0.0`.
95
+ This package requires `eve` `>=0.62.0 <1.0.0`.
54
96
 
55
97
  The range is deliberately wide. eve's 0.x releases use caret-incompatible minor bumps,
56
98
  so a package that pins a narrow window has to republish for every eve minor — which is
57
99
  churn for consumers, not safety, when the surface actually consumed is one small
58
- interface (`SandboxBackend` from `eve/sandbox`) that changes rarely. Rather than
100
+ interface that changes rarely. The range spans two such interfaces: `SandboxBackend` for
101
+ eve 0.62 and 0.63, and the sandbox provider contract from eve 0.64 on. Rather than
59
102
  re-declaring the window, CI keeps the claim honest from both ends:
60
103
  `src/eve-compatibility.test.ts` typechecks the backend against the range's exact floor
61
- (0.27.13) and the newest verified release on every run, and a scheduled workflow re-runs
62
- the suite against `eve@latest` so a breaking eve minor shows up as a red build here
63
- instead of a bug report from your deployment. That is not theoretical: eve 0.32.0 added a
64
- required `stop()` to the backend handle. This package implements it; 0.1.0 does not, and
65
- pairing that release with eve `>=0.32.0` resolves cleanly and then fails at runtime the
66
- first time authored code calls `ctx.getSandbox().stop()`.
104
+ (0.62.0; 0.63.0, the last eve that calls backends, ships identical sandbox types), and
105
+ typechecks the provider against the newest verified release; a scheduled workflow re-runs the suite against
106
+ `eve@latest` so a breaking eve minor shows up as a red build here instead of a bug report
107
+ from your deployment. That is not theoretical: eve 0.32.0 added a required `stop()` to the
108
+ backend handle. This package implements it; 0.1.0 does not, and pairing that release with
109
+ eve `>=0.32.0` resolves cleanly and then fails at runtime the first time authored code calls
110
+ `ctx.getSandbox().stop()`. Likewise, releases up to 0.3.0 import a value that eve 0.64
111
+ removed, so they cannot load at all on eve 0.64 and later.
67
112
 
68
113
  The backend implements both handle lifecycle methods by killing every process the session
69
114
  has spawned that has not yet exited: `shutdown()`, which eve calls at server teardown and
@@ -74,30 +119,55 @@ because the processes are the compute. Neither method touches the session's work
74
119
  directory — it is durable state. Cleanup closes that compute generation to new commands;
75
120
  the next backend `create()` opens a fresh generation over the same workspace. Repeated
76
121
  `create()` calls through one backend instance share the live generation, so one handle
77
- cannot race a new spawn past another handle's cleanup barrier.
122
+ cannot race a new spawn past another handle's cleanup barrier. The provider's
123
+ `onSessionStop()` and `onRuntimeShutdown()` do the same, and its `resume()` shares the live
124
+ generation the same way.
78
125
 
79
126
  ### Options
80
127
 
81
- | Option | Default | Meaning |
82
- | ------------------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
83
- | `env` | `{}` | Environment variables set for every sandboxed command. |
84
- | `networkPolicy` | `"allow-all"` | `"allow-all"` shares the host network; `"deny-all"` runs each command with no network (`--unshare-net`). `setNetworkPolicy` can switch between the two at run time; granular domain policies are rejected (use the Vercel backend for those). |
85
- | `hidePaths` | `[]` | Extra host paths hidden from the sandbox (each covered by an empty tmpfs). |
86
- | `bwrapPath` | `"bwrap"` | bwrap executable to invoke. |
87
- | `cacheDir` | `<appRoot>/.eve/sandbox-cache/bwrap` | Absolute directory holding templates and durable session workspaces. Pin this outside the release directory so a redeploy does not discard durable session state: since eve 0.22.0, eve keys session sandboxes per durable session, not per deployment, so an `appRoot`-derived default would silently destroy every session's `/workspace` on the next redeploy. The generated eveland module always sets this from `EVELAND_SANDBOX_CACHE_DIR`. |
88
- | `templateRevision` | `null` | Optional immutable release identity included in the template cache key but not the session path. Change it when seed files change so new Sessions use a fresh template without overwriting durable workspaces. Eveland sets it from its internal `EVELAND_SANDBOX_TEMPLATE_REVISION`. |
89
- | `runTimeoutMs` | `600000` | Hard wall-clock limit for one `run()` command. Timeout aborts the command and kills its complete bwrap process group. Set `null` to disable it. The limit deliberately does not apply to `spawn()`, which is the API for long-running processes. |
90
- | `maxConcurrentProcesses` | `64` | Maximum live `run()`/`spawn()` commands admitted in one compute generation. A live `spawn()` counts until it actually exits, even when its caller never waits. Set `null` to disable. |
91
- | `maxOutputBytes` | `16777216` | Maximum combined stdout and stderr retained by one `run()` call. Exceeding it aborts and reaps the complete process group. Set `null` to disable. Streaming `spawn()` output is not retained by this backend. |
92
- | `onEvent` | `undefined` | Best-effort structured lifecycle sink for generation, command, and cleanup events. Sink failures are ignored so telemetry cannot change command behavior. |
93
-
94
- Both lifecycle callbacks may call `use({ networkPolicy: "allow-all" | "deny-all" })`. The
128
+ | Option | Default | Meaning |
129
+ | ------------------------ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
130
+ | `env` | `{}` | Environment variables set for every sandboxed command. |
131
+ | `networkPolicy` | `"allow-all"` | `"allow-all"` shares the host network; `"deny-all"` runs each command with no network (`--unshare-net`). `setNetworkPolicy` can switch between the two at run time; granular domain policies are rejected (use the Vercel backend for those). |
132
+ | `hidePaths` | `[]` | Extra host paths hidden from the sandbox (each covered by an empty tmpfs). |
133
+ | `bwrapPath` | `"bwrap"` | bwrap executable to invoke. |
134
+ | `cacheDir` | `<appRoot>/.eve/sandbox-cache/bwrap` | Absolute directory holding durable session workspaces, and, for the backend, templates. The provider always keeps templates in eve's build storage, where eve records them. Pin this outside the release directory so a redeploy does not discard durable session state: since eve 0.22.0, eve keys session sandboxes per durable session, not per deployment, so an `appRoot`-derived default would silently destroy every session's `/workspace` on the next redeploy. The generated eveland module always sets this from `EVELAND_SANDBOX_CACHE_DIR`. |
135
+ | `templateRevision` | `null` | Backend only. Optional immutable release identity included in the template cache key but not the session path. Change it when seed files change so new Sessions use a fresh template without overwriting durable workspaces. Eveland sets it from its internal `EVELAND_SANDBOX_TEMPLATE_REVISION`. |
136
+ | `runTimeoutMs` | `600000` | Hard wall-clock limit for one `run()` command. Timeout aborts the command and kills its complete bwrap process group. Set `null` to disable it. The limit deliberately does not apply to `spawn()`, which is the API for long-running processes. |
137
+ | `maxConcurrentProcesses` | `64` | Maximum live `run()`/`spawn()` commands admitted in one compute generation. A live `spawn()` counts until it actually exits, even when its caller never waits. Set `null` to disable. |
138
+ | `maxOutputBytes` | `16777216` | Maximum combined stdout and stderr retained by one `run()` call. Exceeding it aborts and reaps the complete process group. Set `null` to disable. Streaming `spawn()` output is not retained by this backend. |
139
+ | `onEvent` | `undefined` | Best-effort structured lifecycle sink for generation, command, and cleanup events. Sink failures are ignored so telemetry cannot change command behavior. |
140
+
141
+ `BwrapSandbox.environment()` takes the same options except `templateRevision`, which the
142
+ provider does not need: eve's source revision and resource keys already key its templates.
143
+ It adds `prepare`, described above.
144
+
145
+ With the backend, both lifecycle callbacks may call `use({ networkPolicy: "allow-all" | "deny-all" })`. The
95
146
  policy is applied before `use()` returns the template or live Session, so subsequent commands in
96
147
  that callback use the requested network boundary. Calling `use()` without options keeps the
97
148
  backend's configured policy.
98
149
 
99
150
  ## How it works
100
151
 
152
+ The provider (eve 0.64 and later):
153
+
154
+ - **prepare** (`eve build`): writes eve's workspace tree and skill tree
155
+ (`$HOME/.agents/skills/**` lands at `/workspace/.agents/skills/**`) into a staging
156
+ directory, runs the authored `prepare` inside bwrap, stops anything it left running, and
157
+ atomically renames the result into `<storage>/bwrap/templates/<hash>`, where `<storage>`
158
+ is eve's sandbox storage directory, `<appRoot>/.eve/sandbox-cache`. The key covers eve's
159
+ source revision, the resource keys, the options, and the source of `prepare`.
160
+ - **start** (first sandbox access of a session): clones the template into
161
+ `<cacheDir>/sessions/<hash>`, keyed by eve's session id, and returns the session state eve
162
+ persists: the session key and the network policy.
163
+ - **resume** (every later access, and after restarts): reopens the recorded workspace and
164
+ applies the recorded network policy to a new compute generation. It fails instead of
165
+ recreating a workspace that is gone.
166
+ - **onSessionDelete**: kills the session's processes and removes its workspace and
167
+ metadata; the template survives.
168
+
169
+ The backend (eve 0.62 and 0.63):
170
+
101
171
  - **prewarm** (build time): resolves Eve's `$HOME/.agents/skills/**` seed paths to
102
172
  `/workspace/.agents/skills/**`, writes every seed into a staging directory, then runs the
103
173
  authored `bootstrap` inside bwrap so it can consume those canonical inputs, before atomically
@@ -123,7 +193,10 @@ backend's configured policy.
123
193
  ## Disk usage and cache management
124
194
 
125
195
  Session and template directories persist under `<cacheDir>/{sessions,templates}` across
126
- process restarts and reconnects, enabling fast reattach. `stop()` and `shutdown()` never
196
+ process restarts and reconnects, enabling fast reattach. The provider keeps its templates
197
+ under eve's storage directory instead (`<appRoot>/.eve/sandbox-cache/bwrap/templates`), so
198
+ they go away with the build that prepared them; pass that `appRoot` without `cacheDir` to the
199
+ APIs below to inspect them. `stop()` and `shutdown()` never
127
200
  delete durable state, and this package never schedules automatic deletion. On Eveland,
128
201
  the cache lives at `EVELAND_SANDBOX_CACHE_DIR` outside every release directory, so a
129
202
  redeploy does not touch it.
@@ -0,0 +1,49 @@
1
+ import type { BwrapSandboxUseOptions } from "./options.js";
2
+ import type { BwrapSession } from "./session.js";
3
+ export interface BwrapSeedFile {
4
+ readonly path: string;
5
+ readonly content: string | Uint8Array;
6
+ }
7
+ export interface BwrapBackendRuntimeContext {
8
+ readonly appRoot: string;
9
+ }
10
+ export interface BwrapBackendCreateInput {
11
+ readonly templateKey: string | null;
12
+ readonly sessionKey: string;
13
+ readonly existingMetadata?: Record<string, unknown>;
14
+ readonly tags?: Readonly<Record<string, string>>;
15
+ readonly runtimeContext: BwrapBackendRuntimeContext;
16
+ }
17
+ export interface BwrapBackendBootstrapContext {
18
+ use(options?: BwrapSandboxUseOptions): Promise<BwrapSession>;
19
+ }
20
+ export interface BwrapBackendPrewarmInput {
21
+ readonly templateKey: string;
22
+ readonly bootstrap?: (input: BwrapBackendBootstrapContext) => void | Promise<void>;
23
+ readonly log?: (message: string) => void;
24
+ readonly runtimeContext: BwrapBackendRuntimeContext;
25
+ readonly seedFiles: ReadonlyArray<BwrapSeedFile>;
26
+ }
27
+ export interface BwrapBackendSessionState {
28
+ readonly backendName: string;
29
+ readonly metadata: Record<string, unknown>;
30
+ readonly sessionKey: string;
31
+ }
32
+ export interface BwrapBackendHandle {
33
+ readonly session: BwrapSession;
34
+ readonly useSessionFn: (options?: BwrapSandboxUseOptions) => Promise<BwrapSession>;
35
+ captureState(): Promise<BwrapBackendSessionState>;
36
+ delete(options?: {
37
+ readonly abortSignal?: AbortSignal;
38
+ }): Promise<void>;
39
+ stop(): Promise<void>;
40
+ shutdown(): Promise<void>;
41
+ }
42
+ /** The bubblewrap sandbox backend, for eve 0.62 and 0.63. */
43
+ export interface BwrapSandboxBackend {
44
+ readonly name: string;
45
+ create(input: BwrapBackendCreateInput): Promise<BwrapBackendHandle>;
46
+ prewarm(input: BwrapBackendPrewarmInput): Promise<{
47
+ readonly reused: boolean;
48
+ }>;
49
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/backend.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { SandboxBackend } from "eve/sandbox";
2
- import type { BwrapSandboxCreateOptions, BwrapSandboxUseOptions } from "./options.js";
1
+ import type { BwrapSandboxBackend } from "./backend-contract.js";
2
+ import type { BwrapSandboxCreateOptions } from "./options.js";
3
3
  import type { ProcessRunner } from "./process.js";
4
4
  import { type BwrapDirectoryCopier } from "./cache.js";
5
5
  /**
@@ -14,4 +14,5 @@ export interface CreateBwrapSandboxBackendInput {
14
14
  /** Injectable clone primitive for filesystem-capability tests. */
15
15
  readonly copyDirectory?: BwrapDirectoryCopier;
16
16
  }
17
- export declare function createBwrapSandboxBackend(input?: CreateBwrapSandboxBackendInput): SandboxBackend<BwrapSandboxUseOptions, BwrapSandboxUseOptions>;
17
+ /** The backend for eve 0.62 and 0.63. eve 0.64 and later use `BwrapSandbox` instead. */
18
+ export declare function createBwrapSandboxBackend(input?: CreateBwrapSandboxBackendInput): BwrapSandboxBackend;
package/dist/backend.js CHANGED
@@ -2,92 +2,21 @@ import { randomUUID } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
3
  import { mkdir, rename, rm } from "node:fs/promises";
4
4
  import { basename } from "node:path";
5
- import { SandboxTemplateNotProvisionedError } from "eve/sandbox";
5
+ import { BwrapTemplateNotProvisionedError } from "./errors.js";
6
6
  import { createBwrapOptionsHash, resolveBwrapSandboxOptions } from "./options.js";
7
- import { resolveBwrapCacheRoot, resolveSessionPath, resolveTemplatePath, WORKSPACE_ROOT, } from "./paths.js";
8
- import { createNodeProcessRunner, describeMissingPrereqs, isBwrapAvailable } from "./process.js";
9
- import { createBwrapSession } from "./session.js";
10
- import { cloneDirectoryAtomically, createBwrapCacheLease, registerActiveCachePath, touchCacheMetadata, } from "./cache.js";
11
- const EVE_MODEL_SKILL_ROOT = "$HOME/.agents/skills";
7
+ import { resolveBwrapCacheRoot, resolveSessionPath, resolveTemplatePath } from "./paths.js";
8
+ import { createBwrapRuntime, writeSeedFiles } from "./runtime.js";
9
+ import { cloneDirectoryAtomically, removeCacheMetadata, touchCacheMetadata, } from "./cache.js";
12
10
  /**
13
11
  * Stable backend name. Participates in eve's template/session cache-key
14
12
  * derivation and persisted reconnect state — never change it.
15
13
  */
16
14
  export const BWRAP_BACKEND_NAME = "bwrap";
15
+ /** The backend for eve 0.62 and 0.63. eve 0.64 and later use `BwrapSandbox` instead. */
17
16
  export function createBwrapSandboxBackend(input = {}) {
18
17
  const options = resolveBwrapSandboxOptions(input.createOptions);
19
18
  const optionsHash = createBwrapOptionsHash(options);
20
- const runner = input.runner ?? createNodeProcessRunner();
21
- const generations = new Map();
22
- // Probe only when running against the real bwrap; injected runners skip it.
23
- const shouldProbe = input.runner === undefined;
24
- let probed = false;
25
- function assertBwrapAvailable() {
26
- if (!shouldProbe || probed)
27
- return;
28
- const missing = describeMissingPrereqs({
29
- bwrapPresent: isBwrapAvailable(options.bwrapPath),
30
- workspaceMountpointPresent: existsSync(WORKSPACE_ROOT),
31
- bwrapPath: options.bwrapPath,
32
- });
33
- if (missing)
34
- throw new Error(missing);
35
- probed = true;
36
- }
37
- function openSession(id, workspaceDir, appRoot, tags, generationId, onStopped) {
38
- return createBwrapSession({
39
- id,
40
- workspaceDir,
41
- appRoot,
42
- runner,
43
- options,
44
- tags,
45
- generationId,
46
- onStopped,
47
- });
48
- }
49
- async function openRuntimeSession(id, workspaceDir, appRoot, tags) {
50
- const current = generations.get(workspaceDir);
51
- if (current && current.lifecycleState() !== "stopped")
52
- return current;
53
- const releaseActive = registerActiveCachePath(workspaceDir);
54
- const cacheRoot = resolveBwrapCacheRoot(appRoot, options.cacheDir);
55
- const activeLease = await createBwrapCacheLease({
56
- cacheRoot,
57
- sessionId: basename(workspaceDir),
58
- });
59
- let session;
60
- try {
61
- session = openSession(id, workspaceDir, appRoot, tags, activeLease.lease.generationId, async () => {
62
- releaseActive();
63
- await activeLease.release();
64
- });
65
- }
66
- catch (error) {
67
- releaseActive();
68
- await activeLease.release();
69
- throw error;
70
- }
71
- generations.set(workspaceDir, session);
72
- return session;
73
- }
74
- function resolveSeedPath(seedPath) {
75
- if (seedPath === EVE_MODEL_SKILL_ROOT || seedPath.startsWith(`${EVE_MODEL_SKILL_ROOT}/`)) {
76
- return `${WORKSPACE_ROOT}/.agents/skills${seedPath.slice(EVE_MODEL_SKILL_ROOT.length)}`;
77
- }
78
- return seedPath;
79
- }
80
- async function writeSeedFiles(session, seedFiles) {
81
- for (const seed of seedFiles) {
82
- const seedPath = resolveSeedPath(seed.path);
83
- if (typeof seed.content === "string") {
84
- await session.writeTextFile({ path: seedPath, content: seed.content });
85
- }
86
- else {
87
- await session.writeBinaryFile({ path: seedPath, content: seed.content });
88
- }
89
- }
90
- }
19
+ const runtime = createBwrapRuntime({ options, runner: input.runner });
91
20
  async function useSession(session, useOptions) {
92
21
  if (useOptions?.networkPolicy !== undefined) {
93
22
  await session.setNetworkPolicy(useOptions.networkPolicy);
@@ -97,7 +26,7 @@ export function createBwrapSandboxBackend(input = {}) {
97
26
  return {
98
27
  name: BWRAP_BACKEND_NAME,
99
28
  async prewarm({ templateKey, bootstrap, seedFiles, log, runtimeContext }) {
100
- assertBwrapAvailable();
29
+ runtime.assertBwrapAvailable();
101
30
  const templatePath = resolveTemplatePath(runtimeContext.appRoot, templateKey, optionsHash, options.cacheDir);
102
31
  const touchTemplate = async () => await touchCacheMetadata({
103
32
  cacheRoot: resolveBwrapCacheRoot(runtimeContext.appRoot, options.cacheDir),
@@ -113,7 +42,11 @@ export function createBwrapSandboxBackend(input = {}) {
113
42
  const stagingPath = `${templatePath}.staging-${randomUUID()}`;
114
43
  await mkdir(stagingPath, { recursive: true });
115
44
  try {
116
- const session = openSession(templateKey, stagingPath, runtimeContext.appRoot);
45
+ const session = runtime.openTemplateSession({
46
+ id: templateKey,
47
+ workspaceDir: stagingPath,
48
+ cacheRoots: [resolveBwrapCacheRoot(runtimeContext.appRoot, options.cacheDir)],
49
+ });
117
50
  await writeSeedFiles(session, seedFiles);
118
51
  if (bootstrap) {
119
52
  await bootstrap({ use: async (useOptions) => await useSession(session, useOptions) });
@@ -133,7 +66,7 @@ export function createBwrapSandboxBackend(input = {}) {
133
66
  return { reused: false };
134
67
  },
135
68
  async create({ templateKey, sessionKey, runtimeContext, tags }) {
136
- assertBwrapAvailable();
69
+ runtime.assertBwrapAvailable();
137
70
  const sessionPath = resolveSessionPath(runtimeContext.appRoot, sessionKey, options.cacheDir);
138
71
  let cloneStrategy = "existing";
139
72
  if (!existsSync(sessionPath)) {
@@ -144,10 +77,7 @@ export function createBwrapSandboxBackend(input = {}) {
144
77
  else {
145
78
  const templatePath = resolveTemplatePath(runtimeContext.appRoot, templateKey, optionsHash, options.cacheDir);
146
79
  if (!existsSync(templatePath)) {
147
- throw new SandboxTemplateNotProvisionedError({
148
- backendName: BWRAP_BACKEND_NAME,
149
- templateKey,
150
- });
80
+ throw new BwrapTemplateNotProvisionedError({ templateKey });
151
81
  }
152
82
  cloneStrategy = await cloneDirectoryAtomically({
153
83
  sourcePath: templatePath,
@@ -163,7 +93,14 @@ export function createBwrapSandboxBackend(input = {}) {
163
93
  tags,
164
94
  cloneStrategy,
165
95
  });
166
- const session = await openRuntimeSession(sessionKey, sessionPath, runtimeContext.appRoot, tags);
96
+ const cacheRoot = resolveBwrapCacheRoot(runtimeContext.appRoot, options.cacheDir);
97
+ const { session } = await runtime.openRuntimeSession({
98
+ id: sessionKey,
99
+ workspaceDir: sessionPath,
100
+ cacheRoots: [cacheRoot],
101
+ leaseRoot: cacheRoot,
102
+ tags,
103
+ });
167
104
  return {
168
105
  session,
169
106
  useSessionFn: async (useOptions) => await useSession(session, useOptions),
@@ -186,6 +123,23 @@ export function createBwrapSandboxBackend(input = {}) {
186
123
  async shutdown() {
187
124
  await session.killAll();
188
125
  },
126
+ // eve (>=0.47) calls this when authored code runs
127
+ // `ctx.getSandbox().delete()`: the sandbox and its disposable state are
128
+ // gone for good, and the next access reprovisions from the template.
129
+ // For bwrap the disposable state is the session workspace directory and
130
+ // its metadata sidecar; the template it was cloned from is shared and
131
+ // must survive.
132
+ async delete(deleteOptions) {
133
+ deleteOptions?.abortSignal?.throwIfAborted();
134
+ await session.killAll();
135
+ runtime.forgetRuntimeSession(sessionPath);
136
+ await rm(sessionPath, { force: true, recursive: true });
137
+ await removeCacheMetadata({
138
+ cacheRoot: resolveBwrapCacheRoot(runtimeContext.appRoot, options.cacheDir),
139
+ kind: "session",
140
+ id: basename(sessionPath),
141
+ });
142
+ },
189
143
  };
190
144
  },
191
145
  };
package/dist/cache.d.ts CHANGED
@@ -73,3 +73,13 @@ export declare function touchCacheMetadata(input: {
73
73
  templateRevision?: string | null;
74
74
  now?: Date;
75
75
  }): Promise<BwrapCacheMetadata>;
76
+ /**
77
+ * Removes one cache entry's metadata sidecar. Deleting a session removes its
78
+ * workspace directory too, and a metadata file without a directory would read
79
+ * as an orphan to `pruneBwrapCache` — remove both together.
80
+ */
81
+ export declare function removeCacheMetadata(input: {
82
+ cacheRoot: string;
83
+ kind: BwrapCacheEntryKind;
84
+ id: string;
85
+ }): Promise<void>;
package/dist/cache.js CHANGED
@@ -281,3 +281,11 @@ export async function touchCacheMetadata(input) {
281
281
  }
282
282
  return metadata;
283
283
  }
284
+ /**
285
+ * Removes one cache entry's metadata sidecar. Deleting a session removes its
286
+ * workspace directory too, and a metadata file without a directory would read
287
+ * as an orphan to `pruneBwrapCache` — remove both together.
288
+ */
289
+ export async function removeCacheMetadata(input) {
290
+ await rm(metadataPath(input.cacheRoot, input.kind, input.id), { force: true });
291
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Thrown when a session needs a template that was never prepared.
3
+ *
4
+ * eve recognizes this error structurally rather than by class, so this package
5
+ * can throw it without importing eve at runtime. `name` and `templateKey` are
6
+ * checked by every eve line; `backendName` is what eve 0.62 and 0.63 look for,
7
+ * and `providerName` is what 0.64 and later look for. It carries both, so
8
+ * the same error works for the backend and the provider.
9
+ */
10
+ export declare class BwrapTemplateNotProvisionedError extends Error {
11
+ readonly name = "SandboxTemplateNotProvisionedError";
12
+ readonly backendName = "bwrap";
13
+ readonly providerName = "bwrap";
14
+ readonly templateKey: string;
15
+ constructor(input: {
16
+ readonly templateKey: string;
17
+ });
18
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Thrown when a session needs a template that was never prepared.
3
+ *
4
+ * eve recognizes this error structurally rather than by class, so this package
5
+ * can throw it without importing eve at runtime. `name` and `templateKey` are
6
+ * checked by every eve line; `backendName` is what eve 0.62 and 0.63 look for,
7
+ * and `providerName` is what 0.64 and later look for. It carries both, so
8
+ * the same error works for the backend and the provider.
9
+ */
10
+ export class BwrapTemplateNotProvisionedError extends Error {
11
+ name = "SandboxTemplateNotProvisionedError";
12
+ backendName = "bwrap";
13
+ providerName = "bwrap";
14
+ templateKey;
15
+ constructor(input) {
16
+ super(`Sandbox template "${input.templateKey}" is not provisioned for bwrap. ` +
17
+ "Run `eve build` before serving traffic.");
18
+ this.templateKey = input.templateKey;
19
+ }
20
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,10 @@
1
- import type { SandboxBackend } from "eve/sandbox";
2
- import type { BwrapSandboxCreateOptions, BwrapSandboxUseOptions } from "./options.js";
1
+ import type { BwrapSandboxBackend } from "./backend-contract.js";
2
+ import type { BwrapSandboxCreateOptions } from "./options.js";
3
3
  export { BWRAP_BACKEND_NAME, createBwrapSandboxBackend, type CreateBwrapSandboxBackendInput, } from "./backend.js";
4
+ export type { BwrapBackendBootstrapContext, BwrapBackendCreateInput, BwrapBackendHandle, BwrapBackendPrewarmInput, BwrapBackendRuntimeContext, BwrapBackendSessionState, BwrapSandboxBackend, BwrapSeedFile, } from "./backend-contract.js";
5
+ export { BwrapTemplateNotProvisionedError } from "./errors.js";
6
+ export { BWRAP_PROVIDER_STATE_PROTOCOL_VERSION, createBwrapSandboxProviderDefinition, type BwrapPreparedArtifact, type BwrapSandboxEnvironmentOptions, type BwrapSandboxOpenOptions, type BwrapSandboxProviderDefinition, type BwrapSessionState, type CreateBwrapSandboxProviderDefinitionInput, } from "./provider-definition.js";
7
+ export type { BwrapSession } from "./session.js";
4
8
  export type { BwrapNetworkPolicy, BwrapSandboxCreateOptions, BwrapSandboxUseOptions, } from "./options.js";
5
9
  export type { BwrapCommandFinishReason, BwrapSandboxEvent, BwrapSandboxEventSink, } from "./events.js";
6
10
  export { listBwrapCache, listBwrapCacheLeases, pruneBwrapCache } from "./cache.js";
@@ -9,7 +13,9 @@ export { DEFAULT_MAX_CONCURRENT_PROCESSES, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_RUN
9
13
  export { isBwrapAvailable } from "./process.js";
10
14
  export type { ProcessRunner, SpawnedProcess } from "./process.js";
11
15
  /**
12
- * Creates the bubblewrap sandbox backend for `defineSandbox({ backend })`.
16
+ * Creates the bubblewrap sandbox backend for `defineSandbox({ backend })`, the
17
+ * sandbox API of eve 0.62 and 0.63. On eve 0.64 and later use `BwrapSandbox`
18
+ * from `@evelandhq/sandbox-bwrap/provider` instead.
13
19
  *
14
20
  * ```ts
15
21
  * // agent/sandbox.ts
@@ -21,4 +27,4 @@ export type { ProcessRunner, SpawnedProcess } from "./process.js";
21
27
  * });
22
28
  * ```
23
29
  */
24
- export declare function bwrap(options?: BwrapSandboxCreateOptions): SandboxBackend<BwrapSandboxUseOptions, BwrapSandboxUseOptions>;
30
+ export declare function bwrap(options?: BwrapSandboxCreateOptions): BwrapSandboxBackend;
package/dist/index.js CHANGED
@@ -1,10 +1,14 @@
1
1
  import { createBwrapSandboxBackend } from "./backend.js";
2
2
  export { BWRAP_BACKEND_NAME, createBwrapSandboxBackend, } from "./backend.js";
3
+ export { BwrapTemplateNotProvisionedError } from "./errors.js";
4
+ export { BWRAP_PROVIDER_STATE_PROTOCOL_VERSION, createBwrapSandboxProviderDefinition, } from "./provider-definition.js";
3
5
  export { listBwrapCache, listBwrapCacheLeases, pruneBwrapCache } from "./cache.js";
4
6
  export { DEFAULT_MAX_CONCURRENT_PROCESSES, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_RUN_TIMEOUT_MS, } from "./options.js";
5
7
  export { isBwrapAvailable } from "./process.js";
6
8
  /**
7
- * Creates the bubblewrap sandbox backend for `defineSandbox({ backend })`.
9
+ * Creates the bubblewrap sandbox backend for `defineSandbox({ backend })`, the
10
+ * sandbox API of eve 0.62 and 0.63. On eve 0.64 and later use `BwrapSandbox`
11
+ * from `@evelandhq/sandbox-bwrap/provider` instead.
8
12
  *
9
13
  * ```ts
10
14
  * // agent/sandbox.ts
package/dist/paths.d.ts CHANGED
@@ -8,6 +8,16 @@ export declare const WORKSPACE_ROOT = "/workspace";
8
8
  export declare function resolveBwrapCacheRoot(appRoot: string, cacheDir?: string | null): string;
9
9
  export declare function resolveTemplatePath(appRoot: string, templateKey: string, optionsHash: string, cacheDir?: string | null): string;
10
10
  export declare function resolveSessionPath(appRoot: string, sessionKey: string, cacheDir?: string | null): string;
11
+ /**
12
+ * The provider's cache root inside eve's sandbox storage directory
13
+ * (`<appRoot>/.eve/sandbox-cache`), the same place the backend's default
14
+ * cache root resolves to. eve prepares templates there at build time and
15
+ * records their path, so templates always live here, even when `cacheDir`
16
+ * moves session workspaces elsewhere.
17
+ */
18
+ export declare function resolveProviderCacheRoot(storagePath: string): string;
19
+ export declare function templatePathIn(cacheRoot: string, templateKey: string, optionsHash: string): string;
20
+ export declare function sessionPathIn(cacheRoot: string, sessionKey: string): string;
11
21
  /** Anchors a sandbox-relative path to /workspace; absolute paths pass through. */
12
22
  export declare function resolveWorkspacePath(path: string): string;
13
23
  /**
package/dist/paths.js CHANGED
@@ -15,10 +15,26 @@ function keyDigest(value) {
15
15
  return createHash("sha256").update(value).digest("hex").slice(0, 32);
16
16
  }
17
17
  export function resolveTemplatePath(appRoot, templateKey, optionsHash, cacheDir) {
18
- return join(resolveBwrapCacheRoot(appRoot, cacheDir), "templates", `${keyDigest(templateKey)}-${optionsHash}`);
18
+ return templatePathIn(resolveBwrapCacheRoot(appRoot, cacheDir), templateKey, optionsHash);
19
19
  }
20
20
  export function resolveSessionPath(appRoot, sessionKey, cacheDir) {
21
- return join(resolveBwrapCacheRoot(appRoot, cacheDir), "sessions", keyDigest(sessionKey));
21
+ return sessionPathIn(resolveBwrapCacheRoot(appRoot, cacheDir), sessionKey);
22
+ }
23
+ /**
24
+ * The provider's cache root inside eve's sandbox storage directory
25
+ * (`<appRoot>/.eve/sandbox-cache`), the same place the backend's default
26
+ * cache root resolves to. eve prepares templates there at build time and
27
+ * records their path, so templates always live here, even when `cacheDir`
28
+ * moves session workspaces elsewhere.
29
+ */
30
+ export function resolveProviderCacheRoot(storagePath) {
31
+ return join(storagePath, "bwrap");
32
+ }
33
+ export function templatePathIn(cacheRoot, templateKey, optionsHash) {
34
+ return join(cacheRoot, "templates", `${keyDigest(templateKey)}-${optionsHash}`);
35
+ }
36
+ export function sessionPathIn(cacheRoot, sessionKey) {
37
+ return join(cacheRoot, "sessions", keyDigest(sessionKey));
22
38
  }
23
39
  /** Anchors a sandbox-relative path to /workspace; absolute paths pass through. */
24
40
  export function resolveWorkspacePath(path) {
@@ -0,0 +1,49 @@
1
+ import type { MutableNetworkSandboxSession } from "eve/sandbox";
2
+ import type { SandboxProviderDefinition } from "eve/sandbox/provider";
3
+ import { type BwrapDirectoryCopier } from "./cache.js";
4
+ import type { BwrapNetworkPolicy, BwrapSandboxCreateOptions } from "./options.js";
5
+ import type { ProcessRunner } from "./process.js";
6
+ /**
7
+ * Version of the session state this provider persists through eve. eve refuses
8
+ * to resume state written under a different version, so bump it only together
9
+ * with a reader for the old shape.
10
+ */
11
+ export declare const BWRAP_PROVIDER_STATE_PROTOCOL_VERSION = 1;
12
+ /** Options for `BwrapSandbox.environment(...)`. */
13
+ export type BwrapSandboxEnvironmentOptions = Omit<BwrapSandboxCreateOptions, "templateRevision"> & {
14
+ /**
15
+ * Setup every new sandbox inherits. eve runs it once, when `eve build`
16
+ * prepares the template, not for each session. Commands it runs execute in
17
+ * bwrap, so the build host needs bwrap too.
18
+ */
19
+ readonly prepare?: (sandbox: MutableNetworkSandboxSession) => Promise<void>;
20
+ };
21
+ /** Options for `environment.open(...)`, applied when a session's sandbox is first started. */
22
+ export interface BwrapSandboxOpenOptions {
23
+ readonly networkPolicy?: BwrapNetworkPolicy;
24
+ }
25
+ /** What `eve build` records for a prepared template. */
26
+ export type BwrapPreparedArtifact = {
27
+ readonly version: 1;
28
+ readonly templatePath: string;
29
+ };
30
+ /** What eve persists for one durable session's sandbox. */
31
+ export type BwrapSessionState = {
32
+ readonly version: 1;
33
+ readonly sessionKey: string;
34
+ readonly networkPolicy: BwrapNetworkPolicy;
35
+ };
36
+ export interface CreateBwrapSandboxProviderDefinitionInput {
37
+ /** Injectable process launcher so provider logic is testable without bwrap. */
38
+ readonly runner?: ProcessRunner;
39
+ /** Injectable clone primitive for filesystem-capability tests. */
40
+ readonly copyDirectory?: BwrapDirectoryCopier;
41
+ }
42
+ export type BwrapSandboxProviderDefinition = SandboxProviderDefinition<BwrapSandboxEnvironmentOptions, BwrapSandboxOpenOptions, BwrapPreparedArtifact, BwrapSessionState, MutableNetworkSandboxSession>;
43
+ /**
44
+ * The bubblewrap sandbox provider for eve 0.64 and later, as a plain
45
+ * definition. Pass it to eve's `defineSandboxProvider`, or import the ready
46
+ * `BwrapSandbox` from `@evelandhq/sandbox-bwrap/provider`. This module imports
47
+ * nothing from eve at runtime, so it loads on every eve in the peer range.
48
+ */
49
+ export declare function createBwrapSandboxProviderDefinition(input?: CreateBwrapSandboxProviderDefinitionInput): BwrapSandboxProviderDefinition;
@@ -0,0 +1,212 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, rename, rm } from "node:fs/promises";
4
+ import { basename } from "node:path";
5
+ import { cloneDirectoryAtomically, removeCacheMetadata, touchCacheMetadata, } from "./cache.js";
6
+ import { BwrapTemplateNotProvisionedError } from "./errors.js";
7
+ import { createBwrapOptionsHash, resolveBwrapSandboxOptions } from "./options.js";
8
+ import { resolveProviderCacheRoot, sessionPathIn, templatePathIn } from "./paths.js";
9
+ import { createBwrapRuntime, writeSeedFiles } from "./runtime.js";
10
+ /**
11
+ * Version of the session state this provider persists through eve. eve refuses
12
+ * to resume state written under a different version, so bump it only together
13
+ * with a reader for the old shape.
14
+ */
15
+ export const BWRAP_PROVIDER_STATE_PROTOCOL_VERSION = 1;
16
+ /**
17
+ * The bubblewrap sandbox provider for eve 0.64 and later, as a plain
18
+ * definition. Pass it to eve's `defineSandboxProvider`, or import the ready
19
+ * `BwrapSandbox` from `@evelandhq/sandbox-bwrap/provider`. This module imports
20
+ * nothing from eve at runtime, so it loads on every eve in the peer range.
21
+ */
22
+ export function createBwrapSandboxProviderDefinition(input = {}) {
23
+ return {
24
+ name: "bwrap",
25
+ stateProtocolVersion: BWRAP_PROVIDER_STATE_PROTOCOL_VERSION,
26
+ environment(environmentOptions) {
27
+ const { prepare, ...createOptions } = environmentOptions ?? {};
28
+ const options = resolveBwrapSandboxOptions(createOptions);
29
+ const optionsHash = createBwrapOptionsHash(options);
30
+ const runtime = createBwrapRuntime({ options, runner: input.runner });
31
+ // Templates live beside eve's own prepared artifacts; sessions follow
32
+ // `cacheDir` when it is set, so they outlive the release that made them.
33
+ const cacheRoots = (storagePath) => {
34
+ const templateRoot = resolveProviderCacheRoot(storagePath);
35
+ const sessionRoot = options.cacheDir ?? templateRoot;
36
+ return { templateRoot, sessionRoot, hidden: [templateRoot, sessionRoot] };
37
+ };
38
+ async function openHandle(ctx, sessionKey, cloneStrategy, applyNetworkPolicy) {
39
+ const roots = cacheRoots(ctx.storagePath);
40
+ const sessionPath = sessionPathIn(roots.sessionRoot, sessionKey);
41
+ await touchCacheMetadata({
42
+ cacheRoot: roots.sessionRoot,
43
+ kind: "session",
44
+ id: basename(sessionPath),
45
+ tags: { sessionId: ctx.session.id },
46
+ cloneStrategy,
47
+ });
48
+ const { session, created } = await runtime.openRuntimeSession({
49
+ id: ctx.session.id,
50
+ workspaceDir: sessionPath,
51
+ cacheRoots: roots.hidden,
52
+ leaseRoot: roots.sessionRoot,
53
+ });
54
+ await applyNetworkPolicy(session, created);
55
+ return {
56
+ sandbox: session,
57
+ // The processes are the compute and the workspace directory is the
58
+ // durable session: stopping kills the processes and keeps the files.
59
+ async onSessionStop() {
60
+ await session.killAll();
61
+ },
62
+ async onRuntimeShutdown() {
63
+ await session.killAll();
64
+ },
65
+ // The session's own workspace is disposable; the template it was
66
+ // cloned from is shared by every other session and must survive.
67
+ async onSessionDelete(deleteOptions) {
68
+ deleteOptions?.abortSignal?.throwIfAborted();
69
+ await session.killAll();
70
+ runtime.forgetRuntimeSession(sessionPath);
71
+ await rm(sessionPath, { force: true, recursive: true });
72
+ await removeCacheMetadata({
73
+ cacheRoot: roots.sessionRoot,
74
+ kind: "session",
75
+ id: basename(sessionPath),
76
+ });
77
+ },
78
+ };
79
+ }
80
+ return {
81
+ async prepare(ctx) {
82
+ const templateKey = JSON.stringify({
83
+ version: 1,
84
+ sourceRevision: ctx.sourceRevision,
85
+ workspace: ctx.resources.workspace?.key ?? null,
86
+ skills: ctx.resources.skills?.key ?? null,
87
+ // eve's source revision covers the sandbox module, not helpers it
88
+ // imports, so the preparation's own source joins the key.
89
+ prepare: prepare?.toString() ?? null,
90
+ });
91
+ const roots = cacheRoots(ctx.storagePath);
92
+ const templatePath = templatePathIn(roots.templateRoot, templateKey, optionsHash);
93
+ const artifact = { version: 1, templatePath };
94
+ const touchTemplate = async () => await touchCacheMetadata({
95
+ cacheRoot: roots.templateRoot,
96
+ kind: "template",
97
+ id: basename(templatePath),
98
+ });
99
+ if (existsSync(templatePath)) {
100
+ await touchTemplate();
101
+ ctx.log?.("bwrap: reusing the prepared template");
102
+ return artifact;
103
+ }
104
+ ctx.log?.("bwrap: capturing the template");
105
+ const stagingPath = `${templatePath}.staging-${randomUUID()}`;
106
+ await mkdir(stagingPath, { recursive: true });
107
+ try {
108
+ const session = runtime.openTemplateSession({
109
+ id: basename(templatePath),
110
+ workspaceDir: stagingPath,
111
+ cacheRoots: roots.hidden,
112
+ });
113
+ try {
114
+ await writeSeedFiles(session, resourceSeeds(ctx.resources));
115
+ if (prepare) {
116
+ runtime.assertBwrapAvailable();
117
+ ctx.log?.("bwrap: running sandbox preparation");
118
+ await prepare(session);
119
+ }
120
+ }
121
+ finally {
122
+ // Nothing authored preparation started may outlive the capture.
123
+ await session.killAll();
124
+ }
125
+ await rename(stagingPath, templatePath);
126
+ }
127
+ catch (error) {
128
+ await rm(stagingPath, { force: true, recursive: true }).catch(() => { });
129
+ // A concurrent preparation winning the race is reuse, not failure.
130
+ if (existsSync(templatePath)) {
131
+ await touchTemplate();
132
+ return artifact;
133
+ }
134
+ throw error;
135
+ }
136
+ await touchTemplate();
137
+ return artifact;
138
+ },
139
+ async start(ctx, openOptions, preparedArtifact) {
140
+ runtime.assertBwrapAvailable();
141
+ const { templatePath } = requireArtifact(preparedArtifact);
142
+ const sessionKey = `session:${ctx.session.id}`;
143
+ const sessionPath = sessionPathIn(cacheRoots(ctx.storagePath).sessionRoot, sessionKey);
144
+ let cloneStrategy = "existing";
145
+ if (!existsSync(sessionPath)) {
146
+ if (!existsSync(templatePath)) {
147
+ throw new BwrapTemplateNotProvisionedError({ templateKey: templatePath });
148
+ }
149
+ cloneStrategy = await cloneDirectoryAtomically({
150
+ sourcePath: templatePath,
151
+ targetPath: sessionPath,
152
+ copyDirectory: input.copyDirectory,
153
+ });
154
+ }
155
+ const networkPolicy = openOptions?.networkPolicy ?? options.networkPolicy;
156
+ const handle = await openHandle(ctx, sessionKey, cloneStrategy, async (session) => {
157
+ await session.setNetworkPolicy(networkPolicy);
158
+ });
159
+ return { handle, state: { version: 1, sessionKey, networkPolicy } };
160
+ },
161
+ // eve resumes at every durable step, and after a restart or a move to
162
+ // another deployment. Only the session workspace is needed, never the
163
+ // template, so the artifact passed here may belong to a newer build.
164
+ async resume(ctx, preparedArtifact, sessionState) {
165
+ runtime.assertBwrapAvailable();
166
+ requireArtifact(preparedArtifact);
167
+ const state = requireSessionState(sessionState);
168
+ const sessionPath = sessionPathIn(cacheRoots(ctx.storagePath).sessionRoot, state.sessionKey);
169
+ if (!existsSync(sessionPath)) {
170
+ throw new Error(`bwrap sandbox: session workspace ${sessionPath} no longer exists, so the sandbox cannot be resumed`);
171
+ }
172
+ // A live generation keeps whatever policy the session set since;
173
+ // only a new generation starts over from the recorded one.
174
+ return await openHandle(ctx, state.sessionKey, "existing", async (session, created) => {
175
+ if (created)
176
+ await session.setNetworkPolicy(state.networkPolicy);
177
+ });
178
+ },
179
+ };
180
+ },
181
+ };
182
+ }
183
+ /** eve's workspace and skill trees as seeds at their sandbox target paths. */
184
+ function resourceSeeds(resources) {
185
+ return [resources.workspace, resources.skills].flatMap((tree) => tree === undefined
186
+ ? []
187
+ : tree.files.map((file) => ({
188
+ path: `${tree.targetPath}/${file.relativePath}`,
189
+ content: file.content,
190
+ })));
191
+ }
192
+ function isRecord(value) {
193
+ return typeof value === "object" && value !== null && !Array.isArray(value);
194
+ }
195
+ function requireArtifact(value) {
196
+ if (!isRecord(value) || value.version !== 1 || typeof value.templatePath !== "string") {
197
+ throw new Error("bwrap sandbox: invalid prepared artifact; rebuild the agent with `eve build`");
198
+ }
199
+ return { version: 1, templatePath: value.templatePath };
200
+ }
201
+ function isNetworkPolicy(value) {
202
+ return value === "allow-all" || value === "deny-all";
203
+ }
204
+ function requireSessionState(value) {
205
+ if (!isRecord(value) ||
206
+ value.version !== 1 ||
207
+ typeof value.sessionKey !== "string" ||
208
+ !isNetworkPolicy(value.networkPolicy)) {
209
+ throw new Error("bwrap sandbox: invalid session state");
210
+ }
211
+ return { version: 1, sessionKey: value.sessionKey, networkPolicy: value.networkPolicy };
212
+ }
@@ -0,0 +1,18 @@
1
+ export { BWRAP_PROVIDER_STATE_PROTOCOL_VERSION, createBwrapSandboxProviderDefinition, type BwrapPreparedArtifact, type BwrapSandboxEnvironmentOptions, type BwrapSandboxOpenOptions, type BwrapSandboxProviderDefinition, type BwrapSessionState, type CreateBwrapSandboxProviderDefinitionInput, } from "./provider-definition.js";
2
+ export { isBwrapAvailable } from "./process.js";
3
+ /**
4
+ * The bubblewrap sandbox provider for eve 0.64 and later.
5
+ *
6
+ * ```ts
7
+ * // agent/sandbox.ts
8
+ * import { defineSandbox } from "eve/sandbox";
9
+ * import { BwrapSandbox } from "@evelandhq/sandbox-bwrap/provider";
10
+ *
11
+ * export const environment = BwrapSandbox.environment();
12
+ * export default defineSandbox(() => environment.open());
13
+ * ```
14
+ *
15
+ * This entry point imports `eve/sandbox/provider`, which exists only from eve
16
+ * 0.64 on. On eve 0.62 and 0.63 use `bwrap()` from the package root.
17
+ */
18
+ export declare const BwrapSandbox: import("eve/sandbox/provider").SandboxProvider<import("./provider-definition.js").BwrapSandboxEnvironmentOptions, import("./provider-definition.js").BwrapSandboxOpenOptions, import("eve/sandbox").MutableNetworkSandboxSession>;
@@ -0,0 +1,20 @@
1
+ import { defineSandboxProvider } from "eve/sandbox/provider";
2
+ import { createBwrapSandboxProviderDefinition } from "./provider-definition.js";
3
+ export { BWRAP_PROVIDER_STATE_PROTOCOL_VERSION, createBwrapSandboxProviderDefinition, } from "./provider-definition.js";
4
+ export { isBwrapAvailable } from "./process.js";
5
+ /**
6
+ * The bubblewrap sandbox provider for eve 0.64 and later.
7
+ *
8
+ * ```ts
9
+ * // agent/sandbox.ts
10
+ * import { defineSandbox } from "eve/sandbox";
11
+ * import { BwrapSandbox } from "@evelandhq/sandbox-bwrap/provider";
12
+ *
13
+ * export const environment = BwrapSandbox.environment();
14
+ * export default defineSandbox(() => environment.open());
15
+ * ```
16
+ *
17
+ * This entry point imports `eve/sandbox/provider`, which exists only from eve
18
+ * 0.64 on. On eve 0.62 and 0.63 use `bwrap()` from the package root.
19
+ */
20
+ export const BwrapSandbox = defineSandboxProvider(createBwrapSandboxProviderDefinition());
@@ -0,0 +1,45 @@
1
+ import type { ResolvedBwrapSandboxOptions } from "./options.js";
2
+ import type { ProcessRunner } from "./process.js";
3
+ import type { BwrapSession } from "./session.js";
4
+ export interface BwrapSeed {
5
+ readonly path: string;
6
+ readonly content: string | Uint8Array;
7
+ }
8
+ export interface BwrapSessionLocation {
9
+ readonly id: string;
10
+ readonly workspaceDir: string;
11
+ /** Hidden from every sandboxed command; see `CreateBwrapSessionInput.cacheRoots`. */
12
+ readonly cacheRoots: readonly string[];
13
+ readonly tags?: Readonly<Record<string, string>>;
14
+ }
15
+ /**
16
+ * The session machinery the backend and the provider share: one live compute
17
+ * generation per workspace directory, each holding a cache lease so pruning
18
+ * never removes a workspace in use.
19
+ */
20
+ export interface BwrapRuntime {
21
+ /** Throws a setup message when the host cannot run bwrap. Probed once. */
22
+ assertBwrapAvailable(): void;
23
+ /** A throwaway session over a template being captured; it holds no lease. */
24
+ openTemplateSession(location: BwrapSessionLocation): BwrapSession;
25
+ /**
26
+ * Returns the live generation for this workspace, or starts a new one.
27
+ * `created` tells the caller whether per-generation state such as the
28
+ * network policy still needs to be applied.
29
+ */
30
+ openRuntimeSession(location: BwrapSessionLocation & {
31
+ readonly leaseRoot: string;
32
+ }): Promise<{
33
+ readonly session: BwrapSession;
34
+ readonly created: boolean;
35
+ }>;
36
+ /** Drops a deleted workspace's generation so the next open starts fresh. */
37
+ forgetRuntimeSession(workspaceDir: string): void;
38
+ }
39
+ export declare function createBwrapRuntime(input: {
40
+ readonly options: ResolvedBwrapSandboxOptions;
41
+ /** Injectable process launcher so logic is testable without bwrap. */
42
+ readonly runner?: ProcessRunner;
43
+ }): BwrapRuntime;
44
+ /** Writes seeds into a session; eve's `$HOME/.agents/skills` lands under the sandbox HOME. */
45
+ export declare function writeSeedFiles(session: BwrapSession, seedFiles: ReadonlyArray<BwrapSeed>): Promise<void>;
@@ -0,0 +1,90 @@
1
+ import { existsSync } from "node:fs";
2
+ import { basename } from "node:path";
3
+ import { createBwrapCacheLease, registerActiveCachePath } from "./cache.js";
4
+ import { WORKSPACE_ROOT } from "./paths.js";
5
+ import { createNodeProcessRunner, describeMissingPrereqs, isBwrapAvailable } from "./process.js";
6
+ import { createBwrapSession } from "./session.js";
7
+ const EVE_MODEL_SKILL_ROOT = "$HOME/.agents/skills";
8
+ export function createBwrapRuntime(input) {
9
+ const { options } = input;
10
+ const runner = input.runner ?? createNodeProcessRunner();
11
+ const generations = new Map();
12
+ // Probe only when running against the real bwrap; injected runners skip it.
13
+ const shouldProbe = input.runner === undefined;
14
+ let probed = false;
15
+ function openSession(location, generationId, onStopped) {
16
+ return createBwrapSession({
17
+ id: location.id,
18
+ workspaceDir: location.workspaceDir,
19
+ cacheRoots: location.cacheRoots,
20
+ runner,
21
+ options,
22
+ tags: location.tags,
23
+ generationId,
24
+ onStopped,
25
+ });
26
+ }
27
+ return {
28
+ assertBwrapAvailable() {
29
+ if (!shouldProbe || probed)
30
+ return;
31
+ const missing = describeMissingPrereqs({
32
+ bwrapPresent: isBwrapAvailable(options.bwrapPath),
33
+ workspaceMountpointPresent: existsSync(WORKSPACE_ROOT),
34
+ bwrapPath: options.bwrapPath,
35
+ });
36
+ if (missing)
37
+ throw new Error(missing);
38
+ probed = true;
39
+ },
40
+ openTemplateSession(location) {
41
+ return openSession(location);
42
+ },
43
+ async openRuntimeSession(location) {
44
+ const current = generations.get(location.workspaceDir);
45
+ if (current && current.lifecycleState() !== "stopped") {
46
+ return { session: current, created: false };
47
+ }
48
+ const releaseActive = registerActiveCachePath(location.workspaceDir);
49
+ const activeLease = await createBwrapCacheLease({
50
+ cacheRoot: location.leaseRoot,
51
+ sessionId: basename(location.workspaceDir),
52
+ });
53
+ let session;
54
+ try {
55
+ session = openSession(location, activeLease.lease.generationId, async () => {
56
+ releaseActive();
57
+ await activeLease.release();
58
+ });
59
+ }
60
+ catch (error) {
61
+ releaseActive();
62
+ await activeLease.release();
63
+ throw error;
64
+ }
65
+ generations.set(location.workspaceDir, session);
66
+ return { session, created: true };
67
+ },
68
+ forgetRuntimeSession(workspaceDir) {
69
+ generations.delete(workspaceDir);
70
+ },
71
+ };
72
+ }
73
+ function resolveSeedPath(seedPath) {
74
+ if (seedPath === EVE_MODEL_SKILL_ROOT || seedPath.startsWith(`${EVE_MODEL_SKILL_ROOT}/`)) {
75
+ return `${WORKSPACE_ROOT}/.agents/skills${seedPath.slice(EVE_MODEL_SKILL_ROOT.length)}`;
76
+ }
77
+ return seedPath;
78
+ }
79
+ /** Writes seeds into a session; eve's `$HOME/.agents/skills` lands under the sandbox HOME. */
80
+ export async function writeSeedFiles(session, seedFiles) {
81
+ for (const seed of seedFiles) {
82
+ const seedPath = resolveSeedPath(seed.path);
83
+ if (typeof seed.content === "string") {
84
+ await session.writeTextFile({ path: seedPath, content: seed.content });
85
+ }
86
+ else {
87
+ await session.writeBinaryFile({ path: seedPath, content: seed.content });
88
+ }
89
+ }
90
+ }
package/dist/session.d.ts CHANGED
@@ -1,10 +1,15 @@
1
- import type { SandboxSession } from "eve/sandbox";
1
+ import type { SandboxNetworkPolicy, SandboxSession } from "eve/sandbox";
2
2
  import type { ResolvedBwrapSandboxOptions } from "./options.js";
3
3
  import type { ProcessRunner } from "./process.js";
4
4
  export interface CreateBwrapSessionInput {
5
5
  readonly id: string;
6
6
  readonly workspaceDir: string;
7
- readonly appRoot: string;
7
+ /**
8
+ * Host directories holding templates and session workspaces. Every one is
9
+ * masked with an empty tmpfs so a sandboxed command can never read another
10
+ * session's workspace or a template it was not cloned from.
11
+ */
12
+ readonly cacheRoots: readonly string[];
8
13
  readonly runner: ProcessRunner;
9
14
  readonly options: ResolvedBwrapSandboxOptions;
10
15
  readonly generationId?: string;
@@ -17,6 +22,13 @@ export interface CreateBwrapSessionInput {
17
22
  * session tracks the processes it spawned and can terminate them on demand.
18
23
  */
19
24
  export type BwrapSession = SandboxSession & {
25
+ /**
26
+ * Stable identifier of the durable session this handle wraps. eve 0.64
27
+ * dropped `id` from `SandboxSession`, but every earlier line requires it.
28
+ */
29
+ readonly id: string;
30
+ /** Coarse egress switch; always present here, optional on eve 0.64's `SandboxSession`. */
31
+ setNetworkPolicy(policy: SandboxNetworkPolicy): Promise<void>;
20
32
  /** Kills every process this session spawned that has not yet exited. Idempotent. */
21
33
  killAll(): Promise<void>;
22
34
  /** Internal compute-generation state used to coordinate repeated handles. */
package/dist/session.js CHANGED
@@ -6,7 +6,7 @@ import { Readable } from "node:stream";
6
6
  import { pipeline } from "node:stream/promises";
7
7
  import { createWriteStream } from "node:fs";
8
8
  import { buildBwrapExecArgs, DEFAULT_SANDBOX_PATH } from "./args.js";
9
- import { isWithinWorkspaceReal, resolveBwrapCacheRoot, resolveWorkspacePath, toHostPath, WORKSPACE_ROOT, } from "./paths.js";
9
+ import { isWithinWorkspaceReal, resolveWorkspacePath, toHostPath, WORKSPACE_ROOT, } from "./paths.js";
10
10
  function isMissingFileError(error) {
11
11
  return (typeof error === "object" &&
12
12
  error !== null &&
@@ -62,7 +62,7 @@ function createRunAbortSignal(callerSignal, timeoutMs, outputSignal) {
62
62
  };
63
63
  }
64
64
  export function createBwrapSession(input) {
65
- const { id, workspaceDir, appRoot, runner, options } = input;
65
+ const { id, workspaceDir, cacheRoots, runner, options } = input;
66
66
  const generationId = input.generationId ?? randomUUID();
67
67
  const tags = input.tags ?? {};
68
68
  let networkPolicy = options.networkPolicy;
@@ -205,10 +205,7 @@ export function createBwrapSession(input) {
205
205
  ...options.env,
206
206
  ...spawnOptions.env,
207
207
  };
208
- const hidePaths = [
209
- resolveBwrapCacheRoot(appRoot, options.cacheDir),
210
- ...options.hidePaths,
211
- ].filter((path) => existsSync(path));
208
+ const hidePaths = [...new Set([...cacheRoots, ...options.hidePaths])].filter((path) => existsSync(path));
212
209
  const argv = buildBwrapExecArgs({
213
210
  bwrapPath: options.bwrapPath,
214
211
  workspaceDir,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@evelandhq/sandbox-bwrap",
3
- "version": "0.2.0",
4
- "description": "bubblewrap SandboxBackend for eve agents — real exec sandboxing without Docker or KVM",
3
+ "version": "0.4.0",
4
+ "description": "bubblewrap sandbox for eve agents — real exec sandboxing without Docker or KVM",
5
5
  "keywords": [
6
6
  "agent",
7
7
  "bubblewrap",
@@ -25,6 +25,11 @@
25
25
  "types": "./dist/index.d.ts",
26
26
  "import": "./dist/index.js",
27
27
  "default": "./dist/index.js"
28
+ },
29
+ "./provider": {
30
+ "types": "./dist/provider.d.ts",
31
+ "import": "./dist/provider.js",
32
+ "default": "./dist/provider.js"
28
33
  }
29
34
  },
30
35
  "scripts": {
@@ -40,8 +45,8 @@
40
45
  "devDependencies": {
41
46
  "@types/node": "^26.0.1",
42
47
  "ai": "^7.0.44",
43
- "eve": "0.37.0",
44
- "eve-floor": "npm:eve@0.27.13",
48
+ "eve": "0.64.1",
49
+ "eve-floor": "npm:eve@0.62.0",
45
50
  "oxfmt": "0.58.0",
46
51
  "oxlint": "1.73.0",
47
52
  "tsx": "^4.22.4",
@@ -49,7 +54,7 @@
49
54
  "vitest": "^4.1.9"
50
55
  },
51
56
  "peerDependencies": {
52
- "eve": ">=0.27.0 <1.0.0"
57
+ "eve": ">=0.62.0 <1.0.0"
53
58
  },
54
59
  "engines": {
55
60
  "node": ">=24.0.0"