@mastra/e2b 0.0.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.cjs ADDED
@@ -0,0 +1,858 @@
1
+ 'use strict';
2
+
3
+ var workspace = require('@mastra/core/workspace');
4
+ var e2b = require('e2b');
5
+ var crypto = require('crypto');
6
+
7
+ // src/sandbox/index.ts
8
+
9
+ // src/utils/shell-quote.ts
10
+ function shellQuote(arg) {
11
+ if (/^[a-zA-Z0-9._\-/@:=]+$/.test(arg)) return arg;
12
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
13
+ }
14
+ var MOUNTABLE_TEMPLATE_VERSION = "v1";
15
+ function createDefaultMountableTemplate() {
16
+ const aptPackages = ["s3fs", "fuse"];
17
+ const config = { version: MOUNTABLE_TEMPLATE_VERSION, aptPackages };
18
+ const hash = crypto.createHash("sha256").update(JSON.stringify(config, Object.keys(config).sort())).digest("hex").slice(0, 16);
19
+ const template = e2b.Template().fromTemplate("base").aptInstall(aptPackages);
20
+ return {
21
+ template,
22
+ id: `mastra-${hash}`,
23
+ aptPackages
24
+ };
25
+ }
26
+
27
+ // src/sandbox/mounts/types.ts
28
+ var LOG_PREFIX = "[@mastra/e2b]";
29
+ var SAFE_BUCKET_NAME = /^[a-z0-9][a-z0-9.\-]{1,61}[a-z0-9]$/;
30
+ function validateBucketName(bucket) {
31
+ if (!SAFE_BUCKET_NAME.test(bucket)) {
32
+ throw new Error(
33
+ `Invalid bucket name: "${bucket}". Bucket names must be 3-63 characters, lowercase alphanumeric, hyphens, or dots.`
34
+ );
35
+ }
36
+ }
37
+ function validateEndpoint(endpoint) {
38
+ try {
39
+ new URL(endpoint);
40
+ } catch {
41
+ throw new Error(`Invalid endpoint URL: "${endpoint}"`);
42
+ }
43
+ }
44
+
45
+ // src/sandbox/mounts/s3.ts
46
+ async function mountS3(mountPath, config, ctx) {
47
+ const { sandbox, logger } = ctx;
48
+ validateBucketName(config.bucket);
49
+ if (config.endpoint) {
50
+ validateEndpoint(config.endpoint);
51
+ }
52
+ const checkResult = await sandbox.commands.run('which s3fs || echo "not found"');
53
+ if (checkResult.stdout.includes("not found")) {
54
+ logger.warn(`${LOG_PREFIX} s3fs not found, attempting runtime installation...`);
55
+ logger.info(
56
+ `${LOG_PREFIX} Tip: For faster startup, use createMountableTemplate() to pre-install s3fs in your sandbox template`
57
+ );
58
+ await sandbox.commands.run("sudo apt-get update 2>&1", { timeoutMs: 6e4 });
59
+ const installResult = await sandbox.commands.run(
60
+ "sudo apt-get install -y s3fs fuse 2>&1 || sudo apt-get install -y s3fs-fuse fuse 2>&1",
61
+ { timeoutMs: 12e4 }
62
+ );
63
+ if (installResult.exitCode !== 0) {
64
+ throw new Error(
65
+ `Failed to install s3fs. For S3 mounting, your template needs s3fs and fuse packages.
66
+
67
+ Option 1: Use createMountableTemplate() helper:
68
+ import { E2BSandbox, createMountableTemplate } from '@mastra/e2b';
69
+ const sandbox = new E2BSandbox({ template: createMountableTemplate() });
70
+
71
+ Option 2: Customize the base template:
72
+ new E2BSandbox({ template: base => base.aptInstall(['your-packages']) })
73
+
74
+ Error details: ${installResult.stderr || installResult.stdout}`
75
+ );
76
+ }
77
+ }
78
+ const idResult = await sandbox.commands.run("id -u && id -g");
79
+ const [uid, gid] = idResult.stdout.trim().split("\n");
80
+ const hasCredentials = config.accessKeyId && config.secretAccessKey;
81
+ const credentialsPath = "/tmp/.passwd-s3fs";
82
+ if (!hasCredentials && config.endpoint) {
83
+ throw new Error(
84
+ `S3-compatible storage requires credentials. Detected endpoint: ${config.endpoint}. The public_bucket option only works for AWS S3 public buckets, not R2, MinIO, etc.`
85
+ );
86
+ }
87
+ if (hasCredentials) {
88
+ const credentialsContent = `${config.accessKeyId}:${config.secretAccessKey}`;
89
+ await sandbox.commands.run(`sudo rm -f ${credentialsPath}`);
90
+ await sandbox.files.write(credentialsPath, credentialsContent);
91
+ await sandbox.commands.run(`chmod 600 ${credentialsPath}`);
92
+ }
93
+ const mountOptions = [];
94
+ if (hasCredentials) {
95
+ mountOptions.push(`passwd_file=${credentialsPath}`);
96
+ } else {
97
+ mountOptions.push("public_bucket=1");
98
+ logger.debug(`${LOG_PREFIX} No credentials provided, mounting as public bucket (read-only)`);
99
+ }
100
+ mountOptions.push("allow_other");
101
+ if (uid && gid) {
102
+ mountOptions.push(`uid=${uid}`, `gid=${gid}`);
103
+ }
104
+ if (config.endpoint) {
105
+ const endpoint = config.endpoint.replace(/\/$/, "");
106
+ mountOptions.push(`url=${endpoint}`, "use_path_request_style", "sigv4", "nomultipart");
107
+ }
108
+ if (config.readOnly) {
109
+ mountOptions.push("ro");
110
+ logger.debug(`${LOG_PREFIX} Mounting as read-only`);
111
+ }
112
+ const mountCmd = `sudo s3fs ${config.bucket} ${mountPath} -o ${mountOptions.join(" -o ")}`;
113
+ logger.debug(`${LOG_PREFIX} Mounting S3:`, hasCredentials ? mountCmd.replace(credentialsPath, "***") : mountCmd);
114
+ try {
115
+ const result = await sandbox.commands.run(mountCmd, { timeoutMs: 6e4 });
116
+ logger.debug(`${LOG_PREFIX} s3fs result:`, {
117
+ exitCode: result.exitCode,
118
+ stdout: result.stdout,
119
+ stderr: result.stderr
120
+ });
121
+ if (result.exitCode !== 0) {
122
+ throw new Error(`Failed to mount S3 bucket: ${result.stderr || result.stdout}`);
123
+ }
124
+ } catch (error) {
125
+ const errorObj = error;
126
+ const stderr = errorObj.result?.stderr || "";
127
+ const stdout = errorObj.result?.stdout || "";
128
+ logger.error(`${LOG_PREFIX} s3fs error:`, { stderr, stdout, error: String(error) });
129
+ throw new Error(`Failed to mount S3 bucket: ${stderr || stdout || error}`);
130
+ }
131
+ }
132
+
133
+ // src/sandbox/mounts/gcs.ts
134
+ async function mountGCS(mountPath, config, ctx) {
135
+ const { sandbox, logger } = ctx;
136
+ validateBucketName(config.bucket);
137
+ const checkResult = await sandbox.commands.run('which gcsfuse || echo "not found"');
138
+ if (checkResult.stdout.includes("not found")) {
139
+ const codenameResult = await sandbox.commands.run("lsb_release -cs 2>/dev/null || echo jammy");
140
+ const codename = codenameResult.stdout.trim() || "jammy";
141
+ await sandbox.commands.run(
142
+ `curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/gcsfuse.gpg && echo "deb [signed-by=/etc/apt/keyrings/gcsfuse.gpg] https://packages.cloud.google.com/apt gcsfuse-${codename} main" | sudo tee /etc/apt/sources.list.d/gcsfuse.list && sudo apt-get update && sudo apt-get install -y gcsfuse`,
143
+ { timeoutMs: 12e4 }
144
+ );
145
+ }
146
+ const idResult = await sandbox.commands.run("id -u && id -g");
147
+ const [uid, gid] = idResult.stdout.trim().split("\n");
148
+ const uidGidFlags = uid && gid ? `--uid=${uid} --gid=${gid}` : "";
149
+ const hasCredentials = !!config.serviceAccountKey;
150
+ let mountCmd;
151
+ if (hasCredentials) {
152
+ const keyPath = "/tmp/gcs-key.json";
153
+ await sandbox.commands.run(`sudo rm -f ${keyPath}`);
154
+ await sandbox.files.write(keyPath, config.serviceAccountKey);
155
+ await sandbox.commands.run(`sudo chown root:root ${keyPath} && sudo chmod 600 ${keyPath}`);
156
+ mountCmd = `sudo gcsfuse --key-file=${keyPath} -o allow_other ${uidGidFlags} ${config.bucket} ${mountPath}`;
157
+ } else {
158
+ logger.debug(`${LOG_PREFIX} No credentials provided, mounting GCS as public bucket (read-only)`);
159
+ mountCmd = `sudo gcsfuse --anonymous-access -o allow_other ${uidGidFlags} ${config.bucket} ${mountPath}`;
160
+ }
161
+ logger.debug(`${LOG_PREFIX} Mounting GCS:`, mountCmd);
162
+ try {
163
+ const result = await sandbox.commands.run(mountCmd, { timeoutMs: 6e4 });
164
+ logger.debug(`${LOG_PREFIX} gcsfuse result:`, {
165
+ exitCode: result.exitCode,
166
+ stdout: result.stdout,
167
+ stderr: result.stderr
168
+ });
169
+ if (result.exitCode !== 0) {
170
+ throw new Error(`Failed to mount GCS bucket: ${result.stderr || result.stdout}`);
171
+ }
172
+ } catch (error) {
173
+ const errorObj = error;
174
+ const stderr = errorObj.result?.stderr || "";
175
+ const stdout = errorObj.result?.stdout || "";
176
+ logger.error(`${LOG_PREFIX} gcsfuse error:`, { stderr, stdout, error: String(error) });
177
+ throw new Error(`Failed to mount GCS bucket: ${stderr || stdout || error}`);
178
+ }
179
+ }
180
+
181
+ // src/sandbox/index.ts
182
+ var SAFE_MOUNT_PATH = /^\/[a-zA-Z0-9_.\-/]+$/;
183
+ function validateMountPath(mountPath) {
184
+ if (!SAFE_MOUNT_PATH.test(mountPath)) {
185
+ throw new Error(
186
+ `Invalid mount path: ${mountPath}. Must be an absolute path with alphanumeric, dash, dot, underscore, or slash characters only.`
187
+ );
188
+ }
189
+ }
190
+ var SAFE_MARKER_NAME = /^mount-[a-z0-9]+$/;
191
+ var E2BSandbox = class extends workspace.MastraSandbox {
192
+ id;
193
+ name = "E2BSandbox";
194
+ provider = "e2b";
195
+ // Status is managed by base class lifecycle methods
196
+ status = "pending";
197
+ _sandbox = null;
198
+ _createdAt = null;
199
+ _isRetrying = false;
200
+ timeout;
201
+ templateSpec;
202
+ env;
203
+ metadata;
204
+ configuredRuntimes;
205
+ connectionOpts;
206
+ // Non-optional (initialized by BaseSandbox)
207
+ /** Resolved template ID after building (if needed) */
208
+ _resolvedTemplateId;
209
+ /** Promise for template preparation (started in constructor) */
210
+ _templatePreparePromise;
211
+ constructor(options = {}) {
212
+ super({ name: "E2BSandbox", onStart: options.onStart, onStop: options.onStop, onDestroy: options.onDestroy });
213
+ this.id = options.id ?? this.generateId();
214
+ this.timeout = options.timeout ?? 3e5;
215
+ this.templateSpec = options.template;
216
+ this.env = options.env ?? {};
217
+ this.metadata = options.metadata ?? {};
218
+ this.configuredRuntimes = options.runtimes ?? ["node", "python", "bash"];
219
+ this.connectionOpts = {
220
+ ...options.domain && { domain: options.domain },
221
+ ...options.apiUrl && { apiUrl: options.apiUrl },
222
+ ...options.apiKey && { apiKey: options.apiKey },
223
+ ...options.accessToken && { accessToken: options.accessToken }
224
+ };
225
+ this._templatePreparePromise = this.resolveTemplate().catch((err) => {
226
+ this.logger.debug(`${LOG_PREFIX} Template preparation error (will retry on start):`, err);
227
+ return "";
228
+ });
229
+ }
230
+ generateId() {
231
+ return `e2b-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
232
+ }
233
+ get supportedRuntimes() {
234
+ return this.configuredRuntimes;
235
+ }
236
+ get defaultRuntime() {
237
+ return this.configuredRuntimes[0] ?? "node";
238
+ }
239
+ /**
240
+ * Get the underlying E2B Sandbox instance for direct access to E2B APIs.
241
+ *
242
+ * Use this when you need to access E2B features not exposed through the
243
+ * WorkspaceSandbox interface (e.g., files API, ports, etc.).
244
+ *
245
+ * @throws {SandboxNotReadyError} If the sandbox has not been started
246
+ *
247
+ * @example Direct file operations
248
+ * ```typescript
249
+ * const e2bSandbox = sandbox.instance;
250
+ * await e2bSandbox.files.write('/tmp/test.txt', 'Hello');
251
+ * const content = await e2bSandbox.files.read('/tmp/test.txt');
252
+ * const files = await e2bSandbox.files.list('/tmp');
253
+ * ```
254
+ *
255
+ * @example Access ports
256
+ * ```typescript
257
+ * const e2bSandbox = sandbox.instance;
258
+ * const url = e2bSandbox.getHost(3000);
259
+ * ```
260
+ */
261
+ get instance() {
262
+ if (!this._sandbox) {
263
+ throw new workspace.SandboxNotReadyError(this.id);
264
+ }
265
+ return this._sandbox;
266
+ }
267
+ // ---------------------------------------------------------------------------
268
+ // Mount Support
269
+ // ---------------------------------------------------------------------------
270
+ /**
271
+ * Mount a filesystem at a path in the sandbox.
272
+ * Uses FUSE tools (s3fs, gcsfuse) to mount cloud storage.
273
+ */
274
+ async mount(filesystem, mountPath) {
275
+ validateMountPath(mountPath);
276
+ if (!this._sandbox) {
277
+ throw new workspace.SandboxNotReadyError(this.id);
278
+ }
279
+ this.logger.debug(`${LOG_PREFIX} Mounting "${mountPath}"...`);
280
+ const config = filesystem.getMountConfig?.();
281
+ if (!config) {
282
+ const error = `Filesystem "${filesystem.id}" does not provide a mount config`;
283
+ this.logger.error(`${LOG_PREFIX} ${error}`);
284
+ this.mounts.set(mountPath, { filesystem, state: "error", error });
285
+ return { success: false, mountPath, error };
286
+ }
287
+ const existingMount = await this.checkExistingMount(mountPath, config);
288
+ if (existingMount === "matching") {
289
+ this.logger.debug(
290
+ `${LOG_PREFIX} Detected existing mount for ${filesystem.provider} ("${filesystem.id}") at "${mountPath}" with correct config, skipping`
291
+ );
292
+ this.mounts.set(mountPath, { state: "mounted", config });
293
+ return { success: true, mountPath };
294
+ } else if (existingMount === "mismatched") {
295
+ this.logger.debug(`${LOG_PREFIX} Config mismatch, unmounting to re-mount with new config...`);
296
+ await this.unmount(mountPath);
297
+ }
298
+ this.logger.debug(`${LOG_PREFIX} Config type: ${config.type}`);
299
+ this.mounts.set(mountPath, { filesystem, state: "mounting", config });
300
+ try {
301
+ const checkResult = await this._sandbox.commands.run(
302
+ `[ -d "${mountPath}" ] && [ "$(ls -A "${mountPath}" 2>/dev/null)" ] && echo "non-empty" || echo "ok"`
303
+ );
304
+ if (checkResult.stdout.trim() === "non-empty") {
305
+ const error = `Cannot mount at ${mountPath}: directory exists and is not empty. Mounting would hide existing files. Use a different path or empty the directory first.`;
306
+ this.logger.error(`${LOG_PREFIX} ${error}`);
307
+ this.mounts.set(mountPath, { filesystem, state: "error", config, error });
308
+ return { success: false, mountPath, error };
309
+ }
310
+ } catch {
311
+ }
312
+ try {
313
+ this.logger.debug(`${LOG_PREFIX} Creating mount directory for ${mountPath}...`);
314
+ const mkdirCommand = `sudo mkdir -p "${mountPath}" && sudo chown $(id -u):$(id -g) "${mountPath}"`;
315
+ this.logger.debug(`${LOG_PREFIX} Running command: ${mkdirCommand}`);
316
+ const mkdirResult = await this._sandbox.commands.run(mkdirCommand);
317
+ this.logger.debug(`${LOG_PREFIX} Created mount directory for mount path "${mountPath}":`, mkdirResult);
318
+ } catch (mkdirError) {
319
+ this.logger.debug(`${LOG_PREFIX} mkdir error for "${mountPath}":`, mkdirError);
320
+ this.mounts.set(mountPath, { filesystem, state: "error", config, error: String(mkdirError) });
321
+ return { success: false, mountPath, error: String(mkdirError) };
322
+ }
323
+ const mountCtx = {
324
+ sandbox: this._sandbox,
325
+ logger: this.logger
326
+ };
327
+ try {
328
+ switch (config.type) {
329
+ case "s3":
330
+ this.logger.debug(`${LOG_PREFIX} Mounting S3 bucket at ${mountPath}...`);
331
+ await mountS3(mountPath, config, mountCtx);
332
+ this.logger.debug(`${LOG_PREFIX} Mounted S3 bucket at ${mountPath}`);
333
+ break;
334
+ case "gcs":
335
+ this.logger.debug(`${LOG_PREFIX} Mounting GCS bucket at ${mountPath}...`);
336
+ await mountGCS(mountPath, config, mountCtx);
337
+ this.logger.debug(`${LOG_PREFIX} Mounted GCS bucket at ${mountPath}`);
338
+ break;
339
+ default:
340
+ this.mounts.set(mountPath, {
341
+ filesystem,
342
+ state: "unsupported",
343
+ config,
344
+ error: `Unsupported mount type: ${config.type}`
345
+ });
346
+ return {
347
+ success: false,
348
+ mountPath,
349
+ error: `Unsupported mount type: ${config.type}`
350
+ };
351
+ }
352
+ } catch (error) {
353
+ this.logger.error(
354
+ `${LOG_PREFIX} Error mounting "${filesystem.provider}" (${filesystem.id}) at "${mountPath}":`,
355
+ error
356
+ );
357
+ this.mounts.set(mountPath, { filesystem, state: "error", config, error: String(error) });
358
+ try {
359
+ await this._sandbox.commands.run(`sudo rmdir "${mountPath}" 2>/dev/null || true`);
360
+ this.logger.debug(`${LOG_PREFIX} Cleaned up directory after failed mount: ${mountPath}`);
361
+ } catch {
362
+ }
363
+ return { success: false, mountPath, error: String(error) };
364
+ }
365
+ this.mounts.set(mountPath, { state: "mounted", config });
366
+ await this.writeMarkerFile(mountPath);
367
+ this.logger.debug(`${LOG_PREFIX} Mounted ${mountPath}`);
368
+ return { success: true, mountPath };
369
+ }
370
+ /**
371
+ * Write marker file for detecting config changes on reconnect.
372
+ * Stores both the mount path and config hash in the file.
373
+ */
374
+ async writeMarkerFile(mountPath) {
375
+ if (!this._sandbox) return;
376
+ const markerContent = this.mounts.getMarkerContent(mountPath);
377
+ if (!markerContent) return;
378
+ const filename = this.mounts.markerFilename(mountPath);
379
+ const markerPath = `/tmp/.mastra-mounts/${filename}`;
380
+ try {
381
+ await this._sandbox.commands.run("mkdir -p /tmp/.mastra-mounts");
382
+ await this._sandbox.files.write(markerPath, markerContent);
383
+ } catch {
384
+ this.logger.debug(`${LOG_PREFIX} Warning: Could not write marker file at ${markerPath}`);
385
+ }
386
+ }
387
+ /**
388
+ * Unmount a filesystem from a path in the sandbox.
389
+ */
390
+ async unmount(mountPath) {
391
+ validateMountPath(mountPath);
392
+ if (!this._sandbox) {
393
+ throw new workspace.SandboxNotReadyError(this.id);
394
+ }
395
+ this.logger.debug(`${LOG_PREFIX} Unmounting ${mountPath}...`);
396
+ try {
397
+ const result = await this._sandbox.commands.run(
398
+ `sudo fusermount -u "${mountPath}" 2>/dev/null || sudo umount "${mountPath}"`
399
+ );
400
+ if (result.exitCode !== 0) {
401
+ this.logger.debug(`${LOG_PREFIX} Unmount warning: ${result.stderr || result.stdout}`);
402
+ }
403
+ } catch (error) {
404
+ this.logger.debug(`${LOG_PREFIX} Unmount error:`, error);
405
+ await this._sandbox.commands.run(`sudo umount -l "${mountPath}" 2>/dev/null || true`);
406
+ }
407
+ this.mounts.delete(mountPath);
408
+ const filename = this.mounts.markerFilename(mountPath);
409
+ const markerPath = `/tmp/.mastra-mounts/${filename}`;
410
+ await this._sandbox.commands.run(`rm -f "${markerPath}" 2>/dev/null || true`);
411
+ const rmdirResult = await this._sandbox.commands.run(`sudo rmdir "${mountPath}" 2>&1`);
412
+ if (rmdirResult.exitCode === 0) {
413
+ this.logger.debug(`${LOG_PREFIX} Unmounted and removed ${mountPath}`);
414
+ } else {
415
+ this.logger.debug(
416
+ `${LOG_PREFIX} Unmounted ${mountPath} (directory not removed: ${rmdirResult.stderr?.trim() || "not empty"})`
417
+ );
418
+ }
419
+ }
420
+ /**
421
+ * Get list of current mounts in the sandbox.
422
+ */
423
+ async getMounts() {
424
+ return Array.from(this.mounts.entries).map(([path, entry]) => ({
425
+ path,
426
+ filesystem: entry.filesystem?.provider ?? entry.config?.type ?? "unknown"
427
+ }));
428
+ }
429
+ /**
430
+ * Unmount all stale mounts that are not in the expected mounts list.
431
+ * Also cleans up orphaned directories and marker files from failed mount attempts.
432
+ * Call this after reconnecting to an existing sandbox to clean up old mounts.
433
+ */
434
+ async reconcileMounts(expectedMountPaths) {
435
+ if (!this._sandbox) {
436
+ throw new workspace.SandboxNotReadyError(this.id);
437
+ }
438
+ this.logger.debug(`${LOG_PREFIX} Reconciling mounts. Expected paths:`, expectedMountPaths);
439
+ const mountsResult = await this._sandbox.commands.run(
440
+ `grep -E 'fuse\\.(s3fs|gcsfuse)' /proc/mounts | awk '{print $2}'`
441
+ );
442
+ const currentMounts = mountsResult.stdout.trim().split("\n").filter((p) => p.length > 0);
443
+ this.logger.debug(`${LOG_PREFIX} Current FUSE mounts in sandbox:`, currentMounts);
444
+ const markersResult = await this._sandbox.commands.run(`ls /tmp/.mastra-mounts/ 2>/dev/null || echo ""`);
445
+ const markerFiles = markersResult.stdout.trim().split("\n").filter((f) => f.length > 0 && SAFE_MARKER_NAME.test(f));
446
+ const managedMountPaths = /* @__PURE__ */ new Map();
447
+ for (const markerFile of markerFiles) {
448
+ const markerResult = await this._sandbox.commands.run(
449
+ `cat "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || echo ""`
450
+ );
451
+ const parsed = this.mounts.parseMarkerContent(markerResult.stdout.trim());
452
+ if (parsed && SAFE_MOUNT_PATH.test(parsed.path)) {
453
+ managedMountPaths.set(parsed.path, markerFile);
454
+ }
455
+ }
456
+ const staleMounts = currentMounts.filter((path) => !expectedMountPaths.includes(path));
457
+ for (const stalePath of staleMounts) {
458
+ if (managedMountPaths.has(stalePath)) {
459
+ this.logger.debug(`${LOG_PREFIX} Found stale managed FUSE mount at ${stalePath}, unmounting...`);
460
+ await this.unmount(stalePath);
461
+ } else {
462
+ this.logger.debug(`${LOG_PREFIX} Found external FUSE mount at ${stalePath}, leaving untouched`);
463
+ }
464
+ }
465
+ try {
466
+ const expectedMarkerFiles = new Set(expectedMountPaths.map((p) => this.mounts.markerFilename(p)));
467
+ const markerToPath = /* @__PURE__ */ new Map();
468
+ for (const [path, file] of managedMountPaths) {
469
+ markerToPath.set(file, path);
470
+ }
471
+ for (const markerFile of markerFiles) {
472
+ if (!expectedMarkerFiles.has(markerFile)) {
473
+ const mountPath = markerToPath.get(markerFile);
474
+ if (mountPath) {
475
+ if (!currentMounts.includes(mountPath)) {
476
+ this.logger.debug(`${LOG_PREFIX} Cleaning up orphaned marker and directory for ${mountPath}`);
477
+ await this._sandbox.commands.run(`rm -f "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || true`);
478
+ await this._sandbox.commands.run(`sudo rmdir "${mountPath}" 2>/dev/null || true`);
479
+ }
480
+ } else {
481
+ this.logger.debug(`${LOG_PREFIX} Removing malformed marker file: ${markerFile}`);
482
+ await this._sandbox.commands.run(`rm -f "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || true`);
483
+ }
484
+ }
485
+ }
486
+ } catch {
487
+ this.logger.debug(`${LOG_PREFIX} Error during orphan cleanup (non-fatal)`);
488
+ }
489
+ }
490
+ /**
491
+ * Check if a path is already mounted and if the config matches.
492
+ *
493
+ * @param mountPath - The mount path to check
494
+ * @param newConfig - The new config to compare against the stored config
495
+ * @returns 'not_mounted' | 'matching' | 'mismatched'
496
+ */
497
+ async checkExistingMount(mountPath, newConfig) {
498
+ if (!this._sandbox) throw new workspace.SandboxNotReadyError(this.id);
499
+ const mountCheck = await this._sandbox.commands.run(
500
+ `mountpoint -q "${mountPath}" && echo "mounted" || echo "not mounted"`
501
+ );
502
+ if (mountCheck.stdout.trim() !== "mounted") {
503
+ return "not_mounted";
504
+ }
505
+ const filename = this.mounts.markerFilename(mountPath);
506
+ const markerPath = `/tmp/.mastra-mounts/${filename}`;
507
+ try {
508
+ const markerResult = await this._sandbox.commands.run(`cat "${markerPath}" 2>/dev/null || echo ""`);
509
+ const parsed = this.mounts.parseMarkerContent(markerResult.stdout.trim());
510
+ if (!parsed) {
511
+ return "mismatched";
512
+ }
513
+ const newConfigHash = this.mounts.computeConfigHash(newConfig);
514
+ this.logger.debug(
515
+ `${LOG_PREFIX} Marker check - stored hash: "${parsed.configHash}", new config hash: "${newConfigHash}"`
516
+ );
517
+ if (parsed.path === mountPath && parsed.configHash === newConfigHash) {
518
+ return "matching";
519
+ }
520
+ } catch {
521
+ }
522
+ return "mismatched";
523
+ }
524
+ // ---------------------------------------------------------------------------
525
+ // Lifecycle (overrides base class protected methods)
526
+ // ---------------------------------------------------------------------------
527
+ /**
528
+ * Start the E2B sandbox.
529
+ * Handles template preparation, existing sandbox reconnection, and new sandbox creation.
530
+ *
531
+ * Status management and mount processing are handled by the base class.
532
+ */
533
+ async start() {
534
+ if (this._sandbox) {
535
+ return;
536
+ }
537
+ const [existingSandbox, templateId] = await Promise.all([
538
+ this.findExistingSandbox(),
539
+ this._templatePreparePromise || this.resolveTemplate()
540
+ ]);
541
+ if (existingSandbox) {
542
+ this._sandbox = existingSandbox;
543
+ this._createdAt = /* @__PURE__ */ new Date();
544
+ this.logger.debug(`${LOG_PREFIX} Reconnected to existing sandbox for: ${this.id}`);
545
+ const expectedPaths = Array.from(this.mounts.entries.keys());
546
+ this.logger.debug(`${LOG_PREFIX} Running mount reconciliation...`);
547
+ await this.reconcileMounts(expectedPaths);
548
+ this.logger.debug(`${LOG_PREFIX} Mount reconciliation complete`);
549
+ return;
550
+ }
551
+ let resolvedTemplateId = templateId;
552
+ if (!resolvedTemplateId) {
553
+ this.logger.debug(`${LOG_PREFIX} Template preparation failed earlier, retrying...`);
554
+ resolvedTemplateId = await this.resolveTemplate();
555
+ }
556
+ this.logger.debug(`${LOG_PREFIX} Creating new sandbox for: ${this.id} with template: ${resolvedTemplateId}`);
557
+ try {
558
+ this._sandbox = await e2b.Sandbox.betaCreate(resolvedTemplateId, {
559
+ ...this.connectionOpts,
560
+ autoPause: true,
561
+ metadata: {
562
+ ...this.metadata,
563
+ "mastra-sandbox-id": this.id
564
+ },
565
+ timeoutMs: this.timeout
566
+ });
567
+ } catch (createError) {
568
+ const errorStr = String(createError);
569
+ if (errorStr.includes("404") && errorStr.includes("not found") && !this.templateSpec) {
570
+ this.logger.debug(`${LOG_PREFIX} Template not found, rebuilding: ${templateId}`);
571
+ this._resolvedTemplateId = void 0;
572
+ const rebuiltTemplateId = await this.buildDefaultTemplate();
573
+ this.logger.debug(`${LOG_PREFIX} Retrying sandbox creation with rebuilt template: ${rebuiltTemplateId}`);
574
+ this._sandbox = await e2b.Sandbox.betaCreate(rebuiltTemplateId, {
575
+ ...this.connectionOpts,
576
+ autoPause: true,
577
+ metadata: {
578
+ ...this.metadata,
579
+ "mastra-sandbox-id": this.id
580
+ },
581
+ timeoutMs: this.timeout
582
+ });
583
+ } else {
584
+ throw createError;
585
+ }
586
+ }
587
+ this.logger.debug(`${LOG_PREFIX} Created sandbox ${this._sandbox.sandboxId} for logical ID: ${this.id}`);
588
+ this._createdAt = /* @__PURE__ */ new Date();
589
+ }
590
+ /**
591
+ * Build the default mountable template (bypasses exists check).
592
+ */
593
+ async buildDefaultTemplate() {
594
+ const { template, id } = createDefaultMountableTemplate();
595
+ this.logger.debug(`${LOG_PREFIX} Building default mountable template: ${id}...`);
596
+ const buildResult = await e2b.Template.build(template, id, this.connectionOpts);
597
+ this._resolvedTemplateId = buildResult.templateId;
598
+ this.logger.debug(`${LOG_PREFIX} Template built: ${buildResult.templateId}`);
599
+ return buildResult.templateId;
600
+ }
601
+ /**
602
+ * Resolve the template specification to a template ID.
603
+ *
604
+ * - String: Use as-is (template ID)
605
+ * - TemplateBuilder: Build and return the template ID
606
+ * - Function: Apply to base mountable template, then build
607
+ * - undefined: Use default mountable template (cached)
608
+ */
609
+ async resolveTemplate() {
610
+ if (this._resolvedTemplateId) {
611
+ return this._resolvedTemplateId;
612
+ }
613
+ if (!this.templateSpec) {
614
+ const { template: template2, id } = createDefaultMountableTemplate();
615
+ const exists = await e2b.Template.exists(id, this.connectionOpts);
616
+ if (exists) {
617
+ this.logger.debug(`${LOG_PREFIX} Using cached mountable template: ${id}`);
618
+ this._resolvedTemplateId = id;
619
+ return id;
620
+ }
621
+ this.logger.debug(`${LOG_PREFIX} Building default mountable template: ${id}...`);
622
+ const buildResult2 = await e2b.Template.build(template2, id, this.connectionOpts);
623
+ this._resolvedTemplateId = buildResult2.templateId;
624
+ this.logger.debug(`${LOG_PREFIX} Template built and cached: ${buildResult2.templateId}`);
625
+ return buildResult2.templateId;
626
+ }
627
+ if (typeof this.templateSpec === "string") {
628
+ this._resolvedTemplateId = this.templateSpec;
629
+ return this.templateSpec;
630
+ }
631
+ let template;
632
+ let templateName;
633
+ if (typeof this.templateSpec === "function") {
634
+ const { template: baseTemplate } = createDefaultMountableTemplate();
635
+ template = this.templateSpec(baseTemplate);
636
+ templateName = `mastra-custom-${this.id.replace(/[^a-zA-Z0-9-]/g, "-")}`;
637
+ } else {
638
+ template = this.templateSpec;
639
+ templateName = `mastra-${this.id.replace(/[^a-zA-Z0-9-]/g, "-")}`;
640
+ }
641
+ this.logger.debug(`${LOG_PREFIX} Building custom template: ${templateName}...`);
642
+ const buildResult = await e2b.Template.build(template, templateName, this.connectionOpts);
643
+ this._resolvedTemplateId = buildResult.templateId;
644
+ this.logger.debug(`${LOG_PREFIX} Template built: ${buildResult.templateId}`);
645
+ return buildResult.templateId;
646
+ }
647
+ /**
648
+ * Find an existing sandbox with matching mastra-sandbox-id metadata.
649
+ * Returns the connected sandbox if found, null otherwise.
650
+ */
651
+ async findExistingSandbox() {
652
+ try {
653
+ const paginator = e2b.Sandbox.list({
654
+ ...this.connectionOpts,
655
+ query: {
656
+ metadata: { "mastra-sandbox-id": this.id },
657
+ state: ["running", "paused"]
658
+ }
659
+ });
660
+ const sandboxes = await paginator.nextItems();
661
+ this.logger.debug(`${LOG_PREFIX} sandboxes:`, sandboxes);
662
+ if (sandboxes.length > 0) {
663
+ const existingSandbox = sandboxes[0];
664
+ this.logger.debug(
665
+ `${LOG_PREFIX} Found existing sandbox for ${this.id}: ${existingSandbox.sandboxId} (state: ${existingSandbox.state})`
666
+ );
667
+ return await e2b.Sandbox.connect(existingSandbox.sandboxId, this.connectionOpts);
668
+ }
669
+ } catch (e) {
670
+ this.logger.debug(`${LOG_PREFIX} Error querying for existing sandbox:`, e);
671
+ }
672
+ return null;
673
+ }
674
+ /**
675
+ * Stop the E2B sandbox.
676
+ * Unmounts all filesystems and releases the sandbox reference.
677
+ * Status management is handled by the base class.
678
+ */
679
+ async stop() {
680
+ for (const mountPath of [...this.mounts.entries.keys()]) {
681
+ try {
682
+ await this.unmount(mountPath);
683
+ } catch {
684
+ }
685
+ }
686
+ this._sandbox = null;
687
+ }
688
+ /**
689
+ * Destroy the E2B sandbox and clean up all resources.
690
+ * Unmounts filesystems, kills the sandbox, and clears mount state.
691
+ * Status management is handled by the base class.
692
+ */
693
+ async destroy() {
694
+ for (const mountPath of [...this.mounts.entries.keys()]) {
695
+ try {
696
+ await this.unmount(mountPath);
697
+ } catch {
698
+ }
699
+ }
700
+ if (this._sandbox) {
701
+ try {
702
+ await this._sandbox.kill();
703
+ } catch {
704
+ }
705
+ }
706
+ this._sandbox = null;
707
+ this.mounts.clear();
708
+ }
709
+ /**
710
+ * Check if the sandbox is ready for operations.
711
+ */
712
+ async isReady() {
713
+ return this.status === "running" && this._sandbox !== null;
714
+ }
715
+ /**
716
+ * Get information about the current state of the sandbox.
717
+ */
718
+ async getInfo() {
719
+ return {
720
+ id: this.id,
721
+ name: this.name,
722
+ provider: this.provider,
723
+ status: this.status,
724
+ createdAt: this._createdAt ?? /* @__PURE__ */ new Date(),
725
+ mounts: Array.from(this.mounts.entries).map(([path, entry]) => ({
726
+ path,
727
+ filesystem: entry.filesystem?.provider ?? entry.config?.type ?? "unknown"
728
+ })),
729
+ metadata: {
730
+ ...this.metadata
731
+ }
732
+ };
733
+ }
734
+ /**
735
+ * Get instructions describing this E2B sandbox.
736
+ * Used by agents to understand the execution environment.
737
+ */
738
+ getInstructions() {
739
+ const mountCount = this.mounts.entries.size;
740
+ const mountInfo = mountCount > 0 ? ` ${mountCount} filesystem(s) mounted via FUSE.` : "";
741
+ return `Cloud sandbox with /home/user as working directory.${mountInfo}`;
742
+ }
743
+ // ---------------------------------------------------------------------------
744
+ // Internal Helpers
745
+ // ---------------------------------------------------------------------------
746
+ /**
747
+ * Ensure the sandbox is started and return the E2B Sandbox instance.
748
+ * Uses base class ensureRunning() for status management and error handling.
749
+ * @throws {SandboxNotReadyError} if sandbox fails to start
750
+ */
751
+ async ensureSandbox() {
752
+ await this.ensureRunning();
753
+ return this._sandbox;
754
+ }
755
+ /**
756
+ * Check if an error indicates the sandbox itself is dead/gone.
757
+ * Does NOT include code execution timeouts (those are the user's code taking too long).
758
+ * Does NOT include "port is not open" - that needs sandbox kill, not reconnect.
759
+ */
760
+ isSandboxDeadError(error) {
761
+ if (!error) return false;
762
+ const errorStr = String(error);
763
+ return errorStr.includes("sandbox was not found") || errorStr.includes("Sandbox is probably not running") || errorStr.includes("Sandbox not found") || errorStr.includes("sandbox has been killed");
764
+ }
765
+ /**
766
+ * Handle sandbox timeout by clearing the instance and resetting state.
767
+ *
768
+ * Bypasses the normal stop() lifecycle because the sandbox is already dead —
769
+ * we can't unmount filesystems or run cleanup commands. Instead we reset
770
+ * mount states to 'pending' so they get re-mounted when start() runs again.
771
+ */
772
+ handleSandboxTimeout() {
773
+ this._sandbox = null;
774
+ for (const [path, entry] of this.mounts.entries) {
775
+ if (entry.state === "mounted" || entry.state === "mounting") {
776
+ this.mounts.set(path, { state: "pending" });
777
+ }
778
+ }
779
+ this.status = "stopped";
780
+ }
781
+ // ---------------------------------------------------------------------------
782
+ // Command Execution
783
+ // ---------------------------------------------------------------------------
784
+ /**
785
+ * Execute a shell command in the sandbox.
786
+ * Automatically starts the sandbox if not already running.
787
+ * Retries once if the sandbox is found to be dead.
788
+ */
789
+ async executeCommand(command, args = [], options = {}) {
790
+ this.logger.debug(`${LOG_PREFIX} Executing: ${command} ${args.join(" ")}`, options);
791
+ const sandbox = await this.ensureSandbox();
792
+ const startTime = Date.now();
793
+ const fullCommand = args.length > 0 ? `${command} ${args.map(shellQuote).join(" ")}` : command;
794
+ this.logger.debug(`${LOG_PREFIX} Executing: ${fullCommand}`);
795
+ try {
796
+ const mergedEnv = { ...this.env, ...options.env };
797
+ const envs = Object.fromEntries(
798
+ Object.entries(mergedEnv).filter((entry) => entry[1] !== void 0)
799
+ );
800
+ const result = await sandbox.commands.run(fullCommand, {
801
+ cwd: options.cwd,
802
+ envs,
803
+ timeoutMs: options.timeout,
804
+ onStdout: options.onStdout,
805
+ onStderr: options.onStderr
806
+ });
807
+ const executionTimeMs = Date.now() - startTime;
808
+ this.logger.debug(`${LOG_PREFIX} Exit code: ${result.exitCode} (${executionTimeMs}ms)`);
809
+ if (result.stdout) this.logger.debug(`${LOG_PREFIX} stdout:
810
+ ${result.stdout}`);
811
+ if (result.stderr) this.logger.debug(`${LOG_PREFIX} stderr:
812
+ ${result.stderr}`);
813
+ return {
814
+ success: result.exitCode === 0,
815
+ exitCode: result.exitCode,
816
+ stdout: result.stdout,
817
+ stderr: result.stderr,
818
+ executionTimeMs,
819
+ command,
820
+ args
821
+ };
822
+ } catch (error) {
823
+ if (this.isSandboxDeadError(error) && !this._isRetrying) {
824
+ this.handleSandboxTimeout();
825
+ this._isRetrying = true;
826
+ try {
827
+ return await this.executeCommand(command, args, options);
828
+ } finally {
829
+ this._isRetrying = false;
830
+ }
831
+ }
832
+ const executionTimeMs = Date.now() - startTime;
833
+ const errorObj = error;
834
+ const stdout = errorObj.result?.stdout || "";
835
+ const stderr = errorObj.result?.stderr || (error instanceof Error ? error.message : String(error));
836
+ const exitCode = errorObj.result?.exitCode ?? 1;
837
+ this.logger.debug(`${LOG_PREFIX} Exit code: ${exitCode} (${executionTimeMs}ms) [error]`);
838
+ if (stdout) this.logger.debug(`${LOG_PREFIX} stdout:
839
+ ${stdout}`);
840
+ if (stderr) this.logger.debug(`${LOG_PREFIX} stderr:
841
+ ${stderr}`);
842
+ return {
843
+ success: false,
844
+ exitCode,
845
+ stdout,
846
+ stderr,
847
+ executionTimeMs,
848
+ command,
849
+ args
850
+ };
851
+ }
852
+ }
853
+ };
854
+
855
+ exports.E2BSandbox = E2BSandbox;
856
+ exports.createDefaultMountableTemplate = createDefaultMountableTemplate;
857
+ //# sourceMappingURL=index.cjs.map
858
+ //# sourceMappingURL=index.cjs.map