@molecule/api-code-sandbox-e2b 1.1.0 → 1.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/dist/provider.js CHANGED
@@ -12,6 +12,22 @@
12
12
  const DEFAULT_PREVIEW_PORT = 5173;
13
13
  /** Default sandbox lifetime before E2B auto-pauses (control plane extends per heartbeat). */
14
14
  const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000;
15
+ /**
16
+ * Default lifetime for a spawned process. The SDK's own default is 60 s, which
17
+ * is a sane cap for a one-shot command and useless for the things `spawn` is
18
+ * for — a language server or a terminal session that must survive as long as the
19
+ * editor tab does.
20
+ */
21
+ const DEFAULT_SPAWN_TIMEOUT_MS = 60 * 60 * 1000;
22
+ /**
23
+ * How long to wait for a started command before ASKING whether it is still
24
+ * running. Long enough that an ordinary command simply finishes first; short
25
+ * enough that a launch whose stream is held open by a stranded descendant costs
26
+ * this instead of the caller's whole timeout.
27
+ */
28
+ const SETTLE_PROBE_MS = 1_500;
29
+ /** Default lifetime for a PTY session; same reasoning as {@link DEFAULT_SPAWN_TIMEOUT_MS}. */
30
+ const DEFAULT_PTY_TIMEOUT_MS = 60 * 60 * 1000;
15
31
  /**
16
32
  * E2B's "all destinations" selector (`0.0.0.0/0`, the SDK's `ALL_TRAFFIC`).
17
33
  * Put in `denyOut` alongside an `allowOut` list to make egress deny-by-default —
@@ -28,13 +44,27 @@ const EGRESS_PROBE_DENY = 'example.com';
28
44
  * Imported lazily so the SDK is only required when the bond is actually used.
29
45
  */
30
46
  async function defaultClient(apiKey) {
31
- const { Sandbox, SandboxNotFoundError } = await import('e2b');
47
+ const { Sandbox, SandboxNotFoundError, Volume: VolumeClass } = await import('e2b');
32
48
  const S = Sandbox;
49
+ const V = VolumeClass;
33
50
  return {
34
51
  create: (templateId, opts) => S.create(templateId, { apiKey, ...opts }),
35
52
  connect: (id, opts) => S.connect(id, { apiKey, ...opts }),
36
53
  kill: (id, opts) => S.kill(id, { apiKey, ...opts }),
37
54
  getInfo: (id, opts) => S.getInfo(id, { apiKey, ...opts }),
55
+ createVolume: (name) => V.create(name, { apiKey }),
56
+ listVolumes: () => V.list({ apiKey }),
57
+ destroyVolume: (volumeId) => V.destroy(volumeId, { apiKey }),
58
+ createSnapshot: (sandboxId, name) => S.createSnapshot(sandboxId, { apiKey, name }),
59
+ async listSnapshots(opts) {
60
+ const pager = S.listSnapshots({ apiKey, ...opts });
61
+ const out = [];
62
+ do {
63
+ out.push(...(await pager.nextItems()));
64
+ } while (pager.hasNext && !opts?.limit);
65
+ return out;
66
+ },
67
+ deleteSnapshot: (snapshotId) => S.deleteSnapshot(snapshotId, { apiKey }),
38
68
  // The SDK raises `SandboxNotFoundError` from `getInfo`/`connect` for exactly
39
69
  // one condition — the API answered 404 — and routes every other failure to a
40
70
  // different class. That makes it a POSITIVE not-found, which is the whole
@@ -48,10 +78,18 @@ async function defaultClient(apiKey) {
48
78
  const out = [];
49
79
  const push = (items) => {
50
80
  // Carry the listed state through: it is the only way a caller can skip a
51
- // PAUSED sandbox, and building a handle for one would resume it.
81
+ // PAUSED sandbox, and building a handle for one would resume it. `name`
82
+ // (the SDK's template alias) and `volumeMounts` come along for the same
83
+ // reason: they are how "is this snapshot/volume still in use?" is
84
+ // OBSERVED rather than assumed, and a wrong answer there is data loss.
52
85
  for (const it of items)
53
86
  if (it?.sandboxId)
54
- out.push({ sandboxId: it.sandboxId, state: it.state });
87
+ out.push({
88
+ sandboxId: it.sandboxId,
89
+ state: it.state,
90
+ name: it.name,
91
+ volumeMounts: it.volumeMounts,
92
+ });
55
93
  };
56
94
  const r = await Promise.resolve(result).catch(() => result);
57
95
  if (Array.isArray(r)) {
@@ -75,6 +113,21 @@ async function defaultClient(apiKey) {
75
113
  },
76
114
  };
77
115
  }
116
+ /**
117
+ * The caller's own name for a snapshot, recovered from E2B's namespaced form.
118
+ *
119
+ * E2B answers with `<team-slug>/<name>:<tag>` — a reference the caller never
120
+ * supplied and must never have to parse. This is the one place that translation
121
+ * lives, so `templateId` means the same string going in and coming out.
122
+ *
123
+ * @param snapshot - The snapshot as E2B reported it.
124
+ * @returns The bare name.
125
+ */
126
+ function bareSnapshotName(snapshot) {
127
+ const full = snapshot.names?.[0] ?? snapshot.snapshotId;
128
+ const withoutTag = full.includes(':') ? full.slice(0, full.lastIndexOf(':')) : full;
129
+ return withoutTag.includes('/') ? withoutTag.slice(withoutTag.lastIndexOf('/') + 1) : withoutTag;
130
+ }
78
131
  /**
79
132
  * Single-quote a value for POSIX `sh` so an arbitrary path is one argument.
80
133
  *
@@ -196,19 +249,33 @@ class E2BSandbox {
196
249
  /**
197
250
  * Pause the sandbox (E2B FS + memory snapshot).
198
251
  *
252
+ * Either the sandbox ends up suspended or this THROWS. It must never return a
253
+ * success-shaped outcome for a sandbox that is still running: the caller's next
254
+ * act is to record the sandbox as stopped, and a control plane that believes a
255
+ * running sandbox is asleep bills for it and — where the timeout kills rather
256
+ * than pauses — watches it die at its deadline instead of hibernating.
257
+ *
199
258
  * @returns The outcome; `processesPreserved` is true because the memory
200
259
  * snapshot restores the process tree on resume.
260
+ * @throws {Error} When the SDK exposes no pause at all, or the pause call fails.
201
261
  */
202
262
  async hibernate() {
203
- const pause = this.sbx.betaPause ?? this.sbx.pause;
263
+ // Prefer the supported method; `betaPause` is its deprecated alias and
264
+ // delegates to the same endpoint.
265
+ const pause = this.sbx.pause ?? this.sbx.betaPause;
204
266
  if (!pause) {
205
- // No pause available — nothing we can honestly suspend; report it.
206
- return { processesPreserved: true, mechanism: 'noop', detail: 'pause not supported by SDK' };
267
+ throw new Error('hibernate: this e2b SDK build exposes neither pause() nor betaPause(); the sandbox is still running');
207
268
  }
208
- await pause.call(this.sbx);
269
+ // `false` means the API answered 409 — it was already paused. That is the
270
+ // requested end state, so it is a success, just not one this call caused.
271
+ const paused = await pause.call(this.sbx);
209
272
  this.status = 'sleeping';
210
273
  // E2B pause snapshots FS + memory, so the process tree survives resume.
211
- return { processesPreserved: true, mechanism: 'pause' };
274
+ return {
275
+ processesPreserved: true,
276
+ mechanism: 'pause',
277
+ ...(paused === false ? { detail: 'the sandbox was already paused' } : {}),
278
+ };
212
279
  }
213
280
  /**
214
281
  * Resume the sandbox by reconnecting to a fresh live instance for its id.
@@ -223,6 +290,66 @@ class E2BSandbox {
223
290
  this.previewUrl = this.getPreviewUrl();
224
291
  return { processesPreserved: true, mechanism: 'resume' };
225
292
  }
293
+ /**
294
+ * Wait for a started command, and stop waiting once the command is OVER.
295
+ *
296
+ * `wait()` resolves when the output STREAM ends, which is not the same event
297
+ * as the command finishing: a launch that leaves anything behind holding the
298
+ * inherited stdio — a mis-composed `guard && nohup … &`, a daemon that does not
299
+ * close its descriptors, a user's `npm run dev &` — keeps that stream open long
300
+ * after the process exited, and the caller then blocks for its whole timeout on
301
+ * a command that finished in milliseconds. Three sidecar launches per boot cost
302
+ * 15 s that way.
303
+ *
304
+ * So when the wait outlives a short settle window, this ASKS the sandbox
305
+ * whether the started pid is still there. Gone means the command is over and
306
+ * the accumulated output is the whole of it; alive means it is genuinely still
307
+ * running and the caller's own timeout is the right bound. Never a guess from
308
+ * the shape of the command.
309
+ *
310
+ * @param handle - The started command.
311
+ * @returns The command's result.
312
+ */
313
+ async waitForCommand(handle) {
314
+ let settled = false;
315
+ const finished = handle.wait().finally(() => {
316
+ settled = true;
317
+ });
318
+ const raced = await Promise.race([
319
+ finished,
320
+ new Promise((resolve) => setTimeout(() => resolve(null), SETTLE_PROBE_MS)),
321
+ ]);
322
+ if (raced)
323
+ return raced;
324
+ if (settled)
325
+ return finished;
326
+ const alive = await this.isProcessAlive(handle.pid);
327
+ if (alive)
328
+ return finished;
329
+ // The process is gone; whatever still holds the stream is not it.
330
+ return {
331
+ stdout: handle.stdout ?? '',
332
+ stderr: handle.stderr ?? '',
333
+ exitCode: handle.exitCode ?? 0,
334
+ };
335
+ }
336
+ /**
337
+ * Ask the sandbox whether a pid is still running.
338
+ *
339
+ * @param pid - The process id to check.
340
+ * @returns True when the process exists; true also when the check itself could
341
+ * not run, because "I could not look" must never be reported as "it is gone".
342
+ */
343
+ async isProcessAlive(pid) {
344
+ try {
345
+ const probe = await this.sbx.commands.run(`kill -0 ${Math.floor(pid)} 2>/dev/null && echo MOL_ALIVE || echo MOL_GONE`, { timeoutMs: 10_000, background: true });
346
+ const result = await probe.wait();
347
+ return !result.stdout.includes('MOL_GONE');
348
+ }
349
+ catch (_error) {
350
+ return true;
351
+ }
352
+ }
226
353
  /**
227
354
  * Run a command to completion in the sandbox.
228
355
  *
@@ -238,27 +365,37 @@ class E2BSandbox {
238
365
  * @returns stdout, stderr and the exit code (even when non-zero).
239
366
  */
240
367
  async exec(command, opts) {
241
- // A shell-backgrounded command (`… &` / `… & true`) must NOT block exec —
242
- // that is the whole point of `&`. E2B's `commands.run` waits for the process
243
- // group anyway (a detached `nohup`/`setsid` dev-server launch times out the
244
- // request), so route backgrounded commands through the SDK's native
245
- // `background: true`, which returns immediately. The control plane launches
246
- // every dev server this way (`nohup sh -c '…' >log 2>&1 & true`), so this
247
- // makes the provider-agnostic launch code work unchanged on E2B.
248
- if (/&\s*(?:true\s*)?$/.test(command)) {
249
- await this.sbx.commands.run(command, {
250
- cwd: opts?.cwd,
251
- envs: opts?.env,
252
- background: true,
253
- });
254
- return { stdout: '', stderr: '', exitCode: 0 };
255
- }
368
+ // START the command, then wait on its HANDLE — never `run()` inline.
369
+ //
370
+ // Inline `run()` waits for the process GROUP, so any command that leaves a
371
+ // detached child behind (`nohup … & true`, and every dev-server launch on
372
+ // this platform) blocks until the request deadline and then throws, while
373
+ // the child is in fact running fine. The bond used to dodge that by
374
+ // sniffing the command string for a trailing `&` and returning a fabricated
375
+ // `exitCode: 0` — which classified the shell text instead of observing the
376
+ // process, so it lied twice: a launch shaped `… & fi` (an `if` guard around
377
+ // the `&`) did not match and hung for the full timeout, and a user's
378
+ // `npm run build &` in the terminal reported instant success with no output
379
+ // whether or not it started.
380
+ //
381
+ // The handle removes the guesswork: `wait()` resolves when the STARTED
382
+ // process exits, so a detached launch returns in milliseconds WITH its real
383
+ // exit code, and an ordinary command still returns its full stdout/stderr.
384
+ // Verified live against E2B: a `nohup … >log 2>&1 &` launch returns in
385
+ // ~200 ms with the child still running, and its deadline does not kill it.
256
386
  try {
257
- const r = await this.sbx.commands.run(command, {
387
+ const handle = await this.sbx.commands.run(command, {
258
388
  cwd: opts?.cwd,
259
389
  timeoutMs: opts?.timeout,
260
390
  envs: opts?.env,
391
+ background: true,
261
392
  });
393
+ // The core contract is RETURN, not throw: a non-zero exit is data
394
+ // (`exitCode`), not an error — control-plane code reads it to decide
395
+ // (install sentinels, health probes, existence checks). E2B raises
396
+ // `CommandExitError` for that, so it is caught and mapped below; only a
397
+ // genuine infrastructure failure (no `.result`) rethrows.
398
+ const r = await this.waitForCommand(handle);
262
399
  return { stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode };
263
400
  }
264
401
  catch (error) {
@@ -270,6 +407,132 @@ class E2BSandbox {
270
407
  throw error;
271
408
  }
272
409
  }
410
+ /**
411
+ * Spawn a long-running process with streaming I/O, optionally on a PTY.
412
+ *
413
+ * This is the capability an interactive terminal and a language server need
414
+ * and `exec` cannot provide: a process that outlives the call, streams as it
415
+ * runs, takes input, and can be killed. Without it the IDE has no editor
416
+ * intelligence at all (the LSP socket closes 4003 "Sandbox does not support
417
+ * spawn") and no cancellable terminal.
418
+ *
419
+ * With {@link SpawnOptions.pty} the process gets a real controlling terminal,
420
+ * so Ctrl-C (`0x03`) becomes SIGINT for the foreground job and `resize()`
421
+ * renegotiates the width. Without it, stdio is plain pipes — which is what a
422
+ * language server requires, since a PTY would echo input and translate
423
+ * newlines straight through its framed JSON-RPC.
424
+ *
425
+ * A PTY request is REJECTED rather than downgraded when the SDK build has no
426
+ * `pty` module: a terminal that silently got pipes is a terminal whose Ctrl-C
427
+ * does nothing, and the caller must be able to tell.
428
+ *
429
+ * @param command - The command to run. Ignored when a PTY is requested — E2B
430
+ * starts the user's login shell on the PTY, which is the point.
431
+ * @param opts - Working directory, env, timeout, and optional PTY size.
432
+ * @returns A handle with streaming I/O, stdin, kill, and (on a PTY) resize.
433
+ */
434
+ async spawn(command, opts) {
435
+ const stdoutListeners = [];
436
+ const stderrListeners = [];
437
+ const closeListeners = [];
438
+ let closed = false;
439
+ const fireClose = () => {
440
+ if (closed)
441
+ return;
442
+ closed = true;
443
+ for (const cb of closeListeners)
444
+ cb();
445
+ };
446
+ const emit = (listeners, data) => {
447
+ for (const cb of listeners)
448
+ cb(data);
449
+ };
450
+ let handle;
451
+ let resize;
452
+ if (opts?.pty) {
453
+ const pty = this.sbx.pty;
454
+ if (!pty) {
455
+ throw new Error('E2B SDK build does not expose a pty module; cannot spawn a terminal');
456
+ }
457
+ const decoder = new TextDecoder();
458
+ handle = await pty.create({
459
+ cols: opts.pty.cols,
460
+ rows: opts.pty.rows,
461
+ cwd: opts.cwd,
462
+ envs: opts.env,
463
+ // A terminal must outlive the default 60 s command deadline; the caller
464
+ // decides how long a session may sit idle.
465
+ timeoutMs: opts.timeout ?? DEFAULT_PTY_TIMEOUT_MS,
466
+ // A PTY merges stderr into the terminal stream by definition — there is
467
+ // one file descriptor, which is why a terminal shows them interleaved.
468
+ onData: (data) => emit(stdoutListeners, decoder.decode(data, { stream: true })),
469
+ });
470
+ const pid = handle.pid;
471
+ resize = (size) => {
472
+ void pty.resize(pid, size).catch((_error) => {
473
+ // Best-effort: a resize races the process exiting, and a failed one
474
+ // only means the remote width is stale — never a reason to tear down
475
+ // a live session.
476
+ });
477
+ };
478
+ }
479
+ else {
480
+ handle = await this.sbx.commands.run(command, {
481
+ cwd: opts?.cwd,
482
+ envs: opts?.env,
483
+ timeoutMs: opts?.timeout ?? DEFAULT_SPAWN_TIMEOUT_MS,
484
+ background: true,
485
+ stdin: true,
486
+ onStdout: (data) => emit(stdoutListeners, data),
487
+ onStderr: (data) => emit(stderrListeners, data),
488
+ });
489
+ }
490
+ // `wait()` settles when the process exits, however it exited (including the
491
+ // deadline), so it is the one signal that covers every close path.
492
+ handle
493
+ .wait()
494
+ .then(fireClose)
495
+ .catch((_error) => {
496
+ // A non-zero exit / timeout rejects here; for a spawn the only fact that
497
+ // matters is that the process is over, and the caller learns it the same
498
+ // way either way.
499
+ fireClose();
500
+ });
501
+ const encoder = new TextEncoder();
502
+ const ptyPid = opts?.pty ? handle.pid : null;
503
+ const pty = this.sbx.pty;
504
+ return {
505
+ write: (data) => {
506
+ const write = ptyPid !== null && pty
507
+ ? pty.sendInput(ptyPid, encoder.encode(data))
508
+ : handle.sendStdin(data);
509
+ void write.catch((_error) => {
510
+ // The process may have exited between the caller's check and this
511
+ // write; `onClose` is what tells them, so a lost keystroke on a dead
512
+ // process must not throw into an event handler.
513
+ });
514
+ },
515
+ onStdout: (cb) => {
516
+ stdoutListeners.push(cb);
517
+ },
518
+ onStderr: (cb) => {
519
+ stderrListeners.push(cb);
520
+ },
521
+ onClose: (cb) => {
522
+ closeListeners.push(cb);
523
+ if (closed)
524
+ cb();
525
+ },
526
+ kill: () => {
527
+ const killed = ptyPid !== null && pty ? pty.kill(ptyPid) : handle.kill();
528
+ void killed.catch((_error) => {
529
+ // Already gone / unreachable — kill is idempotent from the caller's
530
+ // point of view, and `onClose` still fires from `wait()`.
531
+ });
532
+ },
533
+ ...(resize ? { resize } : {}),
534
+ };
535
+ }
273
536
  /**
274
537
  * Read a file's contents as text.
275
538
  *
@@ -500,17 +763,36 @@ export class E2BSandboxProvider {
500
763
  * `processesPreserved: true`. A filesystem-only snapshot would cold-boot
501
764
  * instead, leaving a sandbox that is running with every dev server dead.
502
765
  *
503
- * @param config - Project id, env, optional per-boot templateId + labels.
766
+ * `config.volumeName` mounts a persistent volume, which E2B can only attach at
767
+ * CREATE time — there is no attach-to-a-running-sandbox call — so a sandbox
768
+ * claimed from a pre-warmed pool can never be given one afterwards. A mount
769
+ * path is REQUIRED alongside it: E2B mounts shadow whatever the image had at
770
+ * that path, and this bond's superset template keeps a 2.4 GB
771
+ * `/workspace/node_modules` there, so defaulting to the workspace root would
772
+ * hide the entire dependency fleet behind an empty volume and boot a project
773
+ * that cannot resolve a single import.
774
+ *
775
+ * @param config - Project id, env, optional per-boot templateId + labels, and
776
+ * an optional `volumeName` + `volumeMountPath` pair.
504
777
  * @returns A live sandbox handle.
505
778
  */
506
779
  async create(config) {
507
780
  const client = await this.client();
508
781
  const templateId = config.templateId ?? this.config.templateId;
782
+ if (config.volumeName && !config.volumeMountPath) {
783
+ // Refusing beats guessing: the only default that could be picked here is
784
+ // the workspace root, and that is the one value which silently breaks the
785
+ // sandbox it was meant to protect.
786
+ throw new Error(`E2B requires a volumeMountPath alongside volumeName ("${config.volumeName}"): a volume mounted at the workspace root would shadow the template's node_modules`);
787
+ }
509
788
  const sbx = await client.create(templateId, {
510
789
  timeoutMs: this.config.defaultTimeoutMs,
511
790
  envs: config.env ?? {},
512
791
  metadata: { projectId: config.projectId, ...(config.labels ?? {}) },
513
792
  lifecycle: { onTimeout: { action: 'pause', keepMemory: true } },
793
+ ...(config.volumeName
794
+ ? { volumeMounts: { [config.volumeMountPath]: config.volumeName } }
795
+ : {}),
514
796
  });
515
797
  const handle = new E2BSandbox(sbx, client, {
516
798
  previewPort: this.config.defaultPreviewPort,
@@ -642,6 +924,249 @@ export class E2BSandboxProvider {
642
924
  if (sbx)
643
925
  await sbx.kill();
644
926
  }
927
+ /**
928
+ * Every sandbox that currently EXISTS, running or paused.
929
+ *
930
+ * The basis for both "is this volume attached?" and "is this snapshot in use?".
931
+ * Paused sandboxes are included deliberately — a paused sandbox still owns its
932
+ * volume and still boots from its template, and treating it as absent is how a
933
+ * reclamation sweep deletes the storage under a hibernated project.
934
+ *
935
+ * @returns The raw listing rows.
936
+ */
937
+ async liveSandboxes() {
938
+ const client = await this.client();
939
+ const listed = await client.list({});
940
+ return Array.isArray(listed) ? listed : (listed.sandboxes ?? []);
941
+ }
942
+ /**
943
+ * Resolve a volume by the caller's name.
944
+ *
945
+ * @param name - The volume name.
946
+ * @returns The volume, or `null` when the account has no volume by that name.
947
+ */
948
+ async findVolume(name) {
949
+ const client = await this.client();
950
+ if (!client.listVolumes) {
951
+ throw new Error('E2B client does not expose the volume API');
952
+ }
953
+ const volumes = await client.listVolumes();
954
+ return volumes.find((v) => v.name === name) ?? null;
955
+ }
956
+ /**
957
+ * Create a named volume, idempotently.
958
+ *
959
+ * Callers create the volume on EVERY boot (they cannot know whether a previous
960
+ * sandbox already made it), so an existing volume is a success and must not be
961
+ * re-created — re-creating would either fail the boot or, worse, hand back an
962
+ * empty volume in place of the user's files.
963
+ *
964
+ * Volumes are a private beta on E2B: an account without them answers
965
+ * `403 use of volumes is not enabled`, which surfaces here as a throw rather
966
+ * than a silent no-op, because a caller that believes it has a durable volume
967
+ * and does not is exactly the failure this method exists to prevent.
968
+ *
969
+ * @param name - The volume name.
970
+ */
971
+ async createVolume(name) {
972
+ const client = await this.client();
973
+ if (!client.createVolume) {
974
+ throw new Error('E2B client does not expose the volume API');
975
+ }
976
+ if (await this.findVolume(name))
977
+ return;
978
+ await client.createVolume(name);
979
+ }
980
+ /**
981
+ * Remove a named volume. Removing one that does not exist is a success.
982
+ *
983
+ * @param name - The volume name.
984
+ */
985
+ async removeVolume(name) {
986
+ const client = await this.client();
987
+ if (!client.destroyVolume) {
988
+ throw new Error('E2B client does not expose the volume API');
989
+ }
990
+ const volume = await this.findVolume(name);
991
+ if (!volume)
992
+ return;
993
+ await client.destroyVolume(volume.volumeId);
994
+ }
995
+ /**
996
+ * Whether a named volume exists.
997
+ *
998
+ * THROWS when it cannot look. A control plane asks this to decide whether a
999
+ * project's files survived losing its sandbox, and answering `false` for a
1000
+ * failed lookup tells it the user's work is gone — the same "could not look
1001
+ * delivered as looked-and-empty" confusion that `get()` returning `null` used
1002
+ * to be.
1003
+ *
1004
+ * @param name - The volume name.
1005
+ * @returns `true` when the account has a volume by that name.
1006
+ */
1007
+ async volumeExists(name) {
1008
+ return (await this.findVolume(name)) !== null;
1009
+ }
1010
+ /**
1011
+ * Enumerate volumes, reporting which ones a sandbox currently holds.
1012
+ *
1013
+ * `attached` is OBSERVED from the sandbox listing (running and paused alike),
1014
+ * not assumed, because the only reason to enumerate volumes is to delete the
1015
+ * unattached ones. E2B reports neither a creation time nor a size for a volume,
1016
+ * so both are `null` rather than invented.
1017
+ *
1018
+ * @param options - Narrowing by name prefix and attachment.
1019
+ * @returns Every matching volume.
1020
+ */
1021
+ async listVolumes(options) {
1022
+ const client = await this.client();
1023
+ if (!client.listVolumes) {
1024
+ throw new Error('E2B client does not expose the volume API');
1025
+ }
1026
+ const [volumes, sandboxes] = await Promise.all([client.listVolumes(), this.liveSandboxes()]);
1027
+ const attachedNames = new Set();
1028
+ for (const sbx of sandboxes)
1029
+ for (const m of sbx.volumeMounts ?? [])
1030
+ attachedNames.add(m.name);
1031
+ return volumes
1032
+ .filter((v) => !options?.namePrefix || v.name.startsWith(options.namePrefix))
1033
+ .map((v) => ({
1034
+ name: v.name,
1035
+ attached: attachedNames.has(v.name),
1036
+ createdAt: null,
1037
+ sizeBytes: null,
1038
+ }))
1039
+ .filter((v) => options?.attached === undefined || v.attached === options.attached);
1040
+ }
1041
+ /**
1042
+ * Capture a sandbox as a reusable template, using E2B snapshots.
1043
+ *
1044
+ * A snapshot is a persistent image of the sandbox's filesystem AND memory that
1045
+ * outlives the sandbox itself — verified against production: a sandbox was
1046
+ * killed and a new one created from its snapshot came up with the same files.
1047
+ * Re-committing the same `templateId` assigns a new build to the same snapshot
1048
+ * rather than accumulating a second one, so a per-project restore point stays
1049
+ * one resource no matter how often it is refreshed.
1050
+ *
1051
+ * Snapshotting BRIEFLY PAUSES the sandbox and drops its open connections
1052
+ * (PTYs, command streams, websockets). Take one when the project is going
1053
+ * quiet, not underneath a user's terminal.
1054
+ *
1055
+ * `capturePaths` inside a MOUNTED VOLUME are refused rather than silently
1056
+ * omitted: E2B's snapshot images the sandbox's own disk, and a volume is
1057
+ * storage attached from outside it. Committing anyway would produce a template
1058
+ * that boots perfectly into a workspace missing exactly the files it was asked
1059
+ * to preserve.
1060
+ *
1061
+ * @param options - The sandbox, the caller's template id, optional capture paths and label.
1062
+ * @returns The template as it now exists.
1063
+ */
1064
+ async commitTemplate(options) {
1065
+ const client = await this.client();
1066
+ if (!client.createSnapshot) {
1067
+ throw new Error('E2B client does not expose the snapshot API');
1068
+ }
1069
+ if (options.capturePaths?.length && client.getInfo) {
1070
+ const info = await client.getInfo(options.sandboxId);
1071
+ for (const mount of info.volumeMounts ?? []) {
1072
+ const shadowed = options.capturePaths.find((path) => path === mount.path || path.startsWith(`${mount.path}/`));
1073
+ if (shadowed) {
1074
+ throw new Error(`Cannot capture "${shadowed}" into an E2B snapshot: it is on the mounted volume "${mount.name}", which the snapshot does not image`);
1075
+ }
1076
+ }
1077
+ }
1078
+ const snapshot = await client.createSnapshot(options.sandboxId, options.templateId);
1079
+ return {
1080
+ id: options.templateId,
1081
+ ref: snapshot.snapshotId,
1082
+ createdAt: new Date().toISOString(),
1083
+ sizeBytes: null,
1084
+ ...(options.label === undefined ? {} : { label: options.label }),
1085
+ inUse: true,
1086
+ };
1087
+ }
1088
+ /**
1089
+ * Look up one template by the caller's identifier.
1090
+ *
1091
+ * `null` means E2B has no snapshot by that name. A failed lookup throws: a
1092
+ * caller reads `null` as "there is no restore point" and scaffolds a fresh
1093
+ * project over the user's, so a transient API error must never wear that shape.
1094
+ *
1095
+ * @param templateId - The caller's identifier.
1096
+ * @returns The template, or `null` when it does not exist.
1097
+ */
1098
+ async getTemplate(templateId) {
1099
+ const client = await this.client();
1100
+ if (!client.listSnapshots) {
1101
+ throw new Error('E2B client does not expose the snapshot API');
1102
+ }
1103
+ const found = await client.listSnapshots({ name: templateId, limit: 1 });
1104
+ const snapshot = found[0];
1105
+ if (!snapshot)
1106
+ return null;
1107
+ const sandboxes = await this.liveSandboxes();
1108
+ return {
1109
+ id: templateId,
1110
+ ref: snapshot.snapshotId,
1111
+ // E2B reports neither a build time nor a size for a snapshot; `''` is the
1112
+ // fleet's existing "unknown template timestamp" spelling (see the flyio bond).
1113
+ createdAt: '',
1114
+ sizeBytes: null,
1115
+ inUse: sandboxes.some((sbx) => sbx.name === templateId),
1116
+ };
1117
+ }
1118
+ /**
1119
+ * Enumerate templates so a caller can enforce its retention policy.
1120
+ *
1121
+ * Throws rather than answering `[]` when it cannot enumerate — an eviction
1122
+ * loop that reads "nothing exists" from a failed listing stops evicting, and a
1123
+ * reconciler that reads it stops reconciling.
1124
+ *
1125
+ * @param options - Narrowing by id prefix.
1126
+ * @returns Every matching template.
1127
+ */
1128
+ async listTemplates(options) {
1129
+ const client = await this.client();
1130
+ if (!client.listSnapshots) {
1131
+ throw new Error('E2B client does not expose the snapshot API');
1132
+ }
1133
+ const [snapshots, sandboxes] = await Promise.all([client.listSnapshots(), this.liveSandboxes()]);
1134
+ const inUseNames = new Set(sandboxes.map((sbx) => sbx.name).filter(Boolean));
1135
+ return snapshots
1136
+ .map((snapshot) => ({ id: bareSnapshotName(snapshot), snapshot }))
1137
+ .filter(({ id }) => !options?.idPrefix || id.startsWith(options.idPrefix))
1138
+ .map(({ id, snapshot }) => ({
1139
+ id,
1140
+ ref: snapshot.snapshotId,
1141
+ createdAt: '',
1142
+ sizeBytes: null,
1143
+ inUse: inUseNames.has(id),
1144
+ }));
1145
+ }
1146
+ /**
1147
+ * Delete a template. Deleting one that does not exist is a success.
1148
+ *
1149
+ * Refuses when a sandbox still boots from it, by CHECKING the sandbox listing
1150
+ * rather than trusting the caller. E2B enforces the same rule server-side (a
1151
+ * `400` naming the template), which is left to surface as a throw — two
1152
+ * independent refusals for an operation whose failure mode is destroying a
1153
+ * running project.
1154
+ *
1155
+ * @param templateId - The caller's identifier.
1156
+ */
1157
+ async removeTemplate(templateId) {
1158
+ const client = await this.client();
1159
+ if (!client.deleteSnapshot) {
1160
+ throw new Error('E2B client does not expose the snapshot API');
1161
+ }
1162
+ const template = await this.getTemplate(templateId);
1163
+ if (!template)
1164
+ return;
1165
+ if (template.inUse) {
1166
+ throw new Error(`Refusing to delete template "${templateId}": a sandbox is currently running from it`);
1167
+ }
1168
+ await client.deleteSnapshot(template.ref);
1169
+ }
645
1170
  /**
646
1171
  * PROVE egress is deny-by-default by OBSERVING it on a throwaway sandbox.
647
1172
  *