@mastra/e2b 0.14.2-alpha.0 → 0.15.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/index.js CHANGED
@@ -1,6 +1,8 @@
1
- import { FactorySandbox, MastraSandbox, ProcessHandle, SandboxError, SandboxNotReadyError, SandboxProcessManager, UnsupportedStdinCloseError, assertModesUnsupported } from "@mastra/core/workspace";
2
- import { ApiClient, ConnectionConfig, Sandbox, Template } from "e2b";
1
+ import { DirectoryNotEmptyError, DirectoryNotFoundError, FactorySandbox, FileExistsError, FileNotFoundError, IsDirectoryError, MastraFilesystem, MastraSandbox, NotDirectoryError, ProcessHandle, SandboxError, SandboxNotReadyError, SandboxProcessManager, UnsupportedStdinCloseError, assertModesUnsupported } from "@mastra/core/workspace";
2
+ import { ApiClient, ConnectionConfig, InvalidArgumentError, NotFoundError, Sandbox, SandboxNotFoundError, Template } from "e2b";
3
3
  import { createHash } from "crypto";
4
+ import { posix } from "path";
5
+ import { Buffer as Buffer$1 } from "buffer";
4
6
  import { execFile } from "child_process";
5
7
  import { promisify } from "util";
6
8
  import { FRAME_PREFIX, buildProgramModule, buildRunner, sanitizeToolId } from "@mastra/core/tools";
@@ -74,6 +76,514 @@ function createDefaultMountableTemplate(options) {
74
76
  };
75
77
  }
76
78
  //#endregion
79
+ //#region ../../packages/_internals/workspace/dist/index.js
80
+ /** Directory, relative to the template's build cwd, that holds every marker below. */
81
+ const SETUP_MARKER_DIR = ".mastra-sandbox";
82
+ /**
83
+ * Setup completion marker shared by repo templates and their consumers.
84
+ *
85
+ * A repo template writes this file beside the checkout as its last build
86
+ * step, so it exists only in images where every setup command succeeded. Its
87
+ * content is a digest of the setup commands the image ran, letting a sandbox
88
+ * booted from the image tell whether the setup it is about to run already
89
+ * happened. Relative to the template's build cwd, which is also the runtime
90
+ * working directory the repo was cloned into.
91
+ *
92
+ * This is the single-repository marker. Templates built from a repository
93
+ * list write one marker per repository instead ({@link repoSetupMarkerPath}).
94
+ */
95
+ const SETUP_MARKER_PATH = ".mastra-sandbox/setup";
96
+ /**
97
+ * Marker for the workspace-level setup of a multi-repository template: the
98
+ * commands that ran at the working directory after every repository was set
99
+ * up. Written even when there were no such commands, so a consumer can tell
100
+ * "ran nothing" from "never built".
101
+ */
102
+ const WORKSPACE_SETUP_MARKER_PATH = ".mastra-sandbox/workspace-setup";
103
+ /**
104
+ * One line per repository directory whose setup failed while the build was
105
+ * told to continue. Absent when nothing failed. Consumers check this before
106
+ * trusting a per-repository marker: that marker means every step ran, not
107
+ * that every step succeeded.
108
+ */
109
+ const SETUP_FAILED_MARKER_PATH = ".mastra-sandbox/setup-failed";
110
+ /** Per-repository marker of a multi-repository template, keyed by the clone directory name. */
111
+ function repoSetupMarkerPath(repoDir) {
112
+ return `${SETUP_MARKER_DIR}/repos/${repoDir}`;
113
+ }
114
+ /** Blank entries never become build steps, so they never count toward the digest either. */
115
+ function normalizeSetupCommands$1(setupCommand) {
116
+ return (setupCommand === void 0 ? [] : Array.isArray(setupCommand) ? setupCommand : [setupCommand]).filter((command) => command.trim() !== "");
117
+ }
118
+ /** The marker content for a setup command list: `sha256:<hex>` over the commands joined by newlines. */
119
+ function setupMarkerContent(setupCommand) {
120
+ return `sha256:${createHash("sha256").update(normalizeSetupCommands$1(setupCommand).join("\n")).digest("hex")}`;
121
+ }
122
+ /** Shell step that writes the marker relative to the cwd. `content` is a digest, so it is shell-safe. */
123
+ function setupMarkerCommand(content, markerPath = SETUP_MARKER_PATH) {
124
+ return `mkdir -p "$(dirname "${markerPath}")" && printf '%s' '${content}' > "${markerPath}"`;
125
+ }
126
+ /** A clone directory name is one path segment of word characters, dots and dashes, so it never needs quoting. */
127
+ const REPO_DIR_PATTERN = /^[\w.-]+$/;
128
+ /**
129
+ * Shell step that runs one setup command inside a repository clone.
130
+ *
131
+ * Without the guard this is the plain `cd "<dir>" && <cmd>` step. With it,
132
+ * the `cd` sits inside the parenthesized group so that the failure branch
133
+ * runs back at the build cwd and the failure list lands at the workspace
134
+ * level, never inside the repository.
135
+ */
136
+ function guardedSetupCommand({ repoDir, command, continueOnFailure }) {
137
+ if (!REPO_DIR_PATTERN.test(repoDir)) throw new TypeError(`Repository directory "${repoDir}" must match ${REPO_DIR_PATTERN}.`);
138
+ if (!continueOnFailure) return `cd "${repoDir}" && ${command}`;
139
+ return `( cd "${repoDir}" && sh -c ${shellQuote$2(command)} ) || { mkdir -p "${SETUP_MARKER_DIR}" && grep -qxF -- '${repoDir}' "${SETUP_FAILED_MARKER_PATH}" 2>/dev/null || printf '%s\\n' '${repoDir}' >> "${SETUP_FAILED_MARKER_PATH}"; }`;
140
+ }
141
+ function shellQuote$2(value) {
142
+ return `'${value.replace(/'/g, `'\\''`)}'`;
143
+ }
144
+ function repoCloneCommand({ cloneUrl, destination, branch, tokenEnv }) {
145
+ return `git ${tokenEnv ? `${gitAuthFlag$1(tokenEnv)} ` : ""}clone --depth=1 --single-branch ${branch ? `--branch ${shellQuote$1(branch)} ` : ""}${shellQuote$1(cloneUrl)} ${shellQuote$1(destination)}`;
146
+ }
147
+ /** Per-invocation auth header; `-c` config never reaches `.git/config`. */
148
+ function gitAuthFlag$1(tokenEnv) {
149
+ return `-c http.extraheader="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$${tokenEnv}" | base64 -w0)"`;
150
+ }
151
+ function shellQuote$1(value) {
152
+ return `'${value.replace(/'/g, `'\\''`)}'`;
153
+ }
154
+ /**
155
+ * Shell scripts for the file operations a sandbox can only do atomically (or
156
+ * without moving file bytes over the network) by running a command inside the
157
+ * VM. Each builder takes absolute paths and returns one POSIX `sh` script; the
158
+ * caller runs it with `sh -c` and maps the exit codes below to typed errors.
159
+ */
160
+ /** Exit codes the scripts use for outcomes the caller turns into typed errors. Any other non-zero code is a plain failure. */
161
+ const FILE_COMMAND_EXIT = {
162
+ notFound: 20,
163
+ isDirectory: 21,
164
+ exists: 22,
165
+ notDirectory: 23,
166
+ destIsDirectory: 24
167
+ };
168
+ /** Single-quote a string for safe POSIX shell interpolation. */
169
+ function shellQuote$3(value) {
170
+ return `'${value.replace(/'/g, `'\\''`)}'`;
171
+ }
172
+ const { notFound, isDirectory, exists, notDirectory, destIsDirectory } = FILE_COMMAND_EXIT;
173
+ /** `[ -e ]` is false for a broken symlink, which still occupies the path. */
174
+ const present = (variable) => `{ [ -e "$${variable}" ] || [ -L "$${variable}" ]; }`;
175
+ /**
176
+ * A sibling temp path for `path`, so a later `ln` / `mv` onto `path` stays on
177
+ * one filesystem. Hidden, and unique per call.
178
+ */
179
+ function siblingTempPath(path) {
180
+ const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
181
+ return posix.join(posix.dirname(path), `.${posix.basename(path)}.mastra-tmp-${suffix}`);
182
+ }
183
+ /**
184
+ * Hard-link `$tmp` to `$dest` and delete `$tmp`, failing with `exists` when
185
+ * something is at `$dest`. `ln` onto a directory (or a link to one) would
186
+ * create the link inside it and succeed, so that case is undone and reported
187
+ * as taken too.
188
+ */
189
+ function linkTempIntoPlace() {
190
+ return [
191
+ `ln "$tmp" "$dest" 2>/dev/null || { rm -f "$tmp"; ${present("dest")} && exit ${exists}; exit 1; }`,
192
+ `if [ -d "$dest" ]; then rm -f "$dest/$(basename "$tmp")" "$tmp"; exit ${exists}; fi`,
193
+ `rm -f "$tmp"`
194
+ ];
195
+ }
196
+ /**
197
+ * Copy `src` to `dest` inside the sandbox, creating `dest`'s parent.
198
+ *
199
+ * A file source copies its content (a symlinked source is followed). A
200
+ * directory source with `recursive` copies its contents into `dest`, keeping
201
+ * symlinks inside the tree as links. With `overwrite: false` the destination
202
+ * is claimed atomically: a directory with an exclusive `mkdir`, a file by
203
+ * copying to a temp name and hard-linking it into place (`link(2)` fails when
204
+ * the destination exists).
205
+ *
206
+ * Exits: `notFound` (no source), `isDirectory` (directory source without
207
+ * `recursive`), `destIsDirectory` (a file onto an existing directory or a link
208
+ * to one), `exists`.
209
+ */
210
+ function copyCommand(src, dest, options = {}) {
211
+ const noClobber = options.overwrite === false;
212
+ return [
213
+ `src=${shellQuote$3(src)}`,
214
+ `dest=${shellQuote$3(dest)}`,
215
+ `[ -e "$src" ] || exit ${notFound}`,
216
+ `mkdir -p "$(dirname "$dest")" || exit 1`,
217
+ `if [ -d "$src" ]; then`,
218
+ options.recursive ? ` :` : ` exit ${isDirectory}`,
219
+ noClobber ? ` mkdir "$dest" 2>/dev/null || exit ${exists}` : ` mkdir -p "$dest" || exit 1`,
220
+ ` cp -R -P "$src"/. "$dest"/ || exit 1`,
221
+ ` exit 0`,
222
+ `fi`,
223
+ noClobber ? `${present("dest")} && exit ${exists}` : `if [ -d "$dest" ]; then exit ${destIsDirectory}; fi`,
224
+ ...noClobber ? [
225
+ `tmp=${shellQuote$3(siblingTempPath(dest))}`,
226
+ `cp "$src" "$tmp" || { rm -f "$tmp"; exit 1; }`,
227
+ ...linkTempIntoPlace()
228
+ ] : [`cp "$src" "$dest" || exit 1`]
229
+ ].join("\n");
230
+ }
231
+ /**
232
+ * Move `src` to `dest` without replacing anything at `dest`, creating `dest`'s
233
+ * parent. A skipped `mv -n` is detected by the source surviving, whatever
234
+ * its exit code. If a directory appears at `dest` between the check and the
235
+ * move, `mv` moves `src` into it (BSD `mv` has no `-T`), and that is reported
236
+ * as success; the no-clobber rename itself is atomic.
237
+ *
238
+ * Exits: `notFound` (no source), `exists`.
239
+ */
240
+ function moveNoClobberCommand(src, dest) {
241
+ return [
242
+ `src=${shellQuote$3(src)}`,
243
+ `dest=${shellQuote$3(dest)}`,
244
+ `${present("src")} || exit ${notFound}`,
245
+ `${present("dest")} && exit ${exists}`,
246
+ `mkdir -p "$(dirname "$dest")" || exit 1`,
247
+ `mv -n "$src" "$dest" 2>/dev/null || { ${present("src")} && ${present("dest")} && exit ${exists}; exit 1; }`,
248
+ `${present("src")} && exit ${exists}`,
249
+ `exit 0`
250
+ ].join("\n");
251
+ }
252
+ /**
253
+ * Append the bytes already written to `tmp` onto `dest`, then delete `tmp`.
254
+ * The content travels as a file rather than a command argument, so it has no
255
+ * size limit. Creates `dest` (and its parent) when missing.
256
+ *
257
+ * Exits: `isDirectory` (dest is a directory).
258
+ */
259
+ function appendFromCommand(tmp, dest) {
260
+ return [
261
+ `tmp=${shellQuote$3(tmp)}`,
262
+ `dest=${shellQuote$3(dest)}`,
263
+ `if [ -d "$dest" ]; then rm -f "$tmp"; exit ${isDirectory}; fi`,
264
+ `mkdir -p "$(dirname "$dest")" && cat "$tmp" >> "$dest"`,
265
+ `status=$?`,
266
+ `rm -f "$tmp"`,
267
+ `exit $status`
268
+ ].join("\n");
269
+ }
270
+ /**
271
+ * Publish the file already written to `tmp` at `dest` only if nothing is
272
+ * there, then delete `tmp`. `ln` (`link(2)`) is the atomic exclusivity check;
273
+ * `tmp` must be on the same filesystem, e.g. from {@link siblingTempPath}.
274
+ *
275
+ * Exits: `exists`, `notDirectory` (dest's parent is not a directory).
276
+ */
277
+ function linkNoClobberCommand(tmp, dest) {
278
+ return [
279
+ `tmp=${shellQuote$3(tmp)}`,
280
+ `dest=${shellQuote$3(dest)}`,
281
+ `if [ ! -d "$(dirname "$dest")" ]; then rm -f "$tmp"; exit ${notDirectory}; fi`,
282
+ `if ${present("dest")}; then rm -f "$tmp"; exit ${exists}; fi`,
283
+ ...linkTempIntoPlace()
284
+ ].join("\n");
285
+ }
286
+ /** Default recursion depth for `readdir({ recursive: true })`, matching `LocalFilesystem`. */
287
+ const DEFAULT_MAX_DEPTH = 100;
288
+ const toAbsolute = (path) => posix.resolve("/", path);
289
+ /** The entry's own type: a symlink is a link even when it points at a directory. */
290
+ const isSymlink = (info) => info.symlinkTarget !== void 0;
291
+ function toWriteData(content) {
292
+ if (typeof content === "string") return content;
293
+ return content.buffer.slice(content.byteOffset, content.byteOffset + content.byteLength);
294
+ }
295
+ function matchesExtension(name, extension) {
296
+ if (!extension) return true;
297
+ const ext = posix.extname(name);
298
+ return (Array.isArray(extension) ? extension : [extension]).some((e) => e === ext || e === ext.slice(1));
299
+ }
300
+ /**
301
+ * A sandbox's disk, read and written through E2B's envd files API, with the
302
+ * operations the files API can't do in one call (copy, append, no-clobber
303
+ * writes and moves) run as one shell command inside the VM so they are atomic
304
+ * and file bytes never round-trip through the host. Paths are absolute inside
305
+ * the sandbox; this class does not confine paths or symlinks to any root.
306
+ *
307
+ * Providers supply how to reach the files API and the shell, and how to read
308
+ * their SDK's errors.
309
+ *
310
+ * If a provider's `runCommand` re-runs a command whose first run already
311
+ * finished (e.g. a retry after a dropped connection), the second run sees the
312
+ * first's result: a no-clobber copy, write or move reports `FileExistsError`
313
+ * or `FileNotFoundError`, and an append fails, even though the work was done.
314
+ */
315
+ var FilesApiFilesystem = class extends MastraFilesystem {
316
+ async readFile(path, options) {
317
+ const abs = toAbsolute(path);
318
+ const bytes = await this.runFiles("readFile", async (files) => {
319
+ try {
320
+ return await files.read(abs, { format: "bytes" });
321
+ } catch (error) {
322
+ const kind = this.errorKind(error);
323
+ if (kind === "not-found") throw new FileNotFoundError(path);
324
+ if (kind === "invalid-argument") throw new IsDirectoryError(path);
325
+ throw error;
326
+ }
327
+ });
328
+ const buffer = Buffer$1.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
329
+ return options?.encoding ? buffer.toString(options.encoding) : buffer;
330
+ }
331
+ async writeFile(path, content, options) {
332
+ const abs = toAbsolute(path);
333
+ if (options?.overwrite !== false) {
334
+ await this.runFiles("writeFile", async (files) => {
335
+ if (options?.recursive === false) await this.#assertParentDirectory(files, abs, path);
336
+ await this.#write(files, abs, path, toWriteData(content));
337
+ });
338
+ return;
339
+ }
340
+ const tmp = siblingTempPath(abs);
341
+ const upload = async (files) => {
342
+ if (options.recursive === false) await this.#assertParentDirectory(files, abs, path);
343
+ await this.#write(files, tmp, path, toWriteData(content));
344
+ };
345
+ await this.#publishTemp(tmp, "writeFile", upload, linkNoClobberCommand(tmp, abs), path, {
346
+ exists: () => new FileExistsError(path),
347
+ notDirectory: () => new NotDirectoryError(posix.dirname(path))
348
+ });
349
+ }
350
+ async appendFile(path, content) {
351
+ const abs = toAbsolute(path);
352
+ const tmp = siblingTempPath(abs);
353
+ const upload = (files) => this.#write(files, tmp, path, toWriteData(content));
354
+ await this.#publishTemp(tmp, "appendFile", upload, appendFromCommand(tmp, abs), path, { isDirectory: () => new IsDirectoryError(path) });
355
+ }
356
+ async deleteFile(path, options) {
357
+ const abs = toAbsolute(path);
358
+ await this.runFiles("deleteFile", async (files) => {
359
+ const info = await this.#info(files, abs);
360
+ if (!info) {
361
+ if (options?.force) return;
362
+ throw new FileNotFoundError(path);
363
+ }
364
+ if (!isSymlink(info) && info.type === "dir") throw new IsDirectoryError(path);
365
+ await files.remove(abs);
366
+ });
367
+ }
368
+ /**
369
+ * One `cp` inside the VM. A recursive copy keeps symlinks inside the tree as
370
+ * links; `overwrite: false` claims the destination atomically.
371
+ */
372
+ async copyFile(src, dest, options) {
373
+ const command = copyCommand(toAbsolute(src), toAbsolute(dest), {
374
+ recursive: options?.recursive,
375
+ overwrite: options?.overwrite
376
+ });
377
+ await this.#command("copyFile", command, `${src} -> ${dest}`, {
378
+ notFound: () => new FileNotFoundError(src),
379
+ isDirectory: () => new IsDirectoryError(src),
380
+ destIsDirectory: () => new IsDirectoryError(dest),
381
+ exists: () => new FileExistsError(dest)
382
+ });
383
+ }
384
+ async moveFile(src, dest, options) {
385
+ const srcAbs = toAbsolute(src);
386
+ const destAbs = toAbsolute(dest);
387
+ if (options?.overwrite === false) {
388
+ await this.#command("moveFile", moveNoClobberCommand(srcAbs, destAbs), `${src} -> ${dest}`, {
389
+ notFound: () => new FileNotFoundError(src),
390
+ exists: () => new FileExistsError(dest)
391
+ });
392
+ return;
393
+ }
394
+ await this.runFiles("moveFile", async (files) => {
395
+ try {
396
+ await files.rename(srcAbs, destAbs);
397
+ } catch (error) {
398
+ if (this.errorKind(error) === "not-found") throw new FileNotFoundError(src);
399
+ throw error;
400
+ }
401
+ });
402
+ }
403
+ async mkdir(path, options) {
404
+ const abs = toAbsolute(path);
405
+ await this.runFiles("mkdir", async (files) => {
406
+ if (options?.recursive === false) await this.#assertParentDirectory(files, abs, path);
407
+ try {
408
+ await files.makeDir(abs);
409
+ } catch (error) {
410
+ if (this.errorKind(error) === "invalid-argument") throw new FileExistsError(path);
411
+ throw error;
412
+ }
413
+ });
414
+ }
415
+ async rmdir(path, options) {
416
+ const abs = toAbsolute(path);
417
+ await this.runFiles("rmdir", async (files) => {
418
+ const info = await this.#info(files, abs);
419
+ if (!info) {
420
+ if (options?.force) return;
421
+ throw new DirectoryNotFoundError(path);
422
+ }
423
+ if (!options?.recursive) {
424
+ if (isSymlink(info) || info.type !== "dir") throw new NotDirectoryError(path);
425
+ if ((await files.list(abs)).length > 0) throw new DirectoryNotEmptyError(path);
426
+ } else if (!isSymlink(info) && info.type !== "dir") throw new NotDirectoryError(path);
427
+ await files.remove(abs);
428
+ });
429
+ }
430
+ async readdir(path, options) {
431
+ const abs = toAbsolute(path);
432
+ const depth = options?.recursive ? (options.maxDepth ?? DEFAULT_MAX_DEPTH) + 1 : 1;
433
+ const entries = await this.runFiles("readdir", async (files) => {
434
+ try {
435
+ return await files.list(abs, { depth });
436
+ } catch (error) {
437
+ const kind = this.errorKind(error);
438
+ if (kind === "not-found") throw new DirectoryNotFoundError(path);
439
+ if (kind === "invalid-argument") throw new NotDirectoryError(path);
440
+ throw error;
441
+ }
442
+ });
443
+ const result = [];
444
+ for (const entry of entries) {
445
+ if (!entry.type) continue;
446
+ const name = posix.relative(abs, entry.path);
447
+ const type = entry.type === "dir" ? "directory" : "file";
448
+ if (type === "file" && !matchesExtension(name, options?.extension)) continue;
449
+ const link = isSymlink(entry);
450
+ result.push({
451
+ name,
452
+ type,
453
+ ...type === "file" && !link && { size: entry.size },
454
+ ...link && {
455
+ isSymlink: true,
456
+ symlinkTarget: entry.symlinkTarget
457
+ }
458
+ });
459
+ }
460
+ return result;
461
+ }
462
+ async exists(path) {
463
+ const abs = toAbsolute(path);
464
+ return this.runFiles("exists", async (files) => Boolean((await this.#info(files, abs))?.type));
465
+ }
466
+ async stat(path) {
467
+ const abs = toAbsolute(path);
468
+ return this.runFiles("stat", async (files) => {
469
+ let info = await this.#info(files, abs);
470
+ if (info?.type && isSymlink(info)) info = await this.#info(files, posix.resolve(posix.dirname(abs), info.symlinkTarget));
471
+ if (!info?.type) throw new FileNotFoundError(path);
472
+ const modifiedAt = info.modifiedTime ?? /* @__PURE__ */ new Date(0);
473
+ return {
474
+ name: posix.basename(abs),
475
+ path: abs,
476
+ type: info.type === "dir" ? "directory" : "file",
477
+ size: info.type === "dir" ? 0 : info.size,
478
+ createdAt: modifiedAt,
479
+ modifiedAt
480
+ };
481
+ });
482
+ }
483
+ /** `getInfo` without following the entry itself, or `undefined` when nothing is there. */
484
+ async #info(files, abs) {
485
+ try {
486
+ return await files.getInfo(abs);
487
+ } catch (error) {
488
+ if (this.errorKind(error) === "not-found") return void 0;
489
+ throw error;
490
+ }
491
+ }
492
+ async #assertParentDirectory(files, abs, path) {
493
+ const parent = await this.#info(files, posix.dirname(abs));
494
+ if (!parent?.type) throw new DirectoryNotFoundError(posix.dirname(path));
495
+ if (parent.type !== "dir") throw new NotDirectoryError(posix.dirname(path));
496
+ }
497
+ async #write(files, abs, path, data) {
498
+ try {
499
+ await files.write(abs, data);
500
+ } catch (error) {
501
+ if (this.errorKind(error) === "invalid-argument") throw new IsDirectoryError(path);
502
+ throw error;
503
+ }
504
+ }
505
+ /**
506
+ * Upload the temp file `tmp`, then run the command that consumes it. The
507
+ * script deletes it on every path it controls; if the upload or the command
508
+ * fails part-way, delete it here so it doesn't linger in the user's tree.
509
+ */
510
+ async #publishTemp(tmp, operation, upload, script, subject, errors) {
511
+ try {
512
+ await this.runFiles(operation, upload);
513
+ await this.#command(operation, script, subject, errors);
514
+ } catch (error) {
515
+ await this.runFiles(operation, (files) => files.remove(tmp)).catch(() => {});
516
+ throw error;
517
+ }
518
+ }
519
+ /** Run a file command and turn its known exit codes into typed errors. */
520
+ async #command(operation, script, subject, errors) {
521
+ const result = await this.runCommand(operation, script);
522
+ if (result.exitCode === 0) return;
523
+ for (const [outcome, code] of Object.entries(FILE_COMMAND_EXIT)) {
524
+ const toError = errors[outcome];
525
+ if (code === result.exitCode && toError) throw toError();
526
+ }
527
+ throw new Error(`${operation} ${subject} failed (exit ${result.exitCode}): ${result.stderr.trim()}`);
528
+ }
529
+ };
530
+ //#endregion
531
+ //#region src/sandbox/filesystem.ts
532
+ /**
533
+ * envd's edge answers a paused or missing sandbox with a 404 that the SDK
534
+ * reports as `FileNotFoundError`. Same pattern as `E2BSandbox`'s dead-sandbox
535
+ * check, so those errors reach `retryOnDead` instead of reading as a missing file.
536
+ */
537
+ const DEAD_SANDBOX = /\b(?:paused\s+)?sandbox(?:\s+\S+)?\s+(?:was\s+)?not found\b/i;
538
+ /**
539
+ * The disk of an {@link E2BSandbox}, read and written through E2B's files API
540
+ * (`sandbox.e2b.files`). Each operation starts the sandbox if needed, and each
541
+ * files call is retried once on a sandbox that died server-side, like
542
+ * `executeCommand`. Copies, appends and no-clobber writes and moves run as one
543
+ * shell command in the VM, so they are atomic and file bytes don't round-trip
544
+ * through the host. Paths are absolute inside the sandbox; this class does not
545
+ * confine paths or symlinks to any root.
546
+ */
547
+ var E2BSandboxFilesystem = class extends FilesApiFilesystem {
548
+ id;
549
+ name = "E2BSandboxFilesystem";
550
+ provider = "e2b";
551
+ status = "ready";
552
+ #sandbox;
553
+ constructor(sandbox) {
554
+ super({ name: "E2BSandboxFilesystem" });
555
+ this.id = `${sandbox.id}-filesystem`;
556
+ this.#sandbox = sandbox;
557
+ }
558
+ /**
559
+ * Wrap each files call in `retryOnDead`, so a sandbox that died mid-operation
560
+ * is restarted and only the call that failed runs again; the calls before it
561
+ * are never replayed. If the sandbox was killed and replaced rather than
562
+ * paused, the rest of a multi-call operation runs against the new sandbox's disk.
563
+ */
564
+ async runFiles(_operation, fn) {
565
+ await this.#sandbox.ensureRunning();
566
+ const call = (invoke) => this.#sandbox.retryOnDead(() => invoke(this.#sandbox.e2b.files));
567
+ return fn({
568
+ read: (path) => call((files) => files.read(path, { format: "bytes" })),
569
+ write: (path, data) => call((files) => files.write(path, data)),
570
+ list: (path, opts) => call((files) => files.list(path, opts)),
571
+ makeDir: (path) => call((files) => files.makeDir(path)),
572
+ rename: (oldPath, newPath) => call((files) => files.rename(oldPath, newPath)),
573
+ remove: (path) => call((files) => files.remove(path)),
574
+ getInfo: (path) => call((files) => files.getInfo(path))
575
+ });
576
+ }
577
+ runCommand(operation, script) {
578
+ if (!this.#sandbox.executeCommand) throw new Error(`${operation} needs E2BSandbox.executeCommand`);
579
+ return this.#sandbox.executeCommand("sh", ["-c", script]);
580
+ }
581
+ errorKind(error) {
582
+ if (error instanceof NotFoundError && !(error instanceof SandboxNotFoundError) && !(error instanceof Error && DEAD_SANDBOX.test(error.message))) return "not-found";
583
+ if (error instanceof InvalidArgumentError) return "invalid-argument";
584
+ }
585
+ };
586
+ //#endregion
77
587
  //#region src/sandbox/mounts/types.ts
78
588
  const LOG_PREFIX = "[@mastra/e2b]";
79
589
  const SAFE_S3_BUCKET_NAME = /^[a-z0-9][a-z0-9.\-]{1,61}[a-z0-9]$/;
@@ -126,7 +636,7 @@ function validatePrefix(prefix) {
126
636
  * Arguments containing only safe characters are returned as-is.
127
637
  * All others are wrapped in single quotes with embedded single quotes escaped.
128
638
  */
129
- function shellQuote$2(arg) {
639
+ function shellQuote(arg) {
130
640
  if (/^[a-zA-Z0-9._\-/@:=]+$/.test(arg)) return arg;
131
641
  return "'" + arg.replace(/'/g, "'\\''") + "'";
132
642
  }
@@ -179,12 +689,12 @@ Error details: ${installResult.stderr || installResult.stdout}`);
179
689
  };
180
690
  if (hasCredentials) {
181
691
  const credentialsContent = useSessionToken ? [
182
- `export AWSACCESSKEYID=${shellQuote$2(config.accessKeyId)}`,
183
- `export AWSSECRETACCESSKEY=${shellQuote$2(config.secretAccessKey)}`,
184
- `export AWSSESSIONTOKEN=${shellQuote$2(config.sessionToken)}`,
185
- `export AWS_ACCESS_KEY_ID=${shellQuote$2(config.accessKeyId)}`,
186
- `export AWS_SECRET_ACCESS_KEY=${shellQuote$2(config.secretAccessKey)}`,
187
- `export AWS_SESSION_TOKEN=${shellQuote$2(config.sessionToken)}`
692
+ `export AWSACCESSKEYID=${shellQuote(config.accessKeyId)}`,
693
+ `export AWSSECRETACCESSKEY=${shellQuote(config.secretAccessKey)}`,
694
+ `export AWSSESSIONTOKEN=${shellQuote(config.sessionToken)}`,
695
+ `export AWS_ACCESS_KEY_ID=${shellQuote(config.accessKeyId)}`,
696
+ `export AWS_SECRET_ACCESS_KEY=${shellQuote(config.secretAccessKey)}`,
697
+ `export AWS_SESSION_TOKEN=${shellQuote(config.sessionToken)}`
188
698
  ].join("\n") : `${config.accessKeyId}:${config.secretAccessKey}`;
189
699
  await sandbox.commands.run(`sudo rm -f ${credentialsPath}`);
190
700
  try {
@@ -212,8 +722,8 @@ Error details: ${installResult.stderr || installResult.stdout}`);
212
722
  mountOptions.push("ro");
213
723
  logger.debug(`${LOG_PREFIX} Mounting as read-only`);
214
724
  }
215
- const s3fsCmd = `s3fs ${shellQuote$2(bucketArg)} ${shellQuote$2(mountPath)} -o ${mountOptions.join(" -o ")}`;
216
- const mountCmd = useSessionToken ? `sudo sh -c ${shellQuote$2(`. ${credentialsPath} && exec ${s3fsCmd}`)}` : `sudo ${s3fsCmd}`;
725
+ const s3fsCmd = `s3fs ${shellQuote(bucketArg)} ${shellQuote(mountPath)} -o ${mountOptions.join(" -o ")}`;
726
+ const mountCmd = useSessionToken ? `sudo sh -c ${shellQuote(`. ${credentialsPath} && exec ${s3fsCmd}`)}` : `sudo ${s3fsCmd}`;
217
727
  logger.debug(`${LOG_PREFIX} Mounting S3:`, hasCredentials ? mountCmd.replace(credentialsPath, "***") : mountCmd);
218
728
  try {
219
729
  const result = await sandbox.commands.run(mountCmd, { timeoutMs: 6e4 });
@@ -236,7 +746,7 @@ Error details: ${installResult.stderr || installResult.stdout}`);
236
746
  } finally {
237
747
  if (useSessionToken) await removeSessionCredentials();
238
748
  }
239
- if ((await sandbox.commands.run(`mountpoint -q ${shellQuote$2(mountPath)}`)).exitCode !== 0) throw new Error(`s3fs returned exit 0 but ${mountPath} is not a mountpoint. The s3fs daemon likely failed during FUSE init (common causes: region mismatch, invalid credentials, or an S3-compatible endpoint that rejects the signature). Re-run inside the sandbox with '-f -o dbglevel=info' to see the underlying error.`);
749
+ if ((await sandbox.commands.run(`mountpoint -q ${shellQuote(mountPath)}`)).exitCode !== 0) throw new Error(`s3fs returned exit 0 but ${mountPath} is not a mountpoint. The s3fs daemon likely failed during FUSE init (common causes: region mismatch, invalid credentials, or an S3-compatible endpoint that rejects the signature). Re-run inside the sandbox with '-f -o dbglevel=info' to see the underlying error.`);
240
750
  }
241
751
  //#endregion
242
752
  //#region src/sandbox/mounts/gcs.ts
@@ -256,7 +766,7 @@ async function mountGCS(mountPath, config, ctx) {
256
766
  }
257
767
  const [uid, gid] = (await sandbox.commands.run("id -u && id -g")).stdout.trim().split("\n");
258
768
  const uidGidFlags = uid && gid ? `--uid=${uid} --gid=${gid}` : "";
259
- const onlyDirFlag = config.prefix ? ` --only-dir=${shellQuote$2(validatePrefix(config.prefix))}` : "";
769
+ const onlyDirFlag = config.prefix ? ` --only-dir=${shellQuote(validatePrefix(config.prefix))}` : "";
260
770
  const hasCredentials = !!config.serviceAccountKey;
261
771
  let mountCmd;
262
772
  if (hasCredentials) {
@@ -464,9 +974,9 @@ Error details: ${verifyResult.stderr || verifyResult.stdout || installResult?.st
464
974
  await sandbox.commands.run(`sudo rm -f ${configPath}`);
465
975
  await sandbox.files.write(configPath, yaml);
466
976
  await sandbox.commands.run(`sudo chown root:root ${configPath} && sudo chmod 600 ${configPath}`);
467
- await sandbox.commands.run(`sudo rm -rf ${shellQuote$2(cachePath)} && sudo mkdir -p ${shellQuote$2(cachePath)}`);
468
- const prefixFlags = prefix ? ` --virtual-directory=true --subdirectory=${shellQuote$2(prefix)}` : "";
469
- const mountCmd = `sudo blobfuse2 mount ${shellQuote$2(mountPath)} --config-file=${shellQuote$2(configPath)}${prefixFlags}`;
977
+ await sandbox.commands.run(`sudo rm -rf ${shellQuote(cachePath)} && sudo mkdir -p ${shellQuote(cachePath)}`);
978
+ const prefixFlags = prefix ? ` --virtual-directory=true --subdirectory=${shellQuote(prefix)}` : "";
979
+ const mountCmd = `sudo blobfuse2 mount ${shellQuote(mountPath)} --config-file=${shellQuote(configPath)}${prefixFlags}`;
470
980
  logger.debug(`${LOG_PREFIX} Mounting Azure Blob:`, mountCmd);
471
981
  try {
472
982
  const result = await sandbox.commands.run(mountCmd, { timeoutMs: 6e4 });
@@ -705,6 +1215,7 @@ var E2BSandbox = class E2BSandbox extends MastraSandbox {
705
1215
  _sandbox = null;
706
1216
  _createdAt = null;
707
1217
  _isRetrying = false;
1218
+ _filesystem;
708
1219
  timeout;
709
1220
  templateSpec;
710
1221
  metadata;
@@ -977,6 +1488,15 @@ var E2BSandbox = class E2BSandbox extends MastraSandbox {
977
1488
  };
978
1489
  }
979
1490
  /**
1491
+ * The sandbox's disk through E2B's files API: one request per operation
1492
+ * instead of a shell command. Paths are absolute inside the sandbox and are
1493
+ * not confined to any root.
1494
+ */
1495
+ get filesystem() {
1496
+ this._filesystem ??= new E2BSandboxFilesystem(this);
1497
+ return this._filesystem;
1498
+ }
1499
+ /**
980
1500
  * Bulk-write files into the sandbox filesystem via the SDK's native upload.
981
1501
  *
982
1502
  * Per-file permission modes are not supported; an explicit `mode` is
@@ -1607,82 +2127,6 @@ var E2BSandbox = class E2BSandbox extends MastraSandbox {
1607
2127
  }
1608
2128
  };
1609
2129
  //#endregion
1610
- //#region ../../packages/_internals/workspace/dist/index.js
1611
- /** Directory, relative to the template's build cwd, that holds every marker below. */
1612
- const SETUP_MARKER_DIR = ".mastra-sandbox";
1613
- /**
1614
- * Setup completion marker shared by repo templates and their consumers.
1615
- *
1616
- * A repo template writes this file beside the checkout as its last build
1617
- * step, so it exists only in images where every setup command succeeded. Its
1618
- * content is a digest of the setup commands the image ran, letting a sandbox
1619
- * booted from the image tell whether the setup it is about to run already
1620
- * happened. Relative to the template's build cwd, which is also the runtime
1621
- * working directory the repo was cloned into.
1622
- *
1623
- * This is the single-repository marker. Templates built from a repository
1624
- * list write one marker per repository instead ({@link repoSetupMarkerPath}).
1625
- */
1626
- const SETUP_MARKER_PATH = ".mastra-sandbox/setup";
1627
- /**
1628
- * Marker for the workspace-level setup of a multi-repository template: the
1629
- * commands that ran at the working directory after every repository was set
1630
- * up. Written even when there were no such commands, so a consumer can tell
1631
- * "ran nothing" from "never built".
1632
- */
1633
- const WORKSPACE_SETUP_MARKER_PATH = ".mastra-sandbox/workspace-setup";
1634
- /**
1635
- * One line per repository directory whose setup failed while the build was
1636
- * told to continue. Absent when nothing failed. Consumers check this before
1637
- * trusting a per-repository marker: that marker means every step ran, not
1638
- * that every step succeeded.
1639
- */
1640
- const SETUP_FAILED_MARKER_PATH = ".mastra-sandbox/setup-failed";
1641
- /** Per-repository marker of a multi-repository template, keyed by the clone directory name. */
1642
- function repoSetupMarkerPath(repoDir) {
1643
- return `${SETUP_MARKER_DIR}/repos/${repoDir}`;
1644
- }
1645
- /** Blank entries never become build steps, so they never count toward the digest either. */
1646
- function normalizeSetupCommands$1(setupCommand) {
1647
- return (setupCommand === void 0 ? [] : Array.isArray(setupCommand) ? setupCommand : [setupCommand]).filter((command) => command.trim() !== "");
1648
- }
1649
- /** The marker content for a setup command list: `sha256:<hex>` over the commands joined by newlines. */
1650
- function setupMarkerContent(setupCommand) {
1651
- return `sha256:${createHash("sha256").update(normalizeSetupCommands$1(setupCommand).join("\n")).digest("hex")}`;
1652
- }
1653
- /** Shell step that writes the marker relative to the cwd. `content` is a digest, so it is shell-safe. */
1654
- function setupMarkerCommand(content, markerPath = SETUP_MARKER_PATH) {
1655
- return `mkdir -p "$(dirname "${markerPath}")" && printf '%s' '${content}' > "${markerPath}"`;
1656
- }
1657
- /** A clone directory name is one path segment of word characters, dots and dashes, so it never needs quoting. */
1658
- const REPO_DIR_PATTERN = /^[\w.-]+$/;
1659
- /**
1660
- * Shell step that runs one setup command inside a repository clone.
1661
- *
1662
- * Without the guard this is the plain `cd "<dir>" && <cmd>` step. With it,
1663
- * the `cd` sits inside the parenthesized group so that the failure branch
1664
- * runs back at the build cwd and the failure list lands at the workspace
1665
- * level, never inside the repository.
1666
- */
1667
- function guardedSetupCommand({ repoDir, command, continueOnFailure }) {
1668
- if (!REPO_DIR_PATTERN.test(repoDir)) throw new TypeError(`Repository directory "${repoDir}" must match ${REPO_DIR_PATTERN}.`);
1669
- if (!continueOnFailure) return `cd "${repoDir}" && ${command}`;
1670
- return `( cd "${repoDir}" && sh -c ${shellQuote$1(command)} ) || { mkdir -p "${SETUP_MARKER_DIR}" && grep -qxF -- '${repoDir}' "${SETUP_FAILED_MARKER_PATH}" 2>/dev/null || printf '%s\\n' '${repoDir}' >> "${SETUP_FAILED_MARKER_PATH}"; }`;
1671
- }
1672
- function shellQuote$1(value) {
1673
- return `'${value.replace(/'/g, `'\\''`)}'`;
1674
- }
1675
- function repoCloneCommand({ cloneUrl, destination, branch, tokenEnv }) {
1676
- return `git ${tokenEnv ? `${gitAuthFlag$1(tokenEnv)} ` : ""}clone --depth=1 --single-branch ${branch ? `--branch ${shellQuote(branch)} ` : ""}${shellQuote(cloneUrl)} ${shellQuote(destination)}`;
1677
- }
1678
- /** Per-invocation auth header; `-c` config never reaches `.git/config`. */
1679
- function gitAuthFlag$1(tokenEnv) {
1680
- return `-c http.extraheader="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$${tokenEnv}" | base64 -w0)"`;
1681
- }
1682
- function shellQuote(value) {
1683
- return `'${value.replace(/'/g, `'\\''`)}'`;
1684
- }
1685
- //#endregion
1686
2130
  //#region src/utils/repo-template.ts
1687
2131
  /**
1688
2132
  * Sha-tagged repo templates.
@@ -2586,6 +3030,6 @@ var E2BCodeModeTransport = class {
2586
3030
  }
2587
3031
  };
2588
3032
  //#endregion
2589
- export { DEFAULT_NODE_VERSION, E2BCodeModeTransport, E2BFactorySandbox, E2BProcessManager, E2BSandbox, createDefaultMountableTemplate, createRepoTemplate, e2bSandboxProvider, isDeferredNamedTemplateSpec, isNamedTemplateSpec, refreshRepoTemplate, repoTemplateRef };
3033
+ export { DEFAULT_NODE_VERSION, E2BCodeModeTransport, E2BFactorySandbox, E2BProcessManager, E2BSandbox, E2BSandboxFilesystem, createDefaultMountableTemplate, createRepoTemplate, e2bSandboxProvider, isDeferredNamedTemplateSpec, isNamedTemplateSpec, refreshRepoTemplate, repoTemplateRef };
2590
3034
 
2591
3035
  //# sourceMappingURL=index.js.map