@molecule/api-code-sandbox-e2b 1.0.4 → 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,12 +44,32 @@ 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 } = 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 }),
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 }),
68
+ // The SDK raises `SandboxNotFoundError` from `getInfo`/`connect` for exactly
69
+ // one condition — the API answered 404 — and routes every other failure to a
70
+ // different class. That makes it a POSITIVE not-found, which is the whole
71
+ // basis for `get()` returning null.
72
+ isNotFound: (error) => error instanceof SandboxNotFoundError,
37
73
  async list(opts) {
38
74
  // Sandbox.list returns a paginator; normalize to a flat array of running
39
75
  // sandboxes. Support the async-iterator, nextItems(), and array shapes so
@@ -41,9 +77,19 @@ async function defaultClient(apiKey) {
41
77
  const result = S.list({ apiKey, ...opts });
42
78
  const out = [];
43
79
  const push = (items) => {
80
+ // Carry the listed state through: it is the only way a caller can skip a
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.
44
85
  for (const it of items)
45
86
  if (it?.sandboxId)
46
- out.push({ sandboxId: it.sandboxId });
87
+ out.push({
88
+ sandboxId: it.sandboxId,
89
+ state: it.state,
90
+ name: it.name,
91
+ volumeMounts: it.volumeMounts,
92
+ });
47
93
  };
48
94
  const r = await Promise.resolve(result).catch(() => result);
49
95
  if (Array.isArray(r)) {
@@ -67,6 +113,30 @@ async function defaultClient(apiKey) {
67
113
  },
68
114
  };
69
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
+ }
131
+ /**
132
+ * Single-quote a value for POSIX `sh` so an arbitrary path is one argument.
133
+ *
134
+ * @param value - The raw string.
135
+ * @returns The quoted string.
136
+ */
137
+ function shellQuote(value) {
138
+ return `'${value.replace(/'/g, "'\\''")}'`;
139
+ }
70
140
  /**
71
141
  * Build a deny-by-default network update from an allow-list.
72
142
  *
@@ -76,6 +146,49 @@ async function defaultClient(apiKey) {
76
146
  function denyByDefault(allowOut) {
77
147
  return { allowOut, denyOut: [ALL_TRAFFIC] };
78
148
  }
149
+ /**
150
+ * Whether an error means the sandbox POSITIVELY does not exist.
151
+ *
152
+ * Asks the client (the real one compares against the SDK's own
153
+ * `SandboxNotFoundError` class), and falls back to the error's `name` so an
154
+ * injected/duck-typed client — or an SDK loaded twice under different module
155
+ * instances, where `instanceof` silently fails — is still classified correctly.
156
+ * Everything else is a failure to LOOK and must propagate.
157
+ *
158
+ * @param client - The client the error came from.
159
+ * @param error - The thrown value.
160
+ * @returns True only for a not-found.
161
+ */
162
+ function isNotFound(client, error) {
163
+ if (client.isNotFound?.(error))
164
+ return true;
165
+ return error?.name === 'SandboxNotFoundError';
166
+ }
167
+ /**
168
+ * Map an E2B sandbox record onto the core's coarse lifecycle status.
169
+ *
170
+ * E2B has exactly two states. `paused` is a filesystem + memory snapshot, which
171
+ * is what the core calls `sleeping` — NOT `stopped`, because a resume brings the
172
+ * process tree back with it and callers branch on that.
173
+ *
174
+ * @param state - The SDK's `state` field.
175
+ * @returns The core status.
176
+ */
177
+ function toCoreStatus(state) {
178
+ return state === 'paused' ? 'sleeping' : 'running';
179
+ }
180
+ /**
181
+ * Normalize an SDK date field (a `Date`, an ISO string, or absent) to ISO.
182
+ *
183
+ * @param value - The SDK value.
184
+ * @returns An ISO 8601 string, or `null`.
185
+ */
186
+ function toIso(value) {
187
+ if (!value)
188
+ return null;
189
+ const date = value instanceof Date ? value : new Date(value);
190
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
191
+ }
79
192
  /**
80
193
  * A live E2B sandbox mapped onto the core `Sandbox` handle.
81
194
  *
@@ -136,19 +249,33 @@ class E2BSandbox {
136
249
  /**
137
250
  * Pause the sandbox (E2B FS + memory snapshot).
138
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
+ *
139
258
  * @returns The outcome; `processesPreserved` is true because the memory
140
259
  * snapshot restores the process tree on resume.
260
+ * @throws {Error} When the SDK exposes no pause at all, or the pause call fails.
141
261
  */
142
262
  async hibernate() {
143
- 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;
144
266
  if (!pause) {
145
- // No pause available — nothing we can honestly suspend; report it.
146
- 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');
147
268
  }
148
- 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);
149
272
  this.status = 'sleeping';
150
273
  // E2B pause snapshots FS + memory, so the process tree survives resume.
151
- return { processesPreserved: true, mechanism: 'pause' };
274
+ return {
275
+ processesPreserved: true,
276
+ mechanism: 'pause',
277
+ ...(paused === false ? { detail: 'the sandbox was already paused' } : {}),
278
+ };
152
279
  }
153
280
  /**
154
281
  * Resume the sandbox by reconnecting to a fresh live instance for its id.
@@ -163,6 +290,66 @@ class E2BSandbox {
163
290
  this.previewUrl = this.getPreviewUrl();
164
291
  return { processesPreserved: true, mechanism: 'resume' };
165
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
+ }
166
353
  /**
167
354
  * Run a command to completion in the sandbox.
168
355
  *
@@ -178,27 +365,37 @@ class E2BSandbox {
178
365
  * @returns stdout, stderr and the exit code (even when non-zero).
179
366
  */
180
367
  async exec(command, opts) {
181
- // A shell-backgrounded command (`… &` / `… & true`) must NOT block exec —
182
- // that is the whole point of `&`. E2B's `commands.run` waits for the process
183
- // group anyway (a detached `nohup`/`setsid` dev-server launch times out the
184
- // request), so route backgrounded commands through the SDK's native
185
- // `background: true`, which returns immediately. The control plane launches
186
- // every dev server this way (`nohup sh -c '…' >log 2>&1 & true`), so this
187
- // makes the provider-agnostic launch code work unchanged on E2B.
188
- if (/&\s*(?:true\s*)?$/.test(command)) {
189
- await this.sbx.commands.run(command, {
190
- cwd: opts?.cwd,
191
- envs: opts?.env,
192
- background: true,
193
- });
194
- return { stdout: '', stderr: '', exitCode: 0 };
195
- }
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.
196
386
  try {
197
- const r = await this.sbx.commands.run(command, {
387
+ const handle = await this.sbx.commands.run(command, {
198
388
  cwd: opts?.cwd,
199
389
  timeoutMs: opts?.timeout,
200
390
  envs: opts?.env,
391
+ background: true,
201
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);
202
399
  return { stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode };
203
400
  }
204
401
  catch (error) {
@@ -210,6 +407,132 @@ class E2BSandbox {
210
407
  throw error;
211
408
  }
212
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
+ }
213
536
  /**
214
537
  * Read a file's contents as text.
215
538
  *
@@ -316,7 +639,7 @@ class E2BSandbox {
316
639
  chunks.push(chunk);
317
640
  const tarPath = `/tmp/mol-import-${Date.now().toString(36)}.tar`;
318
641
  await this.sbx.files.write(tarPath, new Blob([Buffer.concat(chunks)]));
319
- const r = await this.sbx.commands.run(`mkdir -p ${path} && tar xf ${tarPath} -C ${path} --no-same-owner --no-same-permissions && rm -f ${tarPath}`, { timeoutMs: 300_000 });
642
+ const r = await this.sbx.commands.run(`mkdir -p ${shellQuote(path)} && tar xf ${tarPath} -C ${shellQuote(path)} --no-same-owner --no-same-permissions && rm -f ${tarPath}`, { timeoutMs: 300_000 });
320
643
  if (r.exitCode !== 0) {
321
644
  throw new Error(`importFiles: tar extract failed (${r.exitCode}): ${r.stderr.slice(0, 300)}`);
322
645
  }
@@ -326,14 +649,30 @@ class E2BSandbox {
326
649
  *
327
650
  * Tars the tree in-sandbox, reads it back as bytes, and yields it as a single
328
651
  * chunk. Sufficient for archive/migrate (whole-workspace capture); not a
329
- * chunked pipe.
652
+ * chunked pipe — the whole archive is held in this process's memory once, so
653
+ * callers moving large trees must bound what they export.
330
654
  *
331
- * @param path - Absolute path inside the sandbox to archive.
655
+ * Entries are rooted at the LAST SEGMENT of `path` (`tar -C <parent>
656
+ * <name>`), so `exportFiles('/workspace/my-app')` yields `my-app/…` — the
657
+ * shape Docker's archive endpoint produces and the shape every consumer
658
+ * (`importFiles(<parent>)`, host-side unpackers with `strip: 1`) expects.
659
+ * The previous `tar -C <path> .` rooting yielded `./…`, which
660
+ * `importFiles('/')` extracted at the filesystem root instead of the
661
+ * directory it was taken from.
662
+ *
663
+ * @param path - Absolute path inside the sandbox to archive (not `/`).
332
664
  * @returns A POSIX tar byte stream of that path's contents.
333
665
  */
334
666
  async exportFiles(path) {
667
+ const trimmed = path.replace(/\/+$/, '');
668
+ const slash = trimmed.lastIndexOf('/');
669
+ const parent = slash <= 0 ? '/' : trimmed.slice(0, slash);
670
+ const name = trimmed.slice(slash + 1);
671
+ if (!trimmed.startsWith('/') || !name) {
672
+ throw new Error(`exportFiles: path must be an absolute directory below "/" (got "${path}")`);
673
+ }
335
674
  const tarPath = `/tmp/mol-export-${Date.now().toString(36)}.tar`;
336
- const r = await this.sbx.commands.run(`tar cf ${tarPath} -C ${path} .`, { timeoutMs: 300_000 });
675
+ const r = await this.sbx.commands.run(`tar cf ${tarPath} -C ${shellQuote(parent)} ${shellQuote(name)}`, { timeoutMs: 300_000 });
337
676
  if (r.exitCode !== 0) {
338
677
  throw new Error(`exportFiles: tar create failed (${r.exitCode}): ${r.stderr.slice(0, 300)}`);
339
678
  }
@@ -415,16 +754,45 @@ export class E2BSandboxProvider {
415
754
  /**
416
755
  * Create a new sandbox from the golden template and apply the egress policy.
417
756
  *
418
- * @param config - Project id, env, optional per-boot templateId + labels.
757
+ * The sandbox is created to PAUSE at its timeout, not to be killed. E2B's
758
+ * default is `onTimeout: 'kill'`, which means a sandbox nothing has touched for
759
+ * its lifetime is destroyed — and on this platform the sandbox is the only copy
760
+ * of the project, so the default turns "the tab was closed over lunch" into
761
+ * permanent data loss. `keepMemory` stays on: the pause snapshot restores the
762
+ * process tree, which is what makes `resume()` honest when it reports
763
+ * `processesPreserved: true`. A filesystem-only snapshot would cold-boot
764
+ * instead, leaving a sandbox that is running with every dev server dead.
765
+ *
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.
419
777
  * @returns A live sandbox handle.
420
778
  */
421
779
  async create(config) {
422
780
  const client = await this.client();
423
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
+ }
424
788
  const sbx = await client.create(templateId, {
425
789
  timeoutMs: this.config.defaultTimeoutMs,
426
790
  envs: config.env ?? {},
427
791
  metadata: { projectId: config.projectId, ...(config.labels ?? {}) },
792
+ lifecycle: { onTimeout: { action: 'pause', keepMemory: true } },
793
+ ...(config.volumeName
794
+ ? { volumeMounts: { [config.volumeMountPath]: config.volumeName } }
795
+ : {}),
428
796
  });
429
797
  const handle = new E2BSandbox(sbx, client, {
430
798
  previewPort: this.config.defaultPreviewPort,
@@ -451,12 +819,68 @@ export class E2BSandboxProvider {
451
819
  timeoutMs: this.config.defaultTimeoutMs,
452
820
  });
453
821
  }
454
- catch (_error) {
455
- // connect throws SandboxNotFoundError for a genuinely absent id → null.
456
- // A transient error also lands here; get() is used as an existence probe,
457
- // and the callers re-resolve, so returning null is the safe answer.
458
- return null;
822
+ catch (error) {
823
+ // `null` means the sandbox is GONE, and nothing else. Every other failure
824
+ // THROWS. This used to swallow all of them, and the cost of that is not
825
+ // hypothetical: a control plane reads `null` as "the container is gone",
826
+ // detaches the project, and rebuilds it from a template — so one 5xx from
827
+ // this API destroyed a user's only copy of their code. "I could not look"
828
+ // must never be delivered as "I looked, and it is not there".
829
+ if (isNotFound(client, error))
830
+ return null;
831
+ throw error;
832
+ }
833
+ }
834
+ /**
835
+ * Read a sandbox's record WITHOUT connecting to it.
836
+ *
837
+ * The lookup a control plane polls with. `get()` cannot serve that purpose on
838
+ * E2B: obtaining a handle is `POST /sandboxes/{id}/connect`, which RESUMES a
839
+ * paused sandbox and extends its deadline — so a status poll every few seconds
840
+ * silently un-hibernates every sleeping project, bills for the compute, and
841
+ * leaves the UI showing "asleep". This reads the record instead, so `paused`
842
+ * stays paused and is reported as `sleeping`.
843
+ *
844
+ * `null` means the sandbox positively does not exist. A failed lookup throws,
845
+ * for the same reason `get()` does.
846
+ *
847
+ * @param id - The sandbox id.
848
+ * @returns The descriptor, or `null` when no sandbox has that id.
849
+ */
850
+ async describe(id) {
851
+ const client = await this.client();
852
+ if (!client.getInfo) {
853
+ // Refusing is the point: answering `null` here would report "no such
854
+ // sandbox" for a client that simply cannot look.
855
+ throw new Error('E2B client does not expose getInfo(); cannot describe a sandbox');
856
+ }
857
+ let info;
858
+ try {
859
+ info = await client.getInfo(id);
860
+ }
861
+ catch (error) {
862
+ if (isNotFound(client, error))
863
+ return null;
864
+ throw error;
459
865
  }
866
+ const startedAt = toIso(info.startedAt);
867
+ return {
868
+ id: info.sandboxId ?? id,
869
+ projectId: info.metadata?.projectId ?? null,
870
+ status: toCoreStatus(info.state),
871
+ labels: info.metadata ?? {},
872
+ // E2B has no created-but-never-started state — a sandbox exists only once
873
+ // it has run — so both timestamps are the same fact, and neither is ever
874
+ // the `null` that marks wreckage on a provider that does have one.
875
+ createdAt: startedAt,
876
+ startedAt,
877
+ templateRef: info.templateId ?? null,
878
+ volumeName: info.volumeMounts?.[0]?.name ?? null,
879
+ // E2B publishes every internal port at `<port>-<id>.e2b.app` rather than
880
+ // mapping it to a host port, so there are no mappings to report. The
881
+ // reachable URL comes from `getPreviewUrl(port)`.
882
+ ports: [],
883
+ };
460
884
  }
461
885
  /**
462
886
  * List the caller's live sandboxes.
@@ -470,6 +894,11 @@ export class E2BSandboxProvider {
470
894
  const items = Array.isArray(running) ? running : (running.sandboxes ?? []);
471
895
  const handles = [];
472
896
  for (const it of items) {
897
+ // A PAUSED sandbox is deliberately skipped: building its handle means
898
+ // connecting, and connecting resumes it. Enumerating an account would
899
+ // otherwise wake — and start billing — every hibernated project on it.
900
+ if (it.state === 'paused')
901
+ continue;
473
902
  const h = await this.get(it.sandboxId);
474
903
  if (h)
475
904
  handles.push(h);
@@ -495,6 +924,249 @@ export class E2BSandboxProvider {
495
924
  if (sbx)
496
925
  await sbx.kill();
497
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
+ }
498
1170
  /**
499
1171
  * PROVE egress is deny-by-default by OBSERVING it on a throwaway sandbox.
500
1172
  *