@evelandhq/sandbox-bwrap 0.1.2 → 0.2.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
@@ -21,11 +21,12 @@ the sandbox module into the release directory at build time — `agent/sandbox.j
21
21
  agent, or `agent/sandbox/sandbox.js` when a sandbox folder exists, recursively for every
22
22
  subagent — and vendors this package's built output beside it, so agent projects never declare
23
23
  a deployment backend themselves. If a project shipped its own sandbox module, the build
24
- replaces that definition and reports it in the build log; authored `bootstrap()` and
25
- `onSession()` behavior is not used. The sibling `agent/sandbox/workspace/**` tree is preserved,
26
- so Eve still seeds those files into each Session's `/workspace`. Each Eveland Release supplies a
27
- distinct template revision, so Sessions created against a new Deployment see its updated seeds
28
- while existing durable Session workspaces remain untouched. The systemd runtime invokes
24
+ wraps that definition and reports it in the build log: Eveland overrides only `backend`, while
25
+ authored `bootstrap()`, `onSession()`, `description`, and `revalidationKey` remain active. The
26
+ sibling `agent/sandbox/workspace/**` tree is preserved, so Eve still seeds those files into each
27
+ Session's `/workspace`. Each Eveland Release supplies a distinct template revision, so Sessions
28
+ created against a new Deployment see its updated seeds while existing durable Session workspaces
29
+ remain untouched. The systemd runtime invokes
29
30
  bwrap as its unprivileged deployment user. The local Docker runtime installs bwrap inside the Agent
30
31
  image and grants the outer container only the capabilities nested bwrap requires; the
31
32
  Agent container still receives no Docker socket. Local `eve dev` is untouched — it never runs
@@ -70,57 +71,106 @@ which requires that nothing be left running afterwards, and `stop()`, which auth
70
71
  triggers mid-run through `ctx.getSandbox().stop()`. Backends with provider-side compute
71
72
  distinguish the two — a container to pause, a VM to snapshot; bwrap has no such resource,
72
73
  because the processes are the compute. Neither method touches the session's workspace
73
- directory — it is durable state, and remains available when the session reattaches or the
74
- next callback reopens it.
74
+ directory — it is durable state. Cleanup closes that compute generation to new commands;
75
+ the next backend `create()` opens a fresh generation over the same workspace. Repeated
76
+ `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.
75
78
 
76
79
  ### Options
77
80
 
78
- | Option | Default | Meaning |
79
- | ------------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
80
- | `env` | `{}` | Environment variables set for every sandboxed command. |
81
- | `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). |
82
- | `hidePaths` | `[]` | Extra host paths hidden from the sandbox (each covered by an empty tmpfs). |
83
- | `bwrapPath` | `"bwrap"` | bwrap executable to invoke. |
84
- | `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`. |
85
- | `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`. |
86
- | `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. |
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
95
+ policy is applied before `use()` returns the template or live Session, so subsequent commands in
96
+ that callback use the requested network boundary. Calling `use()` without options keeps the
97
+ backend's configured policy.
87
98
 
88
99
  ## How it works
89
100
 
90
- - **prewarm** (build time): runs the authored `bootstrap` inside bwrap against a
91
- staging directory, resolves Eve's `$HOME/.agents/skills/**` seed paths to
92
- `/workspace/.agents/skills/**`, writes seed files, then atomically renames it into
101
+ - **prewarm** (build time): resolves Eve's `$HOME/.agents/skills/**` seed paths to
102
+ `/workspace/.agents/skills/**`, writes every seed into a staging directory, then runs the
103
+ authored `bootstrap` inside bwrap so it can consume those canonical inputs, before atomically
104
+ renaming the result into
93
105
  `<cacheDir>/templates/<hash>` (`<cacheDir>` defaults to
94
106
  `<appRoot>/.eve/sandbox-cache/bwrap` when the `cacheDir` option is not set). Idempotent
95
107
  per template key + options hash; `templateRevision` participates in that hash.
96
- - **create** (runtime): clones the template into `<cacheDir>/sessions/<hash>` on first
97
- use. The directory IS the durable session state: it persists across reconnects and
98
- process restarts.
108
+ - **create** (runtime): atomically clones the template into
109
+ `<cacheDir>/sessions/<hash>` on first use. It first requests a filesystem reflink
110
+ (copy-on-write); filesystems without reflink support fall back to a normal recursive
111
+ copy. The selected strategy is recorded in cache metadata. The directory IS the durable
112
+ session state: it persists across reconnects and process restarts.
99
113
  - **run/spawn**: every command is one transient bwrap invocation —
100
114
  read-only host rootfs, the session directory bound read-write at `/workspace`,
101
115
  tmpfs `/tmp`, PID/IPC/UTS namespaces unshared, `--die-with-parent`.
102
- `run()` also applies `runTimeoutMs` and kills the entire detached process group
103
- when the deadline or caller AbortSignal fires; `spawn()` remains unbounded until
104
- its holder calls `kill()`, the Session is stopped, or the backend shuts down.
116
+ `run()` also applies `runTimeoutMs` and `maxOutputBytes`, and kills the entire
117
+ detached process group when a boundary or caller AbortSignal fires. Both APIs count
118
+ against `maxConcurrentProcesses`; `spawn()` remains long-running until its holder
119
+ calls `kill()`, the compute generation is stopped, or the backend shuts down.
105
120
  - **File I/O** (`readTextFile`, `writeFile`, …): host-side operations on the session
106
121
  directory; no subprocess. Writes outside `/workspace` are refused.
107
122
 
108
123
  ## Disk usage and cache management
109
124
 
110
- Session and template directories persist indefinitely under
111
- `<cacheDir>/{sessions,templates}` across process restarts and reconnects, enabling fast
112
- reattach when a session resumes. Each session key gets a directory that is reused for
113
- the lifetime of the session; each template is cached per (template key, options hash), with
114
- an optional release revision in the options hash,
115
- and reused across sessions. This backend intentionally does not prune either — its
116
- `stop()` and `shutdown()` methods only kill the session's live processes and leave the
117
- workspace on disk, so reattach is instant and stateless from the agent's perspective. On a long-lived
118
- host, this means the cache will grow with the number of durable sessions and unique
119
- templates, consuming disk space indefinitely. On eveland deployments this cache lives at
120
- `EVELAND_SANDBOX_CACHE_DIR` (one subdirectory per project), outside every release
121
- directory, precisely so that redeploying a project does not touch it.
122
-
123
- Reclaiming space today requires manual intervention: identify which sessions are known dead and delete their corresponding directories under the cache root. Automatic cache pruning (e.g., based on age or LRU) is a known gap and a planned follow-up.
125
+ Session and template directories persist under `<cacheDir>/{sessions,templates}` across
126
+ process restarts and reconnects, enabling fast reattach. `stop()` and `shutdown()` never
127
+ delete durable state, and this package never schedules automatic deletion. On Eveland,
128
+ the cache lives at `EVELAND_SANDBOX_CACHE_DIR` outside every release directory, so a
129
+ redeploy does not touch it.
130
+
131
+ Operator metadata lives separately under `<cacheDir>/metadata`; sandboxed code cannot
132
+ rewrite its own retention timestamps, tags, clone strategy, or active-generation leases.
133
+ Use the explicit APIs to inspect and reclaim storage:
134
+
135
+ ```ts
136
+ import { listBwrapCache, pruneBwrapCache } from "@evelandhq/sandbox-bwrap";
137
+
138
+ const location = { appRoot: "/srv/my-agent", cacheDir: "/var/lib/eveland/sandbox/project" };
139
+ const entries = await listBwrapCache(location);
140
+
141
+ // Dry-run is the default. Age and LRU policies may be combined independently
142
+ // for durable sessions and templates.
143
+ const preview = await pruneBwrapCache({
144
+ ...location,
145
+ sessions: { maxAgeMs: 30 * 24 * 60 * 60 * 1000 },
146
+ templates: { maxEntries: 10 },
147
+ });
148
+
149
+ // Apply only after inspecting preview.candidates and preview.skippedActive.
150
+ const applied = await pruneBwrapCache({
151
+ ...location,
152
+ sessions: { maxAgeMs: 30 * 24 * 60 * 60 * 1000 },
153
+ templates: { maxEntries: 10 },
154
+ dryRun: false,
155
+ });
156
+ ```
157
+
158
+ Active compute generations are skipped and rechecked immediately before deletion. The
159
+ backend uses both process-local reference counts and lease files outside the writable
160
+ workspace, so a separate pruning process can see active sessions. A process crash can
161
+ leave a conservative stale lease; inspect `listBwrapCacheLeases(location)` and confirm
162
+ the owning deployment is stopped before removing such a lease. Leases are not expired
163
+ automatically because deleting a live durable workspace is worse than retaining a false
164
+ positive.
165
+
166
+ ## Lifecycle observability
167
+
168
+ `onEvent` receives discriminated `generation.started`, `command.started`,
169
+ `command.finished`, `cleanup.started`, `cleanup.completed`, and `cleanup.failed` events.
170
+ They include session/generation/command identities, tags, PID/process-group identity when
171
+ available, duration, output byte counts, live-process counts, finish reason, and cleanup
172
+ errors. Events deliberately omit command text and output. Do not put secrets in `tags`;
173
+ the sink is operator-controlled telemetry, not part of the sandbox boundary.
124
174
 
125
175
  ## Security boundary
126
176
 
@@ -151,8 +201,9 @@ Reclaiming space today requires manual intervention: identify which sessions are
151
201
  container's filesystem, not the Docker host; no host root or Docker socket is mounted.
152
202
  - CPU, memory, and PID limits are inherited from whatever cgroup the agent runs in
153
203
  (on eveland, the deployment's Docker container or systemd unit covers sandbox
154
- children too). The backend adds only the `run()` wall-clock deadline; it does not
155
- create a per-command cgroup or impose resource quotas on `spawn()`.
204
+ children too). `maxConcurrentProcesses` is an admission bound, not a kernel PID or
205
+ thread quota; `runTimeoutMs` and `maxOutputBytes` bound one `run()`. The backend does
206
+ not create a per-command cgroup or impose CPU/memory quotas on `spawn()`.
156
207
  - Host-side write/remove calls (`writeFile`, `writeTextFile`, `writeBinaryFile`,
157
208
  `removePath`) verify containment with a realpath-aware check
158
209
  (`isWithinWorkspaceReal`): they resolve symlinks along the path and re-check that
@@ -177,8 +228,11 @@ Reclaiming space today requires manual intervention: identify which sessions are
177
228
  Eveland's generated local Docker image installs `bubblewrap` and `bash`, creates
178
229
  `/workspace`, and starts the outer Agent container with its default capability set
179
230
  dropped, `SYS_ADMIN` and `NET_ADMIN` added for bwrap namespaces, `no-new-privileges`,
180
- and `seccomp=unconfined`. This is a local-development boundary; the supported Linux
181
- production topology uses the unprivileged systemd path below.
231
+ `seccomp=unconfined`, and Docker `--init`. The init process is required to reap orphaned
232
+ bwrap descendants; without it they can accumulate as zombies until the container PID
233
+ limit is exhausted. Containers created before enabling it must be recreated. This is a
234
+ local-development boundary; the supported Linux production topology uses the
235
+ unprivileged systemd path below, where host PID 1 already reaps orphans.
182
236
 
183
237
  - Linux with unprivileged user namespaces available to the calling process. Ubuntu's
184
238
  packaged bubblewrap (0.9.0-1ubuntu0.1 on 24.04) ships **no** AppArmor profile. Since
@@ -218,7 +272,9 @@ production topology uses the unprivileged systemd path below.
218
272
  - Works under systemd hardening (`NoNewPrivileges=yes`, `ProtectSystem=strict`):
219
273
  apt's `bwrap` is not setuid, so it needs no privilege escalation to run — but it
220
274
  still needs the AppArmor profile above to create a user namespace as an
221
- unprivileged user.
275
+ unprivileged user. There is no Docker-style `--init` setting to add to a systemd
276
+ Deployment; systemd already provides the host init/subreaper and `TasksMax` cgroup
277
+ boundary.
222
278
 
223
279
  ## Testing
224
280
 
@@ -229,7 +285,8 @@ production topology uses the unprivileged systemd path below.
229
285
  It provisions a Lima VM (`brew install lima`), streams this worktree in, and runs the
230
286
  test as an unprivileged user under the systemd hardening a deployed eve agent actually
231
287
  gets — `NoNewPrivileges=yes`, `ProtectSystem=strict`, `PrivateTmp=yes`. Prints
232
- `BWRAP SMOKE OK`. Run this before pushing anything that touches `src/args.ts` or
288
+ `BWRAP SMOKE OK`. The test also churns 500 short bwrap commands and checks that the
289
+ zombie count returns to baseline. Run this before pushing anything that touches `src/args.ts` or
233
290
  `src/process.ts`: CI's smoke job covers the unprivileged-user case but not the systemd
234
291
  constraints, and argv that looks right is not the same as a kernel that accepts it.
235
292
  - `pnpm tsx src/integration/bwrap-backend-smoke.ts` — the same test, run directly. Needs
package/dist/backend.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { SandboxBackend } from "eve/sandbox";
2
- import type { BwrapSandboxCreateOptions } from "./options.js";
2
+ import type { BwrapSandboxCreateOptions, BwrapSandboxUseOptions } from "./options.js";
3
3
  import type { ProcessRunner } from "./process.js";
4
+ import { type BwrapDirectoryCopier } from "./cache.js";
4
5
  /**
5
6
  * Stable backend name. Participates in eve's template/session cache-key
6
7
  * derivation and persisted reconnect state — never change it.
@@ -10,5 +11,7 @@ export interface CreateBwrapSandboxBackendInput {
10
11
  readonly createOptions?: BwrapSandboxCreateOptions;
11
12
  /** Injectable process launcher so backend logic is testable without bwrap. */
12
13
  readonly runner?: ProcessRunner;
14
+ /** Injectable clone primitive for filesystem-capability tests. */
15
+ readonly copyDirectory?: BwrapDirectoryCopier;
13
16
  }
14
- export declare function createBwrapSandboxBackend(input?: CreateBwrapSandboxBackendInput): SandboxBackend;
17
+ export declare function createBwrapSandboxBackend(input?: CreateBwrapSandboxBackendInput): SandboxBackend<BwrapSandboxUseOptions, BwrapSandboxUseOptions>;
package/dist/backend.js CHANGED
@@ -1,37 +1,24 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
- import { cp, mkdir, rename, rm } from "node:fs/promises";
4
- import { dirname } from "node:path";
3
+ import { mkdir, rename, rm } from "node:fs/promises";
4
+ import { basename } from "node:path";
5
5
  import { SandboxTemplateNotProvisionedError } from "eve/sandbox";
6
6
  import { createBwrapOptionsHash, resolveBwrapSandboxOptions } from "./options.js";
7
- import { resolveSessionPath, resolveTemplatePath, WORKSPACE_ROOT } from "./paths.js";
7
+ import { resolveBwrapCacheRoot, resolveSessionPath, resolveTemplatePath, WORKSPACE_ROOT, } from "./paths.js";
8
8
  import { createNodeProcessRunner, describeMissingPrereqs, isBwrapAvailable } from "./process.js";
9
9
  import { createBwrapSession } from "./session.js";
10
+ import { cloneDirectoryAtomically, createBwrapCacheLease, registerActiveCachePath, touchCacheMetadata, } from "./cache.js";
10
11
  const EVE_MODEL_SKILL_ROOT = "$HOME/.agents/skills";
11
12
  /**
12
13
  * Stable backend name. Participates in eve's template/session cache-key
13
14
  * derivation and persisted reconnect state — never change it.
14
15
  */
15
16
  export const BWRAP_BACKEND_NAME = "bwrap";
16
- async function copyDirectoryAtomically(sourcePath, targetPath) {
17
- const tmpPath = `${targetPath}.${randomUUID()}.tmp`;
18
- await mkdir(dirname(targetPath), { recursive: true });
19
- try {
20
- await cp(sourcePath, tmpPath, { recursive: true });
21
- await rename(tmpPath, targetPath);
22
- }
23
- catch (error) {
24
- await rm(tmpPath, { force: true, recursive: true }).catch(() => { });
25
- // A concurrent writer winning the rename race is success, not failure.
26
- if (existsSync(targetPath))
27
- return;
28
- throw error;
29
- }
30
- }
31
17
  export function createBwrapSandboxBackend(input = {}) {
32
18
  const options = resolveBwrapSandboxOptions(input.createOptions);
33
19
  const optionsHash = createBwrapOptionsHash(options);
34
20
  const runner = input.runner ?? createNodeProcessRunner();
21
+ const generations = new Map();
35
22
  // Probe only when running against the real bwrap; injected runners skip it.
36
23
  const shouldProbe = input.runner === undefined;
37
24
  let probed = false;
@@ -47,8 +34,42 @@ export function createBwrapSandboxBackend(input = {}) {
47
34
  throw new Error(missing);
48
35
  probed = true;
49
36
  }
50
- function openSession(id, workspaceDir, appRoot) {
51
- return createBwrapSession({ id, workspaceDir, appRoot, runner, options });
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;
52
73
  }
53
74
  function resolveSeedPath(seedPath) {
54
75
  if (seedPath === EVE_MODEL_SKILL_ROOT || seedPath.startsWith(`${EVE_MODEL_SKILL_ROOT}/`)) {
@@ -67,38 +88,58 @@ export function createBwrapSandboxBackend(input = {}) {
67
88
  }
68
89
  }
69
90
  }
91
+ async function useSession(session, useOptions) {
92
+ if (useOptions?.networkPolicy !== undefined) {
93
+ await session.setNetworkPolicy(useOptions.networkPolicy);
94
+ }
95
+ return session;
96
+ }
70
97
  return {
71
98
  name: BWRAP_BACKEND_NAME,
72
99
  async prewarm({ templateKey, bootstrap, seedFiles, log, runtimeContext }) {
73
100
  assertBwrapAvailable();
74
101
  const templatePath = resolveTemplatePath(runtimeContext.appRoot, templateKey, optionsHash, options.cacheDir);
75
- if (existsSync(templatePath))
102
+ const touchTemplate = async () => await touchCacheMetadata({
103
+ cacheRoot: resolveBwrapCacheRoot(runtimeContext.appRoot, options.cacheDir),
104
+ kind: "template",
105
+ id: basename(templatePath),
106
+ templateRevision: options.templateRevision,
107
+ });
108
+ if (existsSync(templatePath)) {
109
+ await touchTemplate();
76
110
  return { reused: true };
111
+ }
77
112
  log?.(`bwrap: capturing template for ${templateKey}`);
78
113
  const stagingPath = `${templatePath}.staging-${randomUUID()}`;
79
114
  await mkdir(stagingPath, { recursive: true });
80
115
  try {
81
116
  const session = openSession(templateKey, stagingPath, runtimeContext.appRoot);
82
- if (bootstrap)
83
- await bootstrap({ use: async () => session });
84
117
  await writeSeedFiles(session, seedFiles);
118
+ if (bootstrap) {
119
+ await bootstrap({ use: async (useOptions) => await useSession(session, useOptions) });
120
+ }
85
121
  await rename(stagingPath, templatePath);
86
122
  }
87
123
  catch (error) {
88
124
  await rm(stagingPath, { force: true, recursive: true }).catch(() => { });
89
125
  // A concurrent prewarm winning the race is reuse, not failure.
90
- if (existsSync(templatePath))
126
+ if (existsSync(templatePath)) {
127
+ await touchTemplate();
91
128
  return { reused: true };
129
+ }
92
130
  throw error;
93
131
  }
132
+ await touchTemplate();
94
133
  return { reused: false };
95
134
  },
96
- async create({ templateKey, sessionKey, runtimeContext }) {
135
+ async create({ templateKey, sessionKey, runtimeContext, tags }) {
97
136
  assertBwrapAvailable();
98
137
  const sessionPath = resolveSessionPath(runtimeContext.appRoot, sessionKey, options.cacheDir);
138
+ let cloneStrategy = "existing";
99
139
  if (!existsSync(sessionPath)) {
100
140
  if (templateKey === null) {
101
141
  await mkdir(sessionPath, { recursive: true });
142
+ cloneStrategy = "empty";
102
143
  }
103
144
  else {
104
145
  const templatePath = resolveTemplatePath(runtimeContext.appRoot, templateKey, optionsHash, options.cacheDir);
@@ -108,13 +149,24 @@ export function createBwrapSandboxBackend(input = {}) {
108
149
  templateKey,
109
150
  });
110
151
  }
111
- await copyDirectoryAtomically(templatePath, sessionPath);
152
+ cloneStrategy = await cloneDirectoryAtomically({
153
+ sourcePath: templatePath,
154
+ targetPath: sessionPath,
155
+ copyDirectory: input.copyDirectory,
156
+ });
112
157
  }
113
158
  }
114
- const session = openSession(sessionKey, sessionPath, runtimeContext.appRoot);
159
+ await touchCacheMetadata({
160
+ cacheRoot: resolveBwrapCacheRoot(runtimeContext.appRoot, options.cacheDir),
161
+ kind: "session",
162
+ id: basename(sessionPath),
163
+ tags,
164
+ cloneStrategy,
165
+ });
166
+ const session = await openRuntimeSession(sessionKey, sessionPath, runtimeContext.appRoot, tags);
115
167
  return {
116
168
  session,
117
- useSessionFn: async () => session,
169
+ useSessionFn: async (useOptions) => await useSession(session, useOptions),
118
170
  async captureState() {
119
171
  return { backendName: BWRAP_BACKEND_NAME, metadata: {}, sessionKey };
120
172
  },
@@ -0,0 +1,75 @@
1
+ export type BwrapCacheEntryKind = "session" | "template";
2
+ export type BwrapCloneStrategy = "empty" | "reflink" | "copy" | "existing";
3
+ export interface BwrapCacheMetadata {
4
+ readonly schemaVersion: 1;
5
+ readonly kind: BwrapCacheEntryKind;
6
+ readonly id: string;
7
+ readonly createdAt: string;
8
+ readonly lastUsedAt: string;
9
+ readonly tags?: Readonly<Record<string, string>>;
10
+ readonly cloneStrategy?: BwrapCloneStrategy;
11
+ readonly templateRevision?: string;
12
+ }
13
+ export interface BwrapCacheEntry extends BwrapCacheMetadata {
14
+ readonly path: string;
15
+ readonly sizeBytes: number;
16
+ readonly active: boolean;
17
+ readonly metadataPresent: boolean;
18
+ }
19
+ export interface BwrapCacheLocation {
20
+ readonly appRoot: string;
21
+ readonly cacheDir?: string | null;
22
+ }
23
+ export interface BwrapCachePrunePolicy {
24
+ readonly maxAgeMs?: number;
25
+ readonly maxEntries?: number;
26
+ }
27
+ export interface BwrapCachePruneInput extends BwrapCacheLocation {
28
+ readonly dryRun?: boolean;
29
+ readonly sessions?: BwrapCachePrunePolicy;
30
+ readonly templates?: BwrapCachePrunePolicy;
31
+ readonly now?: Date;
32
+ }
33
+ export interface BwrapCachePruneResult {
34
+ readonly dryRun: boolean;
35
+ readonly candidates: readonly BwrapCacheEntry[];
36
+ readonly removed: readonly BwrapCacheEntry[];
37
+ readonly skippedActive: readonly BwrapCacheEntry[];
38
+ readonly retained: readonly BwrapCacheEntry[];
39
+ }
40
+ export interface BwrapCacheLease {
41
+ readonly sessionId: string;
42
+ readonly generationId: string;
43
+ readonly pid: number;
44
+ readonly createdAt: string;
45
+ readonly path: string;
46
+ }
47
+ export declare function registerActiveCachePath(path: string): () => void;
48
+ export declare function createBwrapCacheLease(input: {
49
+ cacheRoot: string;
50
+ sessionId: string;
51
+ }): Promise<{
52
+ lease: BwrapCacheLease;
53
+ release(): Promise<void>;
54
+ }>;
55
+ export declare function listBwrapCacheLeases(input: BwrapCacheLocation): Promise<BwrapCacheLease[]>;
56
+ export declare function listBwrapCache(input: BwrapCacheLocation): Promise<BwrapCacheEntry[]>;
57
+ export declare function pruneBwrapCache(input: BwrapCachePruneInput): Promise<BwrapCachePruneResult>;
58
+ export type BwrapDirectoryCopier = (source: string, target: string, options: {
59
+ recursive: true;
60
+ mode?: number;
61
+ }) => Promise<void>;
62
+ export declare function cloneDirectoryAtomically(input: {
63
+ sourcePath: string;
64
+ targetPath: string;
65
+ copyDirectory?: BwrapDirectoryCopier;
66
+ }): Promise<BwrapCloneStrategy>;
67
+ export declare function touchCacheMetadata(input: {
68
+ cacheRoot: string;
69
+ kind: BwrapCacheEntryKind;
70
+ id: string;
71
+ tags?: Readonly<Record<string, string>>;
72
+ cloneStrategy?: BwrapCloneStrategy;
73
+ templateRevision?: string | null;
74
+ now?: Date;
75
+ }): Promise<BwrapCacheMetadata>;