@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/README.md +232 -22
- package/dist/index.d.ts +72 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +72 -5
- package/dist/provider.d.ts +135 -2
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +550 -25
- package/dist/types.d.ts +143 -15
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
*
|