@mastra/e2b 0.7.0 → 0.8.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,92 +1,124 @@
1
- import { SandboxProcessManager, MastraSandbox, SandboxNotReadyError, ProcessHandle } from '@mastra/core/workspace';
2
- import { Template, Sandbox } from 'e2b';
3
- import { createHash, randomBytes } from 'crypto';
4
- import { buildProgramModule, buildRunner, FRAME_PREFIX } from '@mastra/core/tools';
5
- import { transformSync } from 'esbuild';
6
-
7
- // src/sandbox/index.ts
8
- var MOUNTABLE_TEMPLATE_VERSION = "v1";
1
+ import { MastraSandbox, ProcessHandle, SandboxNotReadyError, SandboxProcessManager } from "@mastra/core/workspace";
2
+ import { Sandbox, Template } from "e2b";
3
+ import { createHash, randomBytes } from "crypto";
4
+ import { FRAME_PREFIX, buildProgramModule, buildRunner, sanitizeToolId } from "@mastra/core/tools";
5
+ import { transformSync } from "esbuild";
6
+ /**
7
+ * Create a base template with FUSE mounting dependencies pre-installed.
8
+ *
9
+ * This template includes s3fs and fuse packages required for mounting
10
+ * cloud filesystems (S3, GCS, R2) into the sandbox.
11
+ *
12
+ * The returned `id` is deterministic, allowing E2BSandbox to check if
13
+ * the template already exists before building it.
14
+ *
15
+ * @example Basic usage
16
+ * ```typescript
17
+ * const { template, id } = createMountableTemplate();
18
+ * // First time: builds and caches the template
19
+ * // Subsequent times: reuses existing template
20
+ * const sandbox = new E2BSandbox({ template });
21
+ * ```
22
+ *
23
+ * @example With customization
24
+ * ```typescript
25
+ * const { template } = createMountableTemplate();
26
+ * const customTemplate = template
27
+ * .aptInstall(['nodejs', 'npm'])
28
+ * .runCmd('npm install -g typescript');
29
+ *
30
+ * // Note: customized templates get a unique ID, not the cached one
31
+ * const sandbox = new E2BSandbox({ template: customTemplate });
32
+ * ```
33
+ *
34
+ * @returns Object with template builder and deterministic ID
35
+ */
9
36
  function createDefaultMountableTemplate() {
10
- const aptPackages = ["s3fs", "fuse"];
11
- const config = { version: MOUNTABLE_TEMPLATE_VERSION, aptPackages };
12
- const hash = createHash("sha256").update(JSON.stringify(config, Object.keys(config).sort())).digest("hex").slice(0, 16);
13
- const template = Template().fromTemplate("base").aptInstall(aptPackages);
14
- return {
15
- template,
16
- id: `mastra-${hash}`,
17
- aptPackages
18
- };
37
+ const aptPackages = ["s3fs", "fuse"];
38
+ const config = {
39
+ version: "v1",
40
+ aptPackages
41
+ };
42
+ const hash = createHash("sha256").update(JSON.stringify(config, Object.keys(config).sort())).digest("hex").slice(0, 16);
43
+ return {
44
+ template: Template().fromTemplate("base").aptInstall(aptPackages),
45
+ id: `mastra-${hash}`,
46
+ aptPackages
47
+ };
19
48
  }
20
-
21
- // src/sandbox/mounts/types.ts
22
- var LOG_PREFIX = "[@mastra/e2b]";
23
- var SAFE_BUCKET_NAME = /^[a-z0-9][a-z0-9.\-]{1,61}[a-z0-9]$/;
49
+ //#endregion
50
+ //#region src/sandbox/mounts/types.ts
51
+ const LOG_PREFIX = "[@mastra/e2b]";
52
+ /**
53
+ * Validate a bucket name before interpolating into shell commands.
54
+ * Covers S3, GCS, and S3-compatible (R2, MinIO) naming rules.
55
+ */
56
+ const SAFE_BUCKET_NAME = /^[a-z0-9][a-z0-9.\-]{1,61}[a-z0-9]$/;
24
57
  function validateBucketName(bucket) {
25
- if (!SAFE_BUCKET_NAME.test(bucket)) {
26
- throw new Error(
27
- `Invalid bucket name: "${bucket}". Bucket names must be 3-63 characters, lowercase alphanumeric, hyphens, or dots.`
28
- );
29
- }
58
+ if (!SAFE_BUCKET_NAME.test(bucket)) throw new Error(`Invalid bucket name: "${bucket}". Bucket names must be 3-63 characters, lowercase alphanumeric, hyphens, or dots.`);
30
59
  }
60
+ /**
61
+ * Validate an endpoint URL before interpolating into shell commands.
62
+ */
31
63
  function validateEndpoint(endpoint) {
32
- try {
33
- new URL(endpoint);
34
- } catch {
35
- throw new Error(`Invalid endpoint URL: "${endpoint}"`);
36
- }
64
+ try {
65
+ new URL(endpoint);
66
+ } catch {
67
+ throw new Error(`Invalid endpoint URL: "${endpoint}"`);
68
+ }
37
69
  }
38
- var SAFE_REGION = /^[a-z0-9-]{2,32}$/;
70
+ /**
71
+ * Validate an AWS region (or R2's "auto") before interpolating into shell commands.
72
+ * Accepts standard AWS region codes like "us-east-1", "ap-northeast-1", and "auto".
73
+ */
74
+ const SAFE_REGION = /^[a-z0-9-]{2,32}$/;
39
75
  function validateRegion(region) {
40
- if (typeof region !== "string" || !SAFE_REGION.test(region)) {
41
- throw new Error(
42
- `Invalid region: ${JSON.stringify(region)}. Region must be a string of lowercase alphanumeric or hyphens (e.g., "us-east-1", "ap-northeast-1", "auto").`
43
- );
44
- }
76
+ if (typeof region !== "string" || !SAFE_REGION.test(region)) throw new Error(`Invalid region: ${JSON.stringify(region)}. Region must be a string of lowercase alphanumeric or hyphens (e.g., "us-east-1", "ap-northeast-1", "auto").`);
45
77
  }
78
+ /**
79
+ * Validate and normalize a mount prefix before interpolating into shell commands.
80
+ * Returns the normalized prefix (no leading/trailing slashes).
81
+ *
82
+ * Shell safety is handled by shellQuote() at the call site, so this function
83
+ * only enforces path-level rules (no traversal, no empty result, no control chars).
84
+ */
46
85
  function validatePrefix(prefix) {
47
- let normalized = prefix;
48
- while (normalized.startsWith("/")) normalized = normalized.slice(1);
49
- while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
50
- if (!normalized) {
51
- throw new Error("Mount prefix cannot be empty after normalization.");
52
- }
53
- if (normalized.includes("//") || normalized.split("/").some((s) => s === "." || s === "..")) {
54
- throw new Error(`Invalid mount prefix: "${prefix}". Path traversal is not allowed.`);
55
- }
56
- if (/[\x00-\x1f\x7f]/.test(normalized)) {
57
- throw new Error(`Invalid mount prefix: "${prefix}". Control characters are not allowed.`);
58
- }
59
- return normalized;
86
+ let normalized = prefix;
87
+ while (normalized.startsWith("/")) normalized = normalized.slice(1);
88
+ while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
89
+ if (!normalized) throw new Error("Mount prefix cannot be empty after normalization.");
90
+ if (normalized.includes("//") || normalized.split("/").some((s) => s === "." || s === "..")) throw new Error(`Invalid mount prefix: "${prefix}". Path traversal is not allowed.`);
91
+ if (/[\x00-\x1f\x7f]/.test(normalized)) throw new Error(`Invalid mount prefix: "${prefix}". Control characters are not allowed.`);
92
+ return normalized;
60
93
  }
61
-
62
- // src/utils/shell-quote.ts
94
+ //#endregion
95
+ //#region src/utils/shell-quote.ts
96
+ /**
97
+ * Shell-quote a single argument for safe use in a command string.
98
+ *
99
+ * Arguments containing only safe characters are returned as-is.
100
+ * All others are wrapped in single quotes with embedded single quotes escaped.
101
+ */
63
102
  function shellQuote(arg) {
64
- if (/^[a-zA-Z0-9._\-/@:=]+$/.test(arg)) return arg;
65
- return "'" + arg.replace(/'/g, "'\\''") + "'";
103
+ if (/^[a-zA-Z0-9._\-/@:=]+$/.test(arg)) return arg;
104
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
66
105
  }
67
-
68
- // src/sandbox/mounts/s3.ts
106
+ //#endregion
107
+ //#region src/sandbox/mounts/s3.ts
108
+ /**
109
+ * Mount an S3 bucket using s3fs-fuse.
110
+ */
69
111
  async function mountS3(mountPath, config, ctx) {
70
- const { sandbox, logger } = ctx;
71
- validateBucketName(config.bucket);
72
- validateRegion(config.region);
73
- if (config.endpoint) {
74
- validateEndpoint(config.endpoint);
75
- }
76
- const checkResult = await sandbox.commands.run('which s3fs || echo "not found"');
77
- if (checkResult.stdout.includes("not found")) {
78
- logger.warn(`${LOG_PREFIX} s3fs not found, attempting runtime installation...`);
79
- logger.info(
80
- `${LOG_PREFIX} Tip: For faster startup, use createMountableTemplate() to pre-install s3fs in your sandbox template`
81
- );
82
- await sandbox.commands.run("sudo apt-get update 2>&1", { timeoutMs: 6e4 });
83
- const installResult = await sandbox.commands.run(
84
- "sudo apt-get install -y s3fs fuse 2>&1 || sudo apt-get install -y s3fs-fuse fuse 2>&1",
85
- { timeoutMs: 12e4 }
86
- );
87
- if (installResult.exitCode !== 0) {
88
- throw new Error(
89
- `Failed to install s3fs. For S3 mounting, your template needs s3fs and fuse packages.
112
+ const { sandbox, logger } = ctx;
113
+ validateBucketName(config.bucket);
114
+ validateRegion(config.region);
115
+ if (config.endpoint) validateEndpoint(config.endpoint);
116
+ if ((await sandbox.commands.run("which s3fs || echo \"not found\"")).stdout.includes("not found")) {
117
+ logger.warn(`${LOG_PREFIX} s3fs not found, attempting runtime installation...`);
118
+ logger.info(`${LOG_PREFIX} Tip: For faster startup, use createMountableTemplate() to pre-install s3fs in your sandbox template`);
119
+ await sandbox.commands.run("sudo apt-get update 2>&1", { timeoutMs: 6e4 });
120
+ const installResult = await sandbox.commands.run("sudo apt-get install -y s3fs fuse 2>&1 || sudo apt-get install -y s3fs-fuse fuse 2>&1", { timeoutMs: 12e4 });
121
+ if (installResult.exitCode !== 0) throw new Error(`Failed to install s3fs. For S3 mounting, your template needs s3fs and fuse packages.
90
122
 
91
123
  Option 1: Use createMountableTemplate() helper:
92
124
  import { E2BSandbox, createMountableTemplate } from '@mastra/e2b';
@@ -95,314 +127,275 @@ Option 1: Use createMountableTemplate() helper:
95
127
  Option 2: Customize the base template:
96
128
  new E2BSandbox({ template: base => base.aptInstall(['your-packages']) })
97
129
 
98
- Error details: ${installResult.stderr || installResult.stdout}`
99
- );
100
- }
101
- }
102
- const idResult = await sandbox.commands.run("id -u && id -g");
103
- const [uid, gid] = idResult.stdout.trim().split("\n");
104
- const hasAccessKey = !!config.accessKeyId;
105
- const hasSecretKey = !!config.secretAccessKey;
106
- if (hasAccessKey !== hasSecretKey) {
107
- throw new Error("Both accessKeyId and secretAccessKey must be provided together.");
108
- }
109
- const hasCredentials = hasAccessKey && hasSecretKey;
110
- const mountHash = createHash("md5").update(mountPath).digest("hex").slice(0, 8);
111
- const credentialsPath = `/tmp/.passwd-s3fs-${mountHash}`;
112
- if (!hasCredentials && config.endpoint) {
113
- throw new Error(
114
- `S3-compatible storage requires credentials. Detected endpoint: ${config.endpoint}. The public_bucket option only works for AWS S3 public buckets, not R2, MinIO, etc.`
115
- );
116
- }
117
- if (hasCredentials) {
118
- const credentialsContent = `${config.accessKeyId}:${config.secretAccessKey}`;
119
- await sandbox.commands.run(`sudo rm -f ${credentialsPath}`);
120
- await sandbox.files.write(credentialsPath, credentialsContent);
121
- await sandbox.commands.run(`chmod 600 ${credentialsPath}`);
122
- }
123
- const mountOptions = [];
124
- if (hasCredentials) {
125
- mountOptions.push(`passwd_file=${credentialsPath}`);
126
- } else {
127
- mountOptions.push("public_bucket=1");
128
- logger.debug(`${LOG_PREFIX} No credentials provided, mounting as public bucket (read-only)`);
129
- }
130
- mountOptions.push("allow_other");
131
- if (uid && gid) {
132
- mountOptions.push(`uid=${uid}`, `gid=${gid}`);
133
- }
134
- if (config.endpoint) {
135
- const endpoint = config.endpoint.replace(/\/$/, "");
136
- mountOptions.push(`url=${endpoint}`, "use_path_request_style", "sigv4", "nomultipart");
137
- }
138
- mountOptions.push(`endpoint=${config.region}`);
139
- if (config.readOnly) {
140
- mountOptions.push("ro");
141
- logger.debug(`${LOG_PREFIX} Mounting as read-only`);
142
- }
143
- let bucketArg = config.bucket;
144
- if (config.prefix) {
145
- const normalizedPrefix = validatePrefix(config.prefix);
146
- bucketArg = `${config.bucket}:/${normalizedPrefix}`;
147
- }
148
- const mountCmd = `sudo s3fs ${shellQuote(bucketArg)} ${shellQuote(mountPath)} -o ${mountOptions.join(" -o ")}`;
149
- logger.debug(`${LOG_PREFIX} Mounting S3:`, hasCredentials ? mountCmd.replace(credentialsPath, "***") : mountCmd);
150
- try {
151
- const result = await sandbox.commands.run(mountCmd, { timeoutMs: 6e4 });
152
- logger.debug(`${LOG_PREFIX} s3fs result:`, {
153
- exitCode: result.exitCode,
154
- stdout: result.stdout,
155
- stderr: result.stderr
156
- });
157
- if (result.exitCode !== 0) {
158
- throw new Error(`Failed to mount S3 bucket: ${result.stderr || result.stdout}`);
159
- }
160
- } catch (error) {
161
- const errorObj = error;
162
- const stderr = errorObj.result?.stderr || "";
163
- const stdout = errorObj.result?.stdout || "";
164
- logger.error(`${LOG_PREFIX} s3fs error:`, { stderr, stdout, error: String(error) });
165
- throw new Error(`Failed to mount S3 bucket: ${stderr || stdout || error}`);
166
- }
167
- const verify = await sandbox.commands.run(`mountpoint -q ${shellQuote(mountPath)}`);
168
- if (verify.exitCode !== 0) {
169
- throw new Error(
170
- `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.`
171
- );
172
- }
130
+ Error details: ${installResult.stderr || installResult.stdout}`);
131
+ }
132
+ const [uid, gid] = (await sandbox.commands.run("id -u && id -g")).stdout.trim().split("\n");
133
+ const hasAccessKey = !!config.accessKeyId;
134
+ const hasSecretKey = !!config.secretAccessKey;
135
+ if (hasAccessKey !== hasSecretKey) throw new Error("Both accessKeyId and secretAccessKey must be provided together.");
136
+ const hasCredentials = hasAccessKey && hasSecretKey;
137
+ const credentialsPath = `/tmp/.passwd-s3fs-${createHash("md5").update(mountPath).digest("hex").slice(0, 8)}`;
138
+ if (!hasCredentials && config.endpoint) throw new Error(`S3-compatible storage requires credentials. Detected endpoint: ${config.endpoint}. The public_bucket option only works for AWS S3 public buckets, not R2, MinIO, etc.`);
139
+ if (hasCredentials) {
140
+ const credentialsContent = `${config.accessKeyId}:${config.secretAccessKey}`;
141
+ await sandbox.commands.run(`sudo rm -f ${credentialsPath}`);
142
+ await sandbox.files.write(credentialsPath, credentialsContent);
143
+ await sandbox.commands.run(`chmod 600 ${credentialsPath}`);
144
+ }
145
+ const mountOptions = [];
146
+ if (hasCredentials) mountOptions.push(`passwd_file=${credentialsPath}`);
147
+ else {
148
+ mountOptions.push("public_bucket=1");
149
+ logger.debug(`${LOG_PREFIX} No credentials provided, mounting as public bucket (read-only)`);
150
+ }
151
+ mountOptions.push("allow_other");
152
+ if (uid && gid) mountOptions.push(`uid=${uid}`, `gid=${gid}`);
153
+ if (config.endpoint) {
154
+ const endpoint = config.endpoint.replace(/\/$/, "");
155
+ mountOptions.push(`url=${endpoint}`, "use_path_request_style", "sigv4", "nomultipart");
156
+ }
157
+ mountOptions.push(`endpoint=${config.region}`);
158
+ if (config.readOnly) {
159
+ mountOptions.push("ro");
160
+ logger.debug(`${LOG_PREFIX} Mounting as read-only`);
161
+ }
162
+ let bucketArg = config.bucket;
163
+ if (config.prefix) {
164
+ const normalizedPrefix = validatePrefix(config.prefix);
165
+ bucketArg = `${config.bucket}:/${normalizedPrefix}`;
166
+ }
167
+ const mountCmd = `sudo s3fs ${shellQuote(bucketArg)} ${shellQuote(mountPath)} -o ${mountOptions.join(" -o ")}`;
168
+ logger.debug(`${LOG_PREFIX} Mounting S3:`, hasCredentials ? mountCmd.replace(credentialsPath, "***") : mountCmd);
169
+ try {
170
+ const result = await sandbox.commands.run(mountCmd, { timeoutMs: 6e4 });
171
+ logger.debug(`${LOG_PREFIX} s3fs result:`, {
172
+ exitCode: result.exitCode,
173
+ stdout: result.stdout,
174
+ stderr: result.stderr
175
+ });
176
+ if (result.exitCode !== 0) throw new Error(`Failed to mount S3 bucket: ${result.stderr || result.stdout}`);
177
+ } catch (error) {
178
+ const errorObj = error;
179
+ const stderr = errorObj.result?.stderr || "";
180
+ const stdout = errorObj.result?.stdout || "";
181
+ logger.error(`${LOG_PREFIX} s3fs error:`, {
182
+ stderr,
183
+ stdout,
184
+ error: String(error)
185
+ });
186
+ throw new Error(`Failed to mount S3 bucket: ${stderr || stdout || error}`);
187
+ }
188
+ 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.`);
173
189
  }
190
+ //#endregion
191
+ //#region src/sandbox/mounts/gcs.ts
192
+ /**
193
+ * Mount a GCS bucket using gcsfuse.
194
+ *
195
+ * When `config.prefix` is set, gcsfuse uses `--only-dir` to mount only that
196
+ * subdirectory, aligning sandbox paths with the prefixed GCS keys (mirrors the
197
+ * S3 `bucket:/prefix` and Azure `--subdirectory` mounts).
198
+ */
174
199
  async function mountGCS(mountPath, config, ctx) {
175
- const { sandbox, logger } = ctx;
176
- validateBucketName(config.bucket);
177
- const checkResult = await sandbox.commands.run('which gcsfuse || echo "not found"');
178
- if (checkResult.stdout.includes("not found")) {
179
- const codenameResult = await sandbox.commands.run("lsb_release -cs 2>/dev/null || echo jammy");
180
- const codename = codenameResult.stdout.trim() || "jammy";
181
- await sandbox.commands.run(
182
- `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`,
183
- { timeoutMs: 12e4 }
184
- );
185
- }
186
- const idResult = await sandbox.commands.run("id -u && id -g");
187
- const [uid, gid] = idResult.stdout.trim().split("\n");
188
- const uidGidFlags = uid && gid ? `--uid=${uid} --gid=${gid}` : "";
189
- const onlyDirFlag = config.prefix ? ` --only-dir=${shellQuote(validatePrefix(config.prefix))}` : "";
190
- const hasCredentials = !!config.serviceAccountKey;
191
- let mountCmd;
192
- if (hasCredentials) {
193
- const mountHash = createHash("md5").update(mountPath).digest("hex").slice(0, 8);
194
- const keyPath = `/tmp/gcs-key-${mountHash}.json`;
195
- await sandbox.commands.run(`sudo rm -f ${keyPath}`);
196
- await sandbox.files.write(keyPath, config.serviceAccountKey);
197
- await sandbox.commands.run(`sudo chown root:root ${keyPath} && sudo chmod 600 ${keyPath}`);
198
- mountCmd = `sudo gcsfuse --key-file=${keyPath} -o allow_other ${uidGidFlags}${onlyDirFlag} ${config.bucket} ${mountPath}`;
199
- } else {
200
- logger.debug(`${LOG_PREFIX} No credentials provided, mounting GCS as public bucket (read-only)`);
201
- mountCmd = `sudo gcsfuse --anonymous-access -o allow_other ${uidGidFlags}${onlyDirFlag} ${config.bucket} ${mountPath}`;
202
- }
203
- logger.debug(`${LOG_PREFIX} Mounting GCS:`, mountCmd);
204
- try {
205
- const result = await sandbox.commands.run(mountCmd, { timeoutMs: 6e4 });
206
- logger.debug(`${LOG_PREFIX} gcsfuse result:`, {
207
- exitCode: result.exitCode,
208
- stdout: result.stdout,
209
- stderr: result.stderr
210
- });
211
- if (result.exitCode !== 0) {
212
- throw new Error(`Failed to mount GCS bucket: ${result.stderr || result.stdout}`);
213
- }
214
- } catch (error) {
215
- const errorObj = error;
216
- const stderr = errorObj.result?.stderr || "";
217
- const stdout = errorObj.result?.stdout || "";
218
- logger.error(`${LOG_PREFIX} gcsfuse error:`, { stderr, stdout, error: String(error) });
219
- throw new Error(`Failed to mount GCS bucket: ${stderr || stdout || error}`);
220
- }
200
+ const { sandbox, logger } = ctx;
201
+ validateBucketName(config.bucket);
202
+ if ((await sandbox.commands.run("which gcsfuse || echo \"not found\"")).stdout.includes("not found")) {
203
+ const codename = (await sandbox.commands.run("lsb_release -cs 2>/dev/null || echo jammy")).stdout.trim() || "jammy";
204
+ await sandbox.commands.run(`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`, { timeoutMs: 12e4 });
205
+ }
206
+ const [uid, gid] = (await sandbox.commands.run("id -u && id -g")).stdout.trim().split("\n");
207
+ const uidGidFlags = uid && gid ? `--uid=${uid} --gid=${gid}` : "";
208
+ const onlyDirFlag = config.prefix ? ` --only-dir=${shellQuote(validatePrefix(config.prefix))}` : "";
209
+ const hasCredentials = !!config.serviceAccountKey;
210
+ let mountCmd;
211
+ if (hasCredentials) {
212
+ const keyPath = `/tmp/gcs-key-${createHash("md5").update(mountPath).digest("hex").slice(0, 8)}.json`;
213
+ await sandbox.commands.run(`sudo rm -f ${keyPath}`);
214
+ await sandbox.files.write(keyPath, config.serviceAccountKey);
215
+ await sandbox.commands.run(`sudo chown root:root ${keyPath} && sudo chmod 600 ${keyPath}`);
216
+ mountCmd = `sudo gcsfuse --key-file=${keyPath} -o allow_other ${uidGidFlags}${onlyDirFlag} ${config.bucket} ${mountPath}`;
217
+ } else {
218
+ logger.debug(`${LOG_PREFIX} No credentials provided, mounting GCS as public bucket (read-only)`);
219
+ mountCmd = `sudo gcsfuse --anonymous-access -o allow_other ${uidGidFlags}${onlyDirFlag} ${config.bucket} ${mountPath}`;
220
+ }
221
+ logger.debug(`${LOG_PREFIX} Mounting GCS:`, mountCmd);
222
+ try {
223
+ const result = await sandbox.commands.run(mountCmd, { timeoutMs: 6e4 });
224
+ logger.debug(`${LOG_PREFIX} gcsfuse result:`, {
225
+ exitCode: result.exitCode,
226
+ stdout: result.stdout,
227
+ stderr: result.stderr
228
+ });
229
+ if (result.exitCode !== 0) throw new Error(`Failed to mount GCS bucket: ${result.stderr || result.stdout}`);
230
+ } catch (error) {
231
+ const errorObj = error;
232
+ const stderr = errorObj.result?.stderr || "";
233
+ const stdout = errorObj.result?.stdout || "";
234
+ logger.error(`${LOG_PREFIX} gcsfuse error:`, {
235
+ stderr,
236
+ stdout,
237
+ error: String(error)
238
+ });
239
+ throw new Error(`Failed to mount GCS bucket: ${stderr || stdout || error}`);
240
+ }
221
241
  }
222
- var SAFE_CONTAINER_NAME = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/;
223
- var BLOBFUSE2_GITHUB_DEB = "https://github.com/Azure/azure-storage-fuse/releases/download/blobfuse2-2.5.1/blobfuse2-2.5.1-Ubuntu-22.04.x86_64.deb";
242
+ //#endregion
243
+ //#region src/sandbox/mounts/azure.ts
244
+ const SAFE_CONTAINER_NAME = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/;
245
+ const BLOBFUSE2_GITHUB_DEB = "https://github.com/Azure/azure-storage-fuse/releases/download/blobfuse2-2.5.1/blobfuse2-2.5.1-Ubuntu-22.04.x86_64.deb";
224
246
  function validateContainerName(name) {
225
- if (!SAFE_CONTAINER_NAME.test(name) || name.includes("--")) {
226
- throw new Error(
227
- `Invalid Azure container name: "${name}". Container names must be 3-63 lowercase alphanumeric characters or hyphens, with no consecutive hyphens.`
228
- );
229
- }
247
+ if (!SAFE_CONTAINER_NAME.test(name) || name.includes("--")) throw new Error(`Invalid Azure container name: "${name}". Container names must be 3-63 lowercase alphanumeric characters or hyphens, with no consecutive hyphens.`);
230
248
  }
231
249
  function parseConnectionString(cs) {
232
- const out = {};
233
- for (const part of cs.split(";")) {
234
- const eq = part.indexOf("=");
235
- if (eq === -1) continue;
236
- const key = part.slice(0, eq).trim();
237
- const value = part.slice(eq + 1).trim();
238
- if (!value) continue;
239
- if (key === "AccountName") out.accountName = value;
240
- else if (key === "AccountKey") out.accountKey = value;
241
- else if (key === "SharedAccessSignature") out.sasToken = value;
242
- else if (key === "BlobEndpoint") out.endpoint = value;
243
- else if (key === "EndpointSuffix") out.endpointSuffix = value;
244
- else if (key === "DefaultEndpointsProtocol") out.protocol = value;
245
- }
246
- if (!out.endpoint && out.accountName) {
247
- out.endpoint = `${out.protocol || "https"}://${out.accountName}.blob.${out.endpointSuffix || "core.windows.net"}`;
248
- }
249
- return out;
250
+ const out = {};
251
+ for (const part of cs.split(";")) {
252
+ const eq = part.indexOf("=");
253
+ if (eq === -1) continue;
254
+ const key = part.slice(0, eq).trim();
255
+ const value = part.slice(eq + 1).trim();
256
+ if (!value) continue;
257
+ if (key === "AccountName") out.accountName = value;
258
+ else if (key === "AccountKey") out.accountKey = value;
259
+ else if (key === "SharedAccessSignature") out.sasToken = value;
260
+ else if (key === "BlobEndpoint") out.endpoint = value;
261
+ else if (key === "EndpointSuffix") out.endpointSuffix = value;
262
+ else if (key === "DefaultEndpointsProtocol") out.protocol = value;
263
+ }
264
+ if (!out.endpoint && out.accountName) out.endpoint = `${out.protocol || "https"}://${out.accountName}.blob.${out.endpointSuffix || "core.windows.net"}`;
265
+ return out;
250
266
  }
251
267
  function yamlString(value) {
252
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
268
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
253
269
  }
254
270
  function parseOsRelease(output) {
255
- const values = {};
256
- for (const line of output.split("\n")) {
257
- const eq = line.indexOf("=");
258
- if (eq === -1) continue;
259
- const key = line.slice(0, eq);
260
- const value = line.slice(eq + 1).trim().replace(/^"|"$/g, "");
261
- values[key] = value;
262
- }
263
- return values;
271
+ const values = {};
272
+ for (const line of output.split("\n")) {
273
+ const eq = line.indexOf("=");
274
+ if (eq === -1) continue;
275
+ const key = line.slice(0, eq);
276
+ values[key] = line.slice(eq + 1).trim().replace(/^"|"$/g, "");
277
+ }
278
+ return values;
264
279
  }
265
280
  function resolveMicrosoftAptRepos(osReleaseOutput) {
266
- const osRelease = parseOsRelease(osReleaseOutput);
267
- const distroId = osRelease.ID || "ubuntu";
268
- const codename = osRelease.VERSION_CODENAME || (distroId === "debian" ? "bookworm" : "jammy");
269
- const versionId = osRelease.VERSION_ID || (distroId === "debian" ? "12" : "22.04");
270
- if (!/^[a-z0-9][a-z0-9-]*$/.test(codename)) {
271
- throw new Error(`Invalid distro codename for blobfuse2 repo: "${codename}"`);
272
- }
273
- if (!/^\d+(?:\.\d+)?$/.test(versionId)) {
274
- throw new Error(`Invalid distro version for blobfuse2 repo: "${versionId}"`);
275
- }
276
- if (distroId === "debian") {
277
- const repos = [
278
- { repoUrl: `https://packages.microsoft.com/debian/${versionId.split(".")[0]}/prod`, suite: codename }
279
- ];
280
- if (versionId.split(".")[0] !== "12" || codename !== "bookworm") {
281
- repos.push({ repoUrl: "https://packages.microsoft.com/debian/12/prod", suite: "bookworm" });
282
- }
283
- return repos;
284
- }
285
- if (distroId === "ubuntu") {
286
- const repos = [{ repoUrl: `https://packages.microsoft.com/ubuntu/${versionId}/prod`, suite: codename }];
287
- if (versionId !== "24.04" || codename !== "noble") {
288
- repos.push({ repoUrl: "https://packages.microsoft.com/ubuntu/24.04/prod", suite: "noble" });
289
- }
290
- if (versionId !== "22.04" || codename !== "jammy") {
291
- repos.push({ repoUrl: "https://packages.microsoft.com/ubuntu/22.04/prod", suite: "jammy" });
292
- }
293
- return repos;
294
- }
295
- throw new Error(`Unsupported distro for blobfuse2 runtime installation: "${distroId}"`);
281
+ const osRelease = parseOsRelease(osReleaseOutput);
282
+ const distroId = osRelease.ID || "ubuntu";
283
+ const codename = osRelease.VERSION_CODENAME || (distroId === "debian" ? "bookworm" : "jammy");
284
+ const versionId = osRelease.VERSION_ID || (distroId === "debian" ? "12" : "22.04");
285
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(codename)) throw new Error(`Invalid distro codename for blobfuse2 repo: "${codename}"`);
286
+ if (!/^\d+(?:\.\d+)?$/.test(versionId)) throw new Error(`Invalid distro version for blobfuse2 repo: "${versionId}"`);
287
+ if (distroId === "debian") {
288
+ const repos = [{
289
+ repoUrl: `https://packages.microsoft.com/debian/${versionId.split(".")[0]}/prod`,
290
+ suite: codename
291
+ }];
292
+ if (versionId.split(".")[0] !== "12" || codename !== "bookworm") repos.push({
293
+ repoUrl: "https://packages.microsoft.com/debian/12/prod",
294
+ suite: "bookworm"
295
+ });
296
+ return repos;
297
+ }
298
+ if (distroId === "ubuntu") {
299
+ const repos = [{
300
+ repoUrl: `https://packages.microsoft.com/ubuntu/${versionId}/prod`,
301
+ suite: codename
302
+ }];
303
+ if (versionId !== "24.04" || codename !== "noble") repos.push({
304
+ repoUrl: "https://packages.microsoft.com/ubuntu/24.04/prod",
305
+ suite: "noble"
306
+ });
307
+ if (versionId !== "22.04" || codename !== "jammy") repos.push({
308
+ repoUrl: "https://packages.microsoft.com/ubuntu/22.04/prod",
309
+ suite: "jammy"
310
+ });
311
+ return repos;
312
+ }
313
+ throw new Error(`Unsupported distro for blobfuse2 runtime installation: "${distroId}"`);
296
314
  }
297
315
  function resolveAuth(config) {
298
- let accountName = config.accountName;
299
- let accountKey = config.accountKey;
300
- let sasToken = config.sasToken;
301
- let endpoint = config.endpoint;
302
- if (config.connectionString) {
303
- const parsed = parseConnectionString(config.connectionString);
304
- accountName = accountName ?? parsed.accountName;
305
- accountKey = accountKey ?? parsed.accountKey;
306
- sasToken = sasToken ?? parsed.sasToken;
307
- endpoint = endpoint ?? parsed.endpoint;
308
- }
309
- let mode;
310
- if (config.useDefaultCredential) {
311
- mode = "msi";
312
- } else if (sasToken) {
313
- mode = "sas";
314
- } else if (accountKey) {
315
- mode = "key";
316
- } else {
317
- throw new Error(
318
- "Azure Blob mount requires credentials: provide connectionString, accountKey + accountName, sasToken + accountName, or useDefaultCredential."
319
- );
320
- }
321
- if (!accountName) {
322
- throw new Error("Azure Blob mount requires an accountName (either explicitly or via connectionString).");
323
- }
324
- if (endpoint) {
325
- validateEndpoint(endpoint);
326
- }
327
- return { mode, accountName, accountKey, sasToken, endpoint };
316
+ let accountName = config.accountName;
317
+ let accountKey = config.accountKey;
318
+ let sasToken = config.sasToken;
319
+ let endpoint = config.endpoint;
320
+ if (config.connectionString) {
321
+ const parsed = parseConnectionString(config.connectionString);
322
+ accountName = accountName ?? parsed.accountName;
323
+ accountKey = accountKey ?? parsed.accountKey;
324
+ sasToken = sasToken ?? parsed.sasToken;
325
+ endpoint = endpoint ?? parsed.endpoint;
326
+ }
327
+ let mode;
328
+ if (config.useDefaultCredential) mode = "msi";
329
+ else if (sasToken) mode = "sas";
330
+ else if (accountKey) mode = "key";
331
+ else throw new Error("Azure Blob mount requires credentials: provide connectionString, accountKey + accountName, sasToken + accountName, or useDefaultCredential.");
332
+ if (!accountName) throw new Error("Azure Blob mount requires an accountName (either explicitly or via connectionString).");
333
+ if (endpoint) validateEndpoint(endpoint);
334
+ return {
335
+ mode,
336
+ accountName,
337
+ accountKey,
338
+ sasToken,
339
+ endpoint
340
+ };
328
341
  }
329
342
  function buildBlobfuseConfig(container, auth, cachePath, readOnly) {
330
- const lines = [
331
- "allow-other: true",
332
- "foreground: false",
333
- `read-only: ${readOnly ? "true" : "false"}`,
334
- "logging:",
335
- " type: silent",
336
- "components:",
337
- " - libfuse",
338
- " - file_cache",
339
- " - attr_cache",
340
- " - azstorage",
341
- "libfuse:",
342
- " attribute-expiration-sec: 240",
343
- " entry-expiration-sec: 240",
344
- " negative-entry-expiration-sec: 120",
345
- "file_cache:",
346
- ` path: ${yamlString(cachePath)}`,
347
- " timeout-sec: 120",
348
- "attr_cache:",
349
- " timeout-sec: 7200",
350
- "azstorage:",
351
- ` mode: ${auth.mode}`,
352
- ` account-name: ${yamlString(auth.accountName)}`,
353
- ` container: ${yamlString(container)}`
354
- ];
355
- if (auth.mode === "key" && auth.accountKey) {
356
- lines.push(` account-key: ${yamlString(auth.accountKey)}`);
357
- } else if (auth.mode === "sas" && auth.sasToken) {
358
- lines.push(` sas: ${yamlString(auth.sasToken)}`);
359
- }
360
- if (auth.endpoint) {
361
- lines.push(` endpoint: ${yamlString(auth.endpoint.replace(/\/$/, ""))}`);
362
- }
363
- return lines.join("\n") + "\n";
343
+ const lines = [
344
+ "allow-other: true",
345
+ "foreground: false",
346
+ `read-only: ${readOnly ? "true" : "false"}`,
347
+ "logging:",
348
+ " type: silent",
349
+ "components:",
350
+ " - libfuse",
351
+ " - file_cache",
352
+ " - attr_cache",
353
+ " - azstorage",
354
+ "libfuse:",
355
+ " attribute-expiration-sec: 240",
356
+ " entry-expiration-sec: 240",
357
+ " negative-entry-expiration-sec: 120",
358
+ "file_cache:",
359
+ ` path: ${yamlString(cachePath)}`,
360
+ " timeout-sec: 120",
361
+ "attr_cache:",
362
+ " timeout-sec: 7200",
363
+ "azstorage:",
364
+ ` mode: ${auth.mode}`,
365
+ ` account-name: ${yamlString(auth.accountName)}`,
366
+ ` container: ${yamlString(container)}`
367
+ ];
368
+ if (auth.mode === "key" && auth.accountKey) lines.push(` account-key: ${yamlString(auth.accountKey)}`);
369
+ else if (auth.mode === "sas" && auth.sasToken) lines.push(` sas: ${yamlString(auth.sasToken)}`);
370
+ if (auth.endpoint) lines.push(` endpoint: ${yamlString(auth.endpoint.replace(/\/$/, ""))}`);
371
+ return lines.join("\n") + "\n";
364
372
  }
373
+ /**
374
+ * Mount an Azure Blob container using blobfuse2.
375
+ */
365
376
  async function mountAzure(mountPath, config, ctx) {
366
- const { sandbox, logger } = ctx;
367
- validateContainerName(config.container);
368
- const auth = resolveAuth(config);
369
- const prefix = config.prefix ? validatePrefix(config.prefix) : void 0;
370
- const checkResult = await sandbox.commands.run('which blobfuse2 || echo "not found"');
371
- if (checkResult.stdout.includes("not found")) {
372
- logger.warn(`${LOG_PREFIX} blobfuse2 not found, attempting runtime installation...`);
373
- logger.info(
374
- `${LOG_PREFIX} Tip: For faster startup, pre-install blobfuse2 in your sandbox template via createMountableTemplate()`
375
- );
376
- const osReleaseResult = await sandbox.commands.run("cat /etc/os-release 2>/dev/null || true");
377
- const repos = resolveMicrosoftAptRepos(osReleaseResult.stdout);
378
- const repoSetupResult = await sandbox.commands.run(
379
- "sudo mkdir -p /etc/apt/keyrings && curl --retry 3 --retry-all-errors --retry-delay 2 -fsSL https://packages.microsoft.com/keys/microsoft.asc -o /tmp/ms-key.asc && sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/microsoft.gpg /tmp/ms-key.asc",
380
- { timeoutMs: 6e4 }
381
- );
382
- let installResult;
383
- if (repoSetupResult.exitCode === 0) {
384
- for (const { repoUrl, suite } of repos) {
385
- installResult = await sandbox.commands.run(
386
- `echo "deb [signed-by=/etc/apt/keyrings/microsoft.gpg] ${repoUrl} ${suite} main" | sudo tee /etc/apt/sources.list.d/microsoft-prod.list && sudo apt-get update 2>&1 && sudo apt-get install -y blobfuse2 fuse3 2>&1`,
387
- { timeoutMs: 18e4 }
388
- );
389
- if (installResult.exitCode === 0) break;
390
- logger.warn(`${LOG_PREFIX} blobfuse2 install failed for ${repoUrl} ${suite}, trying fallback if available`);
391
- }
392
- } else {
393
- logger.warn(`${LOG_PREFIX} Failed to set up Microsoft apt repository, trying GitHub release fallback`);
394
- }
395
- let verifyResult = await sandbox.commands.run("which blobfuse2 && blobfuse2 --version", { timeoutMs: 3e4 });
396
- if (verifyResult.exitCode !== 0) {
397
- installResult = await sandbox.commands.run(
398
- `sudo apt-get update -qq 2>&1 || true && sudo apt-get install -y fuse3 ca-certificates curl 2>&1 && curl -L --retry 3 --retry-all-errors --retry-delay 2 -fSLo /tmp/blobfuse2.deb ${BLOBFUSE2_GITHUB_DEB} && sudo dpkg -i /tmp/blobfuse2.deb 2>&1 && sudo bash -c 'lib=$(find /usr/lib -name "libfuse3.so.3.*" | head -1); [ -z "$lib" ] || ln -sf "$lib" /usr/lib/x86_64-linux-gnu/libfuse3.so.3'`,
399
- { timeoutMs: 18e4 }
400
- );
401
- verifyResult = await sandbox.commands.run("which blobfuse2 && blobfuse2 --version", { timeoutMs: 3e4 });
402
- }
403
- if (!installResult || verifyResult.exitCode !== 0) {
404
- throw new Error(
405
- `Failed to install blobfuse2. For Azure Blob mounting, your template needs blobfuse2 and fuse3.
377
+ const { sandbox, logger } = ctx;
378
+ validateContainerName(config.container);
379
+ const auth = resolveAuth(config);
380
+ const prefix = config.prefix ? validatePrefix(config.prefix) : void 0;
381
+ if ((await sandbox.commands.run("which blobfuse2 || echo \"not found\"")).stdout.includes("not found")) {
382
+ logger.warn(`${LOG_PREFIX} blobfuse2 not found, attempting runtime installation...`);
383
+ logger.info(`${LOG_PREFIX} Tip: For faster startup, pre-install blobfuse2 in your sandbox template via createMountableTemplate()`);
384
+ const repos = resolveMicrosoftAptRepos((await sandbox.commands.run("cat /etc/os-release 2>/dev/null || true")).stdout);
385
+ const repoSetupResult = await sandbox.commands.run("sudo mkdir -p /etc/apt/keyrings && curl --retry 3 --retry-all-errors --retry-delay 2 -fsSL https://packages.microsoft.com/keys/microsoft.asc -o /tmp/ms-key.asc && sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/microsoft.gpg /tmp/ms-key.asc", { timeoutMs: 6e4 });
386
+ let installResult;
387
+ if (repoSetupResult.exitCode === 0) for (const { repoUrl, suite } of repos) {
388
+ installResult = await sandbox.commands.run(`echo "deb [signed-by=/etc/apt/keyrings/microsoft.gpg] ${repoUrl} ${suite} main" | sudo tee /etc/apt/sources.list.d/microsoft-prod.list && sudo apt-get update 2>&1 && sudo apt-get install -y blobfuse2 fuse3 2>&1`, { timeoutMs: 18e4 });
389
+ if (installResult.exitCode === 0) break;
390
+ logger.warn(`${LOG_PREFIX} blobfuse2 install failed for ${repoUrl} ${suite}, trying fallback if available`);
391
+ }
392
+ else logger.warn(`${LOG_PREFIX} Failed to set up Microsoft apt repository, trying GitHub release fallback`);
393
+ let verifyResult = await sandbox.commands.run("which blobfuse2 && blobfuse2 --version", { timeoutMs: 3e4 });
394
+ if (verifyResult.exitCode !== 0) {
395
+ installResult = await sandbox.commands.run(`sudo apt-get update -qq 2>&1 || true && sudo apt-get install -y fuse3 ca-certificates curl 2>&1 && curl -L --retry 3 --retry-all-errors --retry-delay 2 -fSLo /tmp/blobfuse2.deb ${BLOBFUSE2_GITHUB_DEB} && sudo dpkg -i /tmp/blobfuse2.deb 2>&1 && sudo bash -c 'lib=\$(find /usr/lib -name "libfuse3.so.3.*" | head -1); [ -z "\$lib" ] || ln -sf "\$lib" /usr/lib/x86_64-linux-gnu/libfuse3.so.3'`, { timeoutMs: 18e4 });
396
+ verifyResult = await sandbox.commands.run("which blobfuse2 && blobfuse2 --version", { timeoutMs: 3e4 });
397
+ }
398
+ if (!installResult || verifyResult.exitCode !== 0) throw new Error(`Failed to install blobfuse2. For Azure Blob mounting, your template needs blobfuse2 and fuse3.
406
399
 
407
400
  Option 1: Use createMountableTemplate() helper:
408
401
  import { E2BSandbox, createMountableTemplate } from '@mastra/e2b';
@@ -411,1065 +404,1123 @@ Option 1: Use createMountableTemplate() helper:
411
404
  Option 2: Customize the base template:
412
405
  new E2BSandbox({ template: base => base.aptInstall(['your-packages']) })
413
406
 
414
- Error details: ${verifyResult.stderr || verifyResult.stdout || installResult?.stderr || installResult?.stdout || "unknown error"}`
415
- );
416
- }
417
- }
418
- const mountHash = createHash("md5").update(mountPath).digest("hex").slice(0, 8);
419
- const configPath = `/tmp/.blobfuse2-config-${mountHash}.yaml`;
420
- const cachePath = `/tmp/blobfuse2-cache-${mountHash}`;
421
- const yaml = buildBlobfuseConfig(config.container, auth, cachePath, !!config.readOnly);
422
- await sandbox.commands.run(`sudo rm -f ${configPath}`);
423
- await sandbox.files.write(configPath, yaml);
424
- await sandbox.commands.run(`sudo chown root:root ${configPath} && sudo chmod 600 ${configPath}`);
425
- await sandbox.commands.run(`sudo rm -rf ${shellQuote(cachePath)} && sudo mkdir -p ${shellQuote(cachePath)}`);
426
- const prefixFlags = prefix ? ` --virtual-directory=true --subdirectory=${shellQuote(prefix)}` : "";
427
- const mountCmd = `sudo blobfuse2 mount ${shellQuote(mountPath)} --config-file=${shellQuote(configPath)}${prefixFlags}`;
428
- logger.debug(`${LOG_PREFIX} Mounting Azure Blob:`, mountCmd);
429
- try {
430
- const result = await sandbox.commands.run(mountCmd, { timeoutMs: 6e4 });
431
- logger.debug(`${LOG_PREFIX} blobfuse2 result:`, {
432
- exitCode: result.exitCode,
433
- stdout: result.stdout,
434
- stderr: result.stderr
435
- });
436
- if (result.exitCode !== 0) {
437
- throw new Error(`Failed to mount Azure Blob container: ${result.stderr || result.stdout}`);
438
- }
439
- } catch (error) {
440
- const errorObj = error;
441
- const stderr = errorObj.result?.stderr || "";
442
- const stdout = errorObj.result?.stdout || "";
443
- logger.error(`${LOG_PREFIX} blobfuse2 error:`, { stderr, stdout, error: String(error) });
444
- throw new Error(`Failed to mount Azure Blob container: ${stderr || stdout || error}`);
445
- }
407
+ Error details: ${verifyResult.stderr || verifyResult.stdout || installResult?.stderr || installResult?.stdout || "unknown error"}`);
408
+ }
409
+ const mountHash = createHash("md5").update(mountPath).digest("hex").slice(0, 8);
410
+ const configPath = `/tmp/.blobfuse2-config-${mountHash}.yaml`;
411
+ const cachePath = `/tmp/blobfuse2-cache-${mountHash}`;
412
+ const yaml = buildBlobfuseConfig(config.container, auth, cachePath, !!config.readOnly);
413
+ await sandbox.commands.run(`sudo rm -f ${configPath}`);
414
+ await sandbox.files.write(configPath, yaml);
415
+ await sandbox.commands.run(`sudo chown root:root ${configPath} && sudo chmod 600 ${configPath}`);
416
+ await sandbox.commands.run(`sudo rm -rf ${shellQuote(cachePath)} && sudo mkdir -p ${shellQuote(cachePath)}`);
417
+ const prefixFlags = prefix ? ` --virtual-directory=true --subdirectory=${shellQuote(prefix)}` : "";
418
+ const mountCmd = `sudo blobfuse2 mount ${shellQuote(mountPath)} --config-file=${shellQuote(configPath)}${prefixFlags}`;
419
+ logger.debug(`${LOG_PREFIX} Mounting Azure Blob:`, mountCmd);
420
+ try {
421
+ const result = await sandbox.commands.run(mountCmd, { timeoutMs: 6e4 });
422
+ logger.debug(`${LOG_PREFIX} blobfuse2 result:`, {
423
+ exitCode: result.exitCode,
424
+ stdout: result.stdout,
425
+ stderr: result.stderr
426
+ });
427
+ if (result.exitCode !== 0) throw new Error(`Failed to mount Azure Blob container: ${result.stderr || result.stdout}`);
428
+ } catch (error) {
429
+ const errorObj = error;
430
+ const stderr = errorObj.result?.stderr || "";
431
+ const stdout = errorObj.result?.stdout || "";
432
+ logger.error(`${LOG_PREFIX} blobfuse2 error:`, {
433
+ stderr,
434
+ stdout,
435
+ error: String(error)
436
+ });
437
+ throw new Error(`Failed to mount Azure Blob container: ${stderr || stdout || error}`);
438
+ }
446
439
  }
440
+ //#endregion
441
+ //#region src/sandbox/process-manager.ts
442
+ /**
443
+ * E2B Process Manager
444
+ *
445
+ * Implements SandboxProcessManager for E2B cloud sandboxes.
446
+ * Wraps the E2B SDK's commands API (background mode, sendStdin, kill, list).
447
+ */
448
+ /**
449
+ * Wraps an E2B CommandHandle to conform to Mastra's ProcessHandle.
450
+ * Not exported — internal to this module.
451
+ *
452
+ * Listener dispatch is handled by the base class. The manager's spawn()/get()
453
+ * methods wire E2B's constructor-time callbacks to handle.emitStdout/emitStderr.
454
+ */
447
455
  var E2BProcessHandle = class extends ProcessHandle {
448
- pid;
449
- _e2bHandle;
450
- _sandbox;
451
- _startTime;
452
- constructor(e2bHandle, sandbox, startTime, options) {
453
- super(options);
454
- this.pid = String(e2bHandle.pid);
455
- this._e2bHandle = e2bHandle;
456
- this._sandbox = sandbox;
457
- this._startTime = startTime;
458
- }
459
- /** Delegates to E2B's handle so exitCode reflects server-side state without needing wait(). */
460
- get exitCode() {
461
- return this._e2bHandle.exitCode;
462
- }
463
- async wait() {
464
- try {
465
- const result = await this._e2bHandle.wait();
466
- return {
467
- success: result.exitCode === 0,
468
- exitCode: result.exitCode,
469
- stdout: this.stdout,
470
- stderr: this.stderr,
471
- executionTimeMs: Date.now() - this._startTime
472
- };
473
- } catch (error) {
474
- const errorObj = error;
475
- const exitCode = errorObj.result?.exitCode ?? errorObj.exitCode ?? this.exitCode ?? 1;
476
- if (errorObj.result?.stdout) this.emitStdout(errorObj.result.stdout);
477
- if (errorObj.result?.stderr) this.emitStderr(errorObj.result.stderr);
478
- return {
479
- success: false,
480
- exitCode,
481
- stdout: this.stdout,
482
- stderr: this.stderr || (error instanceof Error ? error.message : String(error)),
483
- executionTimeMs: Date.now() - this._startTime
484
- };
485
- }
486
- }
487
- async kill() {
488
- if (this.exitCode !== void 0) return false;
489
- return this._e2bHandle.kill();
490
- }
491
- async sendStdin(data) {
492
- if (this.exitCode !== void 0) {
493
- throw new Error(`Process ${this.pid} has already exited with code ${this.exitCode}`);
494
- }
495
- await this._sandbox.commands.sendStdin(this._e2bHandle.pid, data);
496
- }
456
+ pid;
457
+ _e2bHandle;
458
+ _sandbox;
459
+ _startTime;
460
+ constructor(e2bHandle, sandbox, startTime, options) {
461
+ super(options);
462
+ this.pid = String(e2bHandle.pid);
463
+ this._e2bHandle = e2bHandle;
464
+ this._sandbox = sandbox;
465
+ this._startTime = startTime;
466
+ }
467
+ /** Delegates to E2B's handle so exitCode reflects server-side state without needing wait(). */
468
+ get exitCode() {
469
+ return this._e2bHandle.exitCode;
470
+ }
471
+ async wait() {
472
+ try {
473
+ const result = await this._e2bHandle.wait();
474
+ return {
475
+ success: result.exitCode === 0,
476
+ exitCode: result.exitCode,
477
+ stdout: this.stdout,
478
+ stderr: this.stderr,
479
+ executionTimeMs: Date.now() - this._startTime
480
+ };
481
+ } catch (error) {
482
+ const errorObj = error;
483
+ const exitCode = errorObj.result?.exitCode ?? errorObj.exitCode ?? this.exitCode ?? 1;
484
+ if (errorObj.result?.stdout) this.emitStdout(errorObj.result.stdout);
485
+ if (errorObj.result?.stderr) this.emitStderr(errorObj.result.stderr);
486
+ return {
487
+ success: false,
488
+ exitCode,
489
+ stdout: this.stdout,
490
+ stderr: this.stderr || (error instanceof Error ? error.message : String(error)),
491
+ executionTimeMs: Date.now() - this._startTime
492
+ };
493
+ }
494
+ }
495
+ async kill() {
496
+ if (this.exitCode !== void 0) return false;
497
+ return this._e2bHandle.kill();
498
+ }
499
+ async sendStdin(data) {
500
+ if (this.exitCode !== void 0) throw new Error(`Process ${this.pid} has already exited with code ${this.exitCode}`);
501
+ await this._sandbox.commands.sendStdin(this._e2bHandle.pid, data);
502
+ }
497
503
  };
504
+ /**
505
+ * E2B implementation of SandboxProcessManager.
506
+ * Uses the E2B SDK's commands.run() with background: true.
507
+ */
498
508
  var E2BProcessManager = class extends SandboxProcessManager {
499
- async spawn(command, options = {}) {
500
- return this.sandbox.retryOnDead(async () => {
501
- const e2b = this.sandbox.e2b;
502
- const mergedEnv = { ...this.env, ...options.env };
503
- const envs = Object.fromEntries(
504
- Object.entries(mergedEnv).filter((entry) => entry[1] !== void 0)
505
- );
506
- let handle;
507
- const e2bHandle = await e2b.commands.run(command, {
508
- background: true,
509
- stdin: true,
510
- cwd: options.cwd,
511
- envs,
512
- timeoutMs: options.timeout,
513
- onStdout: (data) => handle.emitStdout(data),
514
- onStderr: (data) => handle.emitStderr(data)
515
- });
516
- handle = new E2BProcessHandle(e2bHandle, e2b, Date.now(), options);
517
- this._tracked.set(handle.pid, handle);
518
- return handle;
519
- });
520
- }
521
- /**
522
- * List processes by querying E2B's commands API.
523
- * E2B manages all state server-side — no local tracking needed.
524
- */
525
- async list() {
526
- const e2b = this.sandbox.e2b;
527
- const procs = await e2b.commands.list();
528
- return procs.map((proc) => ({
529
- pid: String(proc.pid),
530
- command: [proc.cmd, ...proc.args].join(" "),
531
- running: true
532
- // E2B only lists running processes
533
- }));
534
- }
535
- /**
536
- * Get a handle to a process by PID.
537
- * Checks base class tracking first, then falls back to commands.connect()
538
- * for processes spawned externally or before reconnection.
539
- */
540
- async get(pid) {
541
- const tracked = this._tracked.get(pid);
542
- if (tracked) return tracked;
543
- const numericPid = /^\d+$/.test(pid) ? Number(pid) : void 0;
544
- if (numericPid === void 0) return void 0;
545
- const e2b = this.sandbox.e2b;
546
- let handle;
547
- try {
548
- const e2bHandle = await e2b.commands.connect(numericPid, {
549
- onStdout: (data) => handle.emitStdout(data),
550
- onStderr: (data) => handle.emitStderr(data)
551
- });
552
- handle = new E2BProcessHandle(e2bHandle, e2b, Date.now());
553
- this._tracked.set(handle.pid, handle);
554
- return handle;
555
- } catch {
556
- return void 0;
557
- }
558
- }
509
+ async spawn(command, options = {}) {
510
+ return this.sandbox.retryOnDead(async () => {
511
+ const e2b = this.sandbox.e2b;
512
+ const mergedEnv = {
513
+ ...this.env,
514
+ ...options.env
515
+ };
516
+ const envs = Object.fromEntries(Object.entries(mergedEnv).filter((entry) => entry[1] !== void 0));
517
+ let handle;
518
+ handle = new E2BProcessHandle(await e2b.commands.run(command, {
519
+ background: true,
520
+ stdin: true,
521
+ cwd: options.cwd,
522
+ envs,
523
+ timeoutMs: options.timeout,
524
+ onStdout: (data) => handle.emitStdout(data),
525
+ onStderr: (data) => handle.emitStderr(data)
526
+ }), e2b, Date.now(), options);
527
+ this._tracked.set(handle.pid, handle);
528
+ return handle;
529
+ });
530
+ }
531
+ /**
532
+ * List processes by querying E2B's commands API.
533
+ * E2B manages all state server-side — no local tracking needed.
534
+ */
535
+ async list() {
536
+ return (await this.sandbox.e2b.commands.list()).map((proc) => ({
537
+ pid: String(proc.pid),
538
+ command: [proc.cmd, ...proc.args].join(" "),
539
+ running: true
540
+ }));
541
+ }
542
+ /**
543
+ * Get a handle to a process by PID.
544
+ * Checks base class tracking first, then falls back to commands.connect()
545
+ * for processes spawned externally or before reconnection.
546
+ */
547
+ async get(pid) {
548
+ const tracked = this._tracked.get(pid);
549
+ if (tracked) return tracked;
550
+ const numericPid = /^\d+$/.test(pid) ? Number(pid) : void 0;
551
+ if (numericPid === void 0) return void 0;
552
+ const e2b = this.sandbox.e2b;
553
+ let handle;
554
+ try {
555
+ handle = new E2BProcessHandle(await e2b.commands.connect(numericPid, {
556
+ onStdout: (data) => handle.emitStdout(data),
557
+ onStderr: (data) => handle.emitStderr(data)
558
+ }), e2b, Date.now());
559
+ this._tracked.set(handle.pid, handle);
560
+ return handle;
561
+ } catch {
562
+ return;
563
+ }
564
+ }
559
565
  };
560
-
561
- // src/sandbox/index.ts
562
- var SAFE_MOUNT_PATH = /^\/[a-zA-Z0-9_.\-/]+$/;
566
+ //#endregion
567
+ //#region src/sandbox/index.ts
568
+ /** Allowlist pattern for mount paths — absolute path with safe characters only. */
569
+ const SAFE_MOUNT_PATH = /^\/[a-zA-Z0-9_.\-/]+$/;
563
570
  function validateMountPath(mountPath) {
564
- if (!SAFE_MOUNT_PATH.test(mountPath)) {
565
- throw new Error(
566
- `Invalid mount path: ${mountPath}. Must be an absolute path with alphanumeric, dash, dot, underscore, or slash characters only.`
567
- );
568
- }
571
+ if (!SAFE_MOUNT_PATH.test(mountPath)) throw new Error(`Invalid mount path: ${mountPath}. Must be an absolute path with alphanumeric, dash, dot, underscore, or slash characters only.`);
569
572
  }
570
- var SAFE_MARKER_NAME = /^mount-[a-z0-9]+$/;
571
- var E2BSandbox = class _E2BSandbox extends MastraSandbox {
572
- id;
573
- name = "E2BSandbox";
574
- provider = "e2b";
575
- status = "pending";
576
- /**
577
- * Networking capability: public HTTPS URLs for sandbox ports.
578
- * E2B exposes every port via `getHost(port)` — no upfront declaration needed.
579
- *
580
- * When not attached in this process, the URL is resolved by looking up the
581
- * existing sandbox by identity (without resuming it) and deriving the host
582
- * (`{port}-{sandboxId}.{domain}`), so other processes can resolve
583
- * deployments without waking a paused sandbox.
584
- */
585
- networking = {
586
- getPortUrl: async (port) => {
587
- try {
588
- if (this._sandbox) {
589
- return `https://${this._sandbox.getHost(port)}`;
590
- }
591
- const info = await this.lookupExistingSandboxInfo();
592
- if (!info) return null;
593
- return `https://${port}-${info.sandboxId}.${this.sandboxDomain}`;
594
- } catch {
595
- return null;
596
- }
597
- }
598
- };
599
- _sandbox = null;
600
- _createdAt = null;
601
- _isRetrying = false;
602
- timeout;
603
- templateSpec;
604
- env;
605
- metadata;
606
- network;
607
- connectionOpts;
608
- _instructionsOverride;
609
- _constructorOptions;
610
- /** Resolved template ID after building (if needed) */
611
- _resolvedTemplateId;
612
- /** Promise for template preparation (started in constructor) */
613
- _templatePreparePromise;
614
- constructor(options = {}) {
615
- super({
616
- ...options,
617
- name: "E2BSandbox",
618
- processes: new E2BProcessManager({ env: options.env ?? {} })
619
- });
620
- this.id = options.id ?? this.generateId();
621
- this.timeout = options.timeout ?? 3e5;
622
- this.templateSpec = options.template;
623
- this.env = options.env ?? {};
624
- this.metadata = options.metadata ?? {};
625
- this.network = options.network;
626
- this.connectionOpts = {
627
- ...options.domain && { domain: options.domain },
628
- ...options.apiUrl && { apiUrl: options.apiUrl },
629
- ...options.apiKey && { apiKey: options.apiKey },
630
- ...options.accessToken && { accessToken: options.accessToken }
631
- };
632
- this._instructionsOverride = options.instructions;
633
- this._constructorOptions = { ...options };
634
- this._templatePreparePromise = this.resolveTemplate().catch((err) => {
635
- this.logger.debug(`${LOG_PREFIX} Template preparation error (will retry on start):`, err);
636
- return "";
637
- });
638
- }
639
- /**
640
- * Construct a sibling `E2BSandbox` that inherits this sandbox's
641
- * configuration (credentials, template, network, metadata, instructions)
642
- * with per-instance overrides.
643
- *
644
- * Performs no I/O — the sandbox clone provisions (or reconnects to an
645
- * existing E2B sandbox with the same logical `id`) on its own `start()`.
646
- * Use it when one configured sandbox acts as the template for a fleet of
647
- * independent sandboxes (e.g. one per project).
648
- *
649
- * `options.idleTimeoutMinutes` maps to the E2B sandbox `timeout` (ms);
650
- * `options.sandboxId` is ignored because E2B reconnects by logical `id`.
651
- */
652
- clone(options = {}) {
653
- const { id: _id, ...base } = this._constructorOptions;
654
- return new _E2BSandbox({
655
- ...base,
656
- ...options.id !== void 0 && { id: options.id },
657
- ...options.env !== void 0 && { env: options.env },
658
- ...options.idleTimeoutMinutes !== void 0 && { timeout: options.idleTimeoutMinutes * 6e4 }
659
- });
660
- }
661
- /**
662
- * Get the underlying E2B Sandbox instance for direct access to E2B APIs.
663
- *
664
- * Use this when you need to access E2B features not exposed through the
665
- * WorkspaceSandbox interface (e.g., files API, ports, etc.).
666
- *
667
- * @throws {SandboxNotReadyError} If the sandbox has not been started
668
- *
669
- * @example Direct file operations
670
- * ```typescript
671
- * const e2b = sandbox.e2b;
672
- * await e2b.files.write('/tmp/test.txt', 'Hello');
673
- * const content = await e2b.files.read('/tmp/test.txt');
674
- * const files = await e2b.files.list('/tmp');
675
- * ```
676
- *
677
- * @example Access ports
678
- * ```typescript
679
- * const e2b = sandbox.e2b;
680
- * const url = e2b.getHost(3000);
681
- * ```
682
- */
683
- get e2b() {
684
- if (!this._sandbox) {
685
- throw new SandboxNotReadyError(this.id);
686
- }
687
- return this._sandbox;
688
- }
689
- // ---------------------------------------------------------------------------
690
- // Lifecycle
691
- // ---------------------------------------------------------------------------
692
- /**
693
- * Start the E2B sandbox.
694
- * Handles template preparation, existing sandbox reconnection, and new sandbox creation.
695
- *
696
- * Status management and mount processing are handled by the base class.
697
- */
698
- async start() {
699
- if (this._sandbox) {
700
- return;
701
- }
702
- const [existingSandbox, templateId] = await Promise.all([
703
- this.findExistingSandbox(),
704
- this._templatePreparePromise || this.resolveTemplate()
705
- ]);
706
- if (existingSandbox) {
707
- this._sandbox = existingSandbox;
708
- this._createdAt = /* @__PURE__ */ new Date();
709
- this.logger.debug(`${LOG_PREFIX} Reconnected to existing sandbox for: ${this.id}`);
710
- const expectedPaths = Array.from(this.mounts.entries.keys());
711
- this.logger.debug(`${LOG_PREFIX} Running mount reconciliation...`);
712
- await this.reconcileMounts(expectedPaths);
713
- this.logger.debug(`${LOG_PREFIX} Mount reconciliation complete`);
714
- return;
715
- }
716
- let resolvedTemplateId = templateId;
717
- if (!resolvedTemplateId) {
718
- this.logger.debug(`${LOG_PREFIX} Template preparation failed earlier, retrying...`);
719
- resolvedTemplateId = await this.resolveTemplate();
720
- }
721
- this.logger.debug(`${LOG_PREFIX} Creating new sandbox for: ${this.id} with template: ${resolvedTemplateId}`);
722
- try {
723
- this._sandbox = await Sandbox.create(resolvedTemplateId, {
724
- ...this.connectionOpts,
725
- lifecycle: { onTimeout: "pause" },
726
- metadata: {
727
- ...this.metadata,
728
- "mastra-sandbox-id": this.id
729
- },
730
- ...this.network && { network: this.network },
731
- timeoutMs: this.timeout
732
- });
733
- } catch (createError) {
734
- const errorStr = String(createError);
735
- if (errorStr.includes("404") && errorStr.includes("not found") && !this.templateSpec) {
736
- this.logger.debug(`${LOG_PREFIX} Template not found, rebuilding: ${templateId}`);
737
- this._resolvedTemplateId = void 0;
738
- const rebuiltTemplateId = await this.buildDefaultTemplate();
739
- this.logger.debug(`${LOG_PREFIX} Retrying sandbox creation with rebuilt template: ${rebuiltTemplateId}`);
740
- this._sandbox = await Sandbox.create(rebuiltTemplateId, {
741
- ...this.connectionOpts,
742
- lifecycle: { onTimeout: "pause" },
743
- metadata: {
744
- ...this.metadata,
745
- "mastra-sandbox-id": this.id
746
- },
747
- ...this.network && { network: this.network },
748
- timeoutMs: this.timeout
749
- });
750
- } else {
751
- throw createError;
752
- }
753
- }
754
- this.logger.debug(`${LOG_PREFIX} Created sandbox ${this._sandbox.sandboxId} for logical ID: ${this.id}`);
755
- this._createdAt = /* @__PURE__ */ new Date();
756
- }
757
- /**
758
- * Stop the E2B sandbox by pausing it (snapshot-stop).
759
- *
760
- * Pausing freezes the whole VM — filesystem, memory, and running processes —
761
- * and stops billing immediately. The next `start()` reconnects and resumes it,
762
- * with background processes still running. Filesystem mounts are unmounted
763
- * first (FUSE mounts don't survive pause) and reconciled again on start.
764
- *
765
- * Status management is handled by the base class.
766
- */
767
- async stop() {
768
- for (const mountPath of [...this.mounts.entries.keys()]) {
769
- try {
770
- await this.unmount(mountPath);
771
- } catch {
772
- }
773
- }
774
- if (this._sandbox) {
775
- await this._sandbox.pause();
776
- this.logger.debug(`${LOG_PREFIX} Paused sandbox ${this._sandbox.sandboxId} for: ${this.id}`);
777
- } else {
778
- const info = await this.lookupExistingSandboxInfo();
779
- if (info?.state === "running") {
780
- await Sandbox.pause(info.sandboxId, this.connectionOpts);
781
- this.logger.debug(`${LOG_PREFIX} Paused detached sandbox ${info.sandboxId} for: ${this.id}`);
782
- }
783
- }
784
- this._sandbox = null;
785
- }
786
- /**
787
- * Destroy the E2B sandbox and clean up all resources.
788
- * Unmounts filesystems, kills the sandbox, and clears mount state.
789
- * Status management is handled by the base class.
790
- */
791
- async destroy() {
792
- if (this._sandbox) {
793
- try {
794
- const procs = await this.processes.list();
795
- await Promise.all(procs.map((p) => this.processes.kill(p.pid)));
796
- } catch {
797
- }
798
- for (const mountPath of [...this.mounts.entries.keys()]) {
799
- try {
800
- await this.unmount(mountPath);
801
- } catch {
802
- }
803
- }
804
- await this._sandbox.kill();
805
- this._sandbox = null;
806
- } else {
807
- const info = await this.lookupExistingSandboxInfo();
808
- if (info) {
809
- await Sandbox.kill(info.sandboxId, this.connectionOpts);
810
- this.logger.debug(`${LOG_PREFIX} Killed detached sandbox ${info.sandboxId} for: ${this.id}`);
811
- }
812
- }
813
- this.mounts.clear();
814
- }
815
- async getInfo() {
816
- return {
817
- id: this.id,
818
- name: this.name,
819
- provider: this.provider,
820
- status: this.status,
821
- createdAt: this._createdAt ?? /* @__PURE__ */ new Date(),
822
- mounts: Array.from(this.mounts.entries).map(([path, entry]) => ({
823
- path,
824
- filesystem: entry.filesystem?.provider ?? entry.config?.type ?? "unknown"
825
- })),
826
- metadata: {
827
- ...this.metadata
828
- }
829
- };
830
- }
831
- // ---------------------------------------------------------------------------
832
- // File Upload
833
- // ---------------------------------------------------------------------------
834
- /**
835
- * Bulk-write files into the sandbox filesystem via the SDK's native upload.
836
- */
837
- async writeFiles(files) {
838
- await this.ensureRunning();
839
- await this.e2b.files.write(
840
- files.map((f) => ({
841
- path: f.path,
842
- data: typeof f.content === "string" ? f.content : new Blob([new Uint8Array(f.content)])
843
- }))
844
- );
845
- }
846
- /**
847
- * Get instructions describing this E2B sandbox.
848
- * Used by agents to understand the execution environment.
849
- */
850
- getInstructions(opts) {
851
- if (this._instructionsOverride === void 0) return this._getDefaultInstructions();
852
- if (typeof this._instructionsOverride === "string") return this._instructionsOverride;
853
- const defaultInstructions = this._getDefaultInstructions();
854
- return this._instructionsOverride({ defaultInstructions, requestContext: opts?.requestContext });
855
- }
856
- _getDefaultInstructions() {
857
- const mountCount = this.mounts.entries.size;
858
- const mountInfo = mountCount > 0 ? ` ${mountCount} filesystem(s) mounted via FUSE.` : "";
859
- return `Cloud sandbox.${mountInfo}`;
860
- }
861
- // ---------------------------------------------------------------------------
862
- // Mounting
863
- // ---------------------------------------------------------------------------
864
- /**
865
- * Mount a filesystem at a path in the sandbox.
866
- * Uses FUSE tools (s3fs, gcsfuse) to mount cloud storage.
867
- */
868
- async mount(filesystem, mountPath) {
869
- validateMountPath(mountPath);
870
- if (!this._sandbox) {
871
- throw new SandboxNotReadyError(this.id);
872
- }
873
- this.logger.debug(`${LOG_PREFIX} Mounting "${mountPath}"...`);
874
- const config = filesystem.getMountConfig?.();
875
- if (!config) {
876
- const error = `Filesystem "${filesystem.id}" does not provide a mount config`;
877
- this.logger.error(`${LOG_PREFIX} ${error}`);
878
- this.mounts.set(mountPath, { filesystem, state: "error", error });
879
- return { success: false, mountPath, error };
880
- }
881
- const existingMount = await this.checkExistingMount(mountPath, config);
882
- if (existingMount === "matching") {
883
- this.logger.debug(
884
- `${LOG_PREFIX} Detected existing mount for ${filesystem.provider} ("${filesystem.id}") at "${mountPath}" with correct config, skipping`
885
- );
886
- this.mounts.set(mountPath, { state: "mounted", config });
887
- return { success: true, mountPath };
888
- } else if (existingMount === "mismatched") {
889
- this.logger.debug(`${LOG_PREFIX} Config mismatch, unmounting to re-mount with new config...`);
890
- await this.unmount(mountPath);
891
- }
892
- this.logger.debug(`${LOG_PREFIX} Config type: ${config.type}`);
893
- this.mounts.set(mountPath, { filesystem, state: "mounting", config });
894
- try {
895
- const checkResult = await this._sandbox.commands.run(
896
- `[ -d "${mountPath}" ] && [ "$(ls -A "${mountPath}" 2>/dev/null)" ] && echo "non-empty" || echo "ok"`
897
- );
898
- if (checkResult.stdout.trim() === "non-empty") {
899
- 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.`;
900
- this.logger.error(`${LOG_PREFIX} ${error}`);
901
- this.mounts.set(mountPath, { filesystem, state: "error", config, error });
902
- return { success: false, mountPath, error };
903
- }
904
- } catch {
905
- }
906
- try {
907
- this.logger.debug(`${LOG_PREFIX} Creating mount directory for ${mountPath}...`);
908
- const mkdirCommand = `sudo mkdir -p "${mountPath}" && sudo chown $(id -u):$(id -g) "${mountPath}"`;
909
- this.logger.debug(`${LOG_PREFIX} Running command: ${mkdirCommand}`);
910
- const mkdirResult = await this._sandbox.commands.run(mkdirCommand);
911
- this.logger.debug(`${LOG_PREFIX} Created mount directory for mount path "${mountPath}":`, mkdirResult);
912
- } catch (mkdirError) {
913
- this.logger.debug(`${LOG_PREFIX} mkdir error for "${mountPath}":`, mkdirError);
914
- this.mounts.set(mountPath, { filesystem, state: "error", config, error: String(mkdirError) });
915
- return { success: false, mountPath, error: String(mkdirError) };
916
- }
917
- const mountCtx = {
918
- sandbox: this._sandbox,
919
- logger: this.logger
920
- };
921
- try {
922
- switch (config.type) {
923
- case "s3":
924
- this.logger.debug(`${LOG_PREFIX} Mounting S3 bucket at ${mountPath}...`);
925
- await mountS3(mountPath, config, mountCtx);
926
- this.logger.debug(`${LOG_PREFIX} Mounted S3 bucket at ${mountPath}`);
927
- break;
928
- case "gcs":
929
- this.logger.debug(`${LOG_PREFIX} Mounting GCS bucket at ${mountPath}...`);
930
- await mountGCS(mountPath, config, mountCtx);
931
- this.logger.debug(`${LOG_PREFIX} Mounted GCS bucket at ${mountPath}`);
932
- break;
933
- case "azure-blob":
934
- this.logger.debug(`${LOG_PREFIX} Mounting Azure Blob container at ${mountPath}...`);
935
- await mountAzure(mountPath, config, mountCtx);
936
- this.logger.debug(`${LOG_PREFIX} Mounted Azure Blob container at ${mountPath}`);
937
- break;
938
- default:
939
- this.mounts.set(mountPath, {
940
- filesystem,
941
- state: "unsupported",
942
- config,
943
- error: `Unsupported mount type: ${config.type}`
944
- });
945
- return {
946
- success: false,
947
- mountPath,
948
- error: `Unsupported mount type: ${config.type}`
949
- };
950
- }
951
- } catch (error) {
952
- this.logger.error(
953
- `${LOG_PREFIX} Error mounting "${filesystem.provider}" (${filesystem.id}) at "${mountPath}":`,
954
- error
955
- );
956
- this.mounts.set(mountPath, { filesystem, state: "error", config, error: String(error) });
957
- try {
958
- await this._sandbox.commands.run(`sudo rmdir "${mountPath}" 2>/dev/null || true`);
959
- this.logger.debug(`${LOG_PREFIX} Cleaned up directory after failed mount: ${mountPath}`);
960
- } catch {
961
- }
962
- return { success: false, mountPath, error: String(error) };
963
- }
964
- this.mounts.set(mountPath, { state: "mounted", config });
965
- await this.writeMarkerFile(mountPath);
966
- this.logger.debug(`${LOG_PREFIX} Mounted ${mountPath}`);
967
- return { success: true, mountPath };
968
- }
969
- /**
970
- * Unmount a filesystem from a path in the sandbox.
971
- */
972
- async unmount(mountPath) {
973
- validateMountPath(mountPath);
974
- if (!this._sandbox) {
975
- throw new SandboxNotReadyError(this.id);
976
- }
977
- this.logger.debug(`${LOG_PREFIX} Unmounting ${mountPath}...`);
978
- try {
979
- const result = await this._sandbox.commands.run(
980
- `sudo fusermount -u "${mountPath}" 2>/dev/null || sudo umount "${mountPath}"`
981
- );
982
- if (result.exitCode !== 0) {
983
- this.logger.debug(`${LOG_PREFIX} Unmount warning: ${result.stderr || result.stdout}`);
984
- }
985
- } catch (error) {
986
- this.logger.debug(`${LOG_PREFIX} Unmount error:`, error);
987
- await this._sandbox.commands.run(`sudo umount -l "${mountPath}" 2>/dev/null || true`);
988
- }
989
- this.mounts.delete(mountPath);
990
- const filename = this.mounts.markerFilename(mountPath);
991
- const markerPath = `/tmp/.mastra-mounts/${filename}`;
992
- await this._sandbox.commands.run(`rm -f "${markerPath}" 2>/dev/null || true`);
993
- const rmdirResult = await this._sandbox.commands.run(`sudo rmdir "${mountPath}" 2>&1`);
994
- if (rmdirResult.exitCode === 0) {
995
- this.logger.debug(`${LOG_PREFIX} Unmounted and removed ${mountPath}`);
996
- } else {
997
- this.logger.debug(
998
- `${LOG_PREFIX} Unmounted ${mountPath} (directory not removed: ${rmdirResult.stderr?.trim() || "not empty"})`
999
- );
1000
- }
1001
- }
1002
- /**
1003
- * Unmount all stale mounts that are not in the expected mounts list.
1004
- * Also cleans up orphaned directories and marker files from failed mount attempts.
1005
- * Call this after reconnecting to an existing sandbox to clean up old mounts.
1006
- */
1007
- async reconcileMounts(expectedMountPaths) {
1008
- if (!this._sandbox) {
1009
- throw new SandboxNotReadyError(this.id);
1010
- }
1011
- this.logger.debug(`${LOG_PREFIX} Reconciling mounts. Expected paths:`, expectedMountPaths);
1012
- const mountsResult = await this._sandbox.commands.run(
1013
- `grep -E 'fuse\\.(s3fs|gcsfuse|blobfuse2)' /proc/mounts | awk '{print $2}'`
1014
- );
1015
- const currentMounts = mountsResult.stdout.trim().split("\n").filter((p) => p.length > 0);
1016
- this.logger.debug(`${LOG_PREFIX} Current FUSE mounts in sandbox:`, currentMounts);
1017
- const markersResult = await this._sandbox.commands.run(`ls /tmp/.mastra-mounts/ 2>/dev/null || echo ""`);
1018
- const markerFiles = markersResult.stdout.trim().split("\n").filter((f) => f.length > 0 && SAFE_MARKER_NAME.test(f));
1019
- const managedMountPaths = /* @__PURE__ */ new Map();
1020
- for (const markerFile of markerFiles) {
1021
- const markerResult = await this._sandbox.commands.run(
1022
- `cat "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || echo ""`
1023
- );
1024
- const parsed = this.mounts.parseMarkerContent(markerResult.stdout.trim());
1025
- if (parsed && SAFE_MOUNT_PATH.test(parsed.path)) {
1026
- managedMountPaths.set(parsed.path, markerFile);
1027
- }
1028
- }
1029
- const staleMounts = currentMounts.filter((path) => !expectedMountPaths.includes(path));
1030
- for (const stalePath of staleMounts) {
1031
- if (managedMountPaths.has(stalePath)) {
1032
- this.logger.debug(`${LOG_PREFIX} Found stale managed FUSE mount at ${stalePath}, unmounting...`);
1033
- await this.unmount(stalePath);
1034
- } else {
1035
- this.logger.debug(`${LOG_PREFIX} Found external FUSE mount at ${stalePath}, leaving untouched`);
1036
- }
1037
- }
1038
- try {
1039
- const expectedMarkerFiles = new Set(expectedMountPaths.map((p) => this.mounts.markerFilename(p)));
1040
- const markerToPath = /* @__PURE__ */ new Map();
1041
- for (const [path, file] of managedMountPaths) {
1042
- markerToPath.set(file, path);
1043
- }
1044
- for (const markerFile of markerFiles) {
1045
- if (!expectedMarkerFiles.has(markerFile)) {
1046
- const mountPath = markerToPath.get(markerFile);
1047
- if (mountPath) {
1048
- if (!currentMounts.includes(mountPath)) {
1049
- this.logger.debug(`${LOG_PREFIX} Cleaning up orphaned marker and directory for ${mountPath}`);
1050
- await this._sandbox.commands.run(`rm -f "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || true`);
1051
- await this._sandbox.commands.run(`sudo rmdir "${mountPath}" 2>/dev/null || true`);
1052
- }
1053
- } else {
1054
- this.logger.debug(`${LOG_PREFIX} Removing malformed marker file: ${markerFile}`);
1055
- await this._sandbox.commands.run(`rm -f "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || true`);
1056
- }
1057
- }
1058
- }
1059
- } catch {
1060
- this.logger.debug(`${LOG_PREFIX} Error during orphan cleanup (non-fatal)`);
1061
- }
1062
- }
1063
- // ---------------------------------------------------------------------------
1064
- // Deprecated
1065
- // ---------------------------------------------------------------------------
1066
- /** @deprecated Use `e2b` instead. */
1067
- get instance() {
1068
- return this.e2b;
1069
- }
1070
- /** @deprecated Use `status === 'running'` instead. */
1071
- async isReady() {
1072
- return this.status === "running" && this._sandbox !== null;
1073
- }
1074
- // ---------------------------------------------------------------------------
1075
- // Internal Helpers
1076
- // ---------------------------------------------------------------------------
1077
- generateId() {
1078
- return `e2b-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1079
- }
1080
- /** Domain used to derive public sandbox hosts (self-hosted E2B or e2b.app). */
1081
- get sandboxDomain() {
1082
- return this.connectionOpts.domain ?? process.env.E2B_DOMAIN ?? "e2b.app";
1083
- }
1084
- /**
1085
- * Look up an existing sandbox with matching mastra-sandbox-id metadata
1086
- * WITHOUT connecting or resuming it. Returns its list info or null.
1087
- */
1088
- async lookupExistingSandboxInfo() {
1089
- try {
1090
- const paginator = Sandbox.list({
1091
- ...this.connectionOpts,
1092
- query: {
1093
- metadata: { "mastra-sandbox-id": this.id },
1094
- state: ["running", "paused"]
1095
- }
1096
- });
1097
- const sandboxes = await paginator.nextItems();
1098
- this.logger.debug(`${LOG_PREFIX} sandboxes:`, sandboxes);
1099
- if (sandboxes.length > 0) {
1100
- const existingSandbox = sandboxes[0];
1101
- this.logger.debug(
1102
- `${LOG_PREFIX} Found existing sandbox for ${this.id}: ${existingSandbox.sandboxId} (state: ${existingSandbox.state})`
1103
- );
1104
- return existingSandbox;
1105
- }
1106
- } catch (e) {
1107
- this.logger.debug(`${LOG_PREFIX} Error querying for existing sandbox:`, e);
1108
- }
1109
- return null;
1110
- }
1111
- /**
1112
- * Find an existing sandbox with matching mastra-sandbox-id metadata.
1113
- * Returns the connected sandbox if found, null otherwise.
1114
- * Connecting to a paused sandbox resumes it.
1115
- */
1116
- async findExistingSandbox() {
1117
- const info = await this.lookupExistingSandboxInfo();
1118
- if (!info) return null;
1119
- try {
1120
- return await Sandbox.connect(info.sandboxId, this.connectionOpts);
1121
- } catch (e) {
1122
- this.logger.debug(`${LOG_PREFIX} Error connecting to existing sandbox:`, e);
1123
- return null;
1124
- }
1125
- }
1126
- /**
1127
- * Resolve the template specification to a template ID.
1128
- *
1129
- * - String: Use as-is (template ID)
1130
- * - TemplateBuilder: Build and return the template ID
1131
- * - Function: Apply to base mountable template, then build
1132
- * - undefined: Use default mountable template (cached)
1133
- */
1134
- async resolveTemplate() {
1135
- if (this._resolvedTemplateId) {
1136
- return this._resolvedTemplateId;
1137
- }
1138
- if (!this.templateSpec) {
1139
- const { template: template2, id } = createDefaultMountableTemplate();
1140
- const exists = await Template.exists(id, this.connectionOpts);
1141
- if (exists) {
1142
- this.logger.debug(`${LOG_PREFIX} Using cached mountable template: ${id}`);
1143
- this._resolvedTemplateId = id;
1144
- return id;
1145
- }
1146
- this.logger.debug(`${LOG_PREFIX} Building default mountable template: ${id}...`);
1147
- const buildResult2 = await Template.build(template2, id, this.connectionOpts);
1148
- this._resolvedTemplateId = buildResult2.templateId;
1149
- this.logger.debug(`${LOG_PREFIX} Template built and cached: ${buildResult2.templateId}`);
1150
- return buildResult2.templateId;
1151
- }
1152
- if (typeof this.templateSpec === "string") {
1153
- this._resolvedTemplateId = this.templateSpec;
1154
- return this.templateSpec;
1155
- }
1156
- let template;
1157
- let templateName;
1158
- if (typeof this.templateSpec === "function") {
1159
- const { template: baseTemplate } = createDefaultMountableTemplate();
1160
- template = this.templateSpec(baseTemplate);
1161
- templateName = `mastra-custom-${this.id.replace(/[^a-zA-Z0-9-]/g, "-")}`;
1162
- } else {
1163
- template = this.templateSpec;
1164
- templateName = `mastra-${this.id.replace(/[^a-zA-Z0-9-]/g, "-")}`;
1165
- }
1166
- this.logger.debug(`${LOG_PREFIX} Building custom template: ${templateName}...`);
1167
- const buildResult = await Template.build(template, templateName, this.connectionOpts);
1168
- this._resolvedTemplateId = buildResult.templateId;
1169
- this.logger.debug(`${LOG_PREFIX} Template built: ${buildResult.templateId}`);
1170
- return buildResult.templateId;
1171
- }
1172
- /**
1173
- * Build the default mountable template (bypasses exists check).
1174
- */
1175
- async buildDefaultTemplate() {
1176
- const { template, id } = createDefaultMountableTemplate();
1177
- this.logger.debug(`${LOG_PREFIX} Building default mountable template: ${id}...`);
1178
- const buildResult = await Template.build(template, id, this.connectionOpts);
1179
- this._resolvedTemplateId = buildResult.templateId;
1180
- this.logger.debug(`${LOG_PREFIX} Template built: ${buildResult.templateId}`);
1181
- return buildResult.templateId;
1182
- }
1183
- /**
1184
- * Write marker file for detecting config changes on reconnect.
1185
- * Stores both the mount path and config hash in the file.
1186
- */
1187
- async writeMarkerFile(mountPath) {
1188
- if (!this._sandbox) return;
1189
- const markerContent = this.mounts.getMarkerContent(mountPath);
1190
- if (!markerContent) return;
1191
- const filename = this.mounts.markerFilename(mountPath);
1192
- const markerPath = `/tmp/.mastra-mounts/${filename}`;
1193
- try {
1194
- await this._sandbox.commands.run("mkdir -p /tmp/.mastra-mounts");
1195
- await this._sandbox.files.write(markerPath, markerContent);
1196
- } catch {
1197
- this.logger.debug(`${LOG_PREFIX} Warning: Could not write marker file at ${markerPath}`);
1198
- }
1199
- }
1200
- /**
1201
- * Check if a path is already mounted and if the config matches.
1202
- */
1203
- async checkExistingMount(mountPath, newConfig) {
1204
- if (!this._sandbox) throw new SandboxNotReadyError(this.id);
1205
- const mountCheck = await this._sandbox.commands.run(
1206
- `mountpoint -q "${mountPath}" && echo "mounted" || echo "not mounted"`
1207
- );
1208
- if (mountCheck.stdout.trim() !== "mounted") {
1209
- return "not_mounted";
1210
- }
1211
- const filename = this.mounts.markerFilename(mountPath);
1212
- const markerPath = `/tmp/.mastra-mounts/${filename}`;
1213
- try {
1214
- const markerResult = await this._sandbox.commands.run(`cat "${markerPath}" 2>/dev/null || echo ""`);
1215
- const parsed = this.mounts.parseMarkerContent(markerResult.stdout.trim());
1216
- if (!parsed) {
1217
- return "mismatched";
1218
- }
1219
- const newConfigHash = this.mounts.computeConfigHash(newConfig);
1220
- this.logger.debug(
1221
- `${LOG_PREFIX} Marker check - stored hash: "${parsed.configHash}", new config hash: "${newConfigHash}"`
1222
- );
1223
- if (parsed.path === mountPath && parsed.configHash === newConfigHash) {
1224
- return "matching";
1225
- }
1226
- } catch {
1227
- }
1228
- return "mismatched";
1229
- }
1230
- /**
1231
- * Check if an error indicates the sandbox itself is dead/gone.
1232
- * Does NOT include code execution timeouts (those are the user's code taking too long).
1233
- * Does NOT include "port is not open" - that needs sandbox kill, not reconnect.
1234
- */
1235
- isSandboxDeadError(error) {
1236
- if (!error) return false;
1237
- const errorStr = String(error);
1238
- 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");
1239
- }
1240
- /**
1241
- * Handle sandbox timeout by clearing the instance and resetting state.
1242
- *
1243
- * Bypasses the normal stop() lifecycle because the sandbox is already dead —
1244
- * we can't unmount filesystems or run cleanup commands. Instead we reset
1245
- * mount states to 'pending' so they get re-mounted when start() runs again.
1246
- */
1247
- handleSandboxTimeout() {
1248
- this._sandbox = null;
1249
- for (const [path, entry] of this.mounts.entries) {
1250
- if (entry.state === "mounted" || entry.state === "mounting") {
1251
- this.mounts.set(path, { state: "pending" });
1252
- }
1253
- }
1254
- this.status = "stopped";
1255
- }
1256
- /**
1257
- * Execute an operation with automatic retry if the sandbox is found to be dead.
1258
- *
1259
- * When the E2B sandbox times out or crashes mid-operation, this method
1260
- * resets sandbox state, restarts it, and retries the operation once.
1261
- *
1262
- * @internal Used by E2BProcessManager to handle dead sandboxes during spawn.
1263
- */
1264
- async retryOnDead(fn) {
1265
- try {
1266
- return await fn();
1267
- } catch (error) {
1268
- if (this.isSandboxDeadError(error) && !this._isRetrying) {
1269
- this.handleSandboxTimeout();
1270
- this._isRetrying = true;
1271
- try {
1272
- await this.ensureRunning();
1273
- return await fn();
1274
- } finally {
1275
- this._isRetrying = false;
1276
- }
1277
- }
1278
- throw error;
1279
- }
1280
- }
573
+ /** Allowlist for marker filenames from ls output — e.g. "mount-abc123" */
574
+ const SAFE_MARKER_NAME = /^mount-[a-z0-9]+$/;
575
+ /**
576
+ * Simplified E2B sandbox implementation.
577
+ *
578
+ * Features:
579
+ * - Single sandbox instance lifecycle
580
+ * - Supports mounting cloud filesystems (S3, GCS, R2) via FUSE
581
+ * - Automatic sandbox timeout handling with retry
582
+ *
583
+ * @example Basic usage
584
+ * ```typescript
585
+ * import { Workspace } from '@mastra/core/workspace';
586
+ * import { E2BSandbox } from '@mastra/e2b';
587
+ *
588
+ * const sandbox = new E2BSandbox({
589
+ * timeout: 60000,
590
+ * });
591
+ *
592
+ * const workspace = new Workspace({ sandbox });
593
+ * const result = await workspace.executeCode('console.log("Hello!")');
594
+ * ```
595
+ *
596
+ * @example With S3 filesystem mounting
597
+ * ```typescript
598
+ * import { Workspace } from '@mastra/core/workspace';
599
+ * import { E2BSandbox } from '@mastra/e2b';
600
+ * import { S3Filesystem } from '@mastra/s3';
601
+ *
602
+ * const workspace = new Workspace({
603
+ * mounts: {
604
+ * '/bucket': new S3Filesystem({
605
+ * bucket: 'my-bucket',
606
+ * region: 'us-east-1',
607
+ * }),
608
+ * },
609
+ * sandbox: new E2BSandbox({ timeout: 60000 }),
610
+ * });
611
+ *
612
+ * ```
613
+ */
614
+ var E2BSandbox = class E2BSandbox extends MastraSandbox {
615
+ id;
616
+ name = "E2BSandbox";
617
+ provider = "e2b";
618
+ status = "pending";
619
+ /**
620
+ * Networking capability: public HTTPS URLs for sandbox ports.
621
+ * E2B exposes every port via `getHost(port)` — no upfront declaration needed.
622
+ *
623
+ * When not attached in this process, the URL is resolved by looking up the
624
+ * existing sandbox by identity (without resuming it) and deriving the host
625
+ * (`{port}-{sandboxId}.{domain}`), so other processes can resolve
626
+ * deployments without waking a paused sandbox.
627
+ */
628
+ networking = { getPortUrl: async (port) => {
629
+ try {
630
+ if (this._sandbox) return `https://${this._sandbox.getHost(port)}`;
631
+ const info = await this.lookupExistingSandboxInfo();
632
+ if (!info) return null;
633
+ return `https://${port}-${info.sandboxId}.${this.sandboxDomain}`;
634
+ } catch {
635
+ return null;
636
+ }
637
+ } };
638
+ _sandbox = null;
639
+ _createdAt = null;
640
+ _isRetrying = false;
641
+ timeout;
642
+ templateSpec;
643
+ env;
644
+ metadata;
645
+ network;
646
+ connectionOpts;
647
+ _instructionsOverride;
648
+ _constructorOptions;
649
+ /** Resolved template ID after building (if needed) */
650
+ _resolvedTemplateId;
651
+ /** Promise for template preparation (started in constructor) */
652
+ _templatePreparePromise;
653
+ constructor(options = {}) {
654
+ super({
655
+ ...options,
656
+ name: "E2BSandbox",
657
+ processes: new E2BProcessManager({ env: options.env ?? {} })
658
+ });
659
+ this.id = options.id ?? this.generateId();
660
+ this.timeout = options.timeout ?? 3e5;
661
+ this.templateSpec = options.template;
662
+ this.env = options.env ?? {};
663
+ this.metadata = options.metadata ?? {};
664
+ this.network = options.network;
665
+ this.connectionOpts = {
666
+ ...options.domain && { domain: options.domain },
667
+ ...options.apiUrl && { apiUrl: options.apiUrl },
668
+ ...options.apiKey && { apiKey: options.apiKey },
669
+ ...options.accessToken && { accessToken: options.accessToken }
670
+ };
671
+ this._instructionsOverride = options.instructions;
672
+ this._constructorOptions = { ...options };
673
+ this._templatePreparePromise = this.resolveTemplate().catch((err) => {
674
+ this.logger.debug(`${LOG_PREFIX} Template preparation error (will retry on start):`, err);
675
+ return "";
676
+ });
677
+ }
678
+ /**
679
+ * Construct a sibling `E2BSandbox` that inherits this sandbox's
680
+ * configuration (credentials, template, network, metadata, instructions)
681
+ * with per-instance overrides.
682
+ *
683
+ * Performs no I/O — the sandbox clone provisions (or reconnects to an
684
+ * existing E2B sandbox with the same logical `id`) on its own `start()`.
685
+ * Use it when one configured sandbox acts as the template for a fleet of
686
+ * independent sandboxes (e.g. one per project).
687
+ *
688
+ * `options.idleTimeoutMinutes` maps to the E2B sandbox `timeout` (ms);
689
+ * `options.sandboxId` is ignored because E2B reconnects by logical `id`.
690
+ */
691
+ clone(options = {}) {
692
+ const { id: _id, ...base } = this._constructorOptions;
693
+ return new E2BSandbox({
694
+ ...base,
695
+ ...options.id !== void 0 && { id: options.id },
696
+ ...options.env !== void 0 && { env: options.env },
697
+ ...options.idleTimeoutMinutes !== void 0 && { timeout: options.idleTimeoutMinutes * 6e4 }
698
+ });
699
+ }
700
+ /**
701
+ * Get the underlying E2B Sandbox instance for direct access to E2B APIs.
702
+ *
703
+ * Use this when you need to access E2B features not exposed through the
704
+ * WorkspaceSandbox interface (e.g., files API, ports, etc.).
705
+ *
706
+ * @throws {SandboxNotReadyError} If the sandbox has not been started
707
+ *
708
+ * @example Direct file operations
709
+ * ```typescript
710
+ * const e2b = sandbox.e2b;
711
+ * await e2b.files.write('/tmp/test.txt', 'Hello');
712
+ * const content = await e2b.files.read('/tmp/test.txt');
713
+ * const files = await e2b.files.list('/tmp');
714
+ * ```
715
+ *
716
+ * @example Access ports
717
+ * ```typescript
718
+ * const e2b = sandbox.e2b;
719
+ * const url = e2b.getHost(3000);
720
+ * ```
721
+ */
722
+ get e2b() {
723
+ if (!this._sandbox) throw new SandboxNotReadyError(this.id);
724
+ return this._sandbox;
725
+ }
726
+ /**
727
+ * Start the E2B sandbox.
728
+ * Handles template preparation, existing sandbox reconnection, and new sandbox creation.
729
+ *
730
+ * Status management and mount processing are handled by the base class.
731
+ */
732
+ async start() {
733
+ if (this._sandbox) return;
734
+ const [existingSandbox, templateId] = await Promise.all([this.findExistingSandbox(), this._templatePreparePromise || this.resolveTemplate()]);
735
+ if (existingSandbox) {
736
+ this._sandbox = existingSandbox;
737
+ this._createdAt = /* @__PURE__ */ new Date();
738
+ this.logger.debug(`${LOG_PREFIX} Reconnected to existing sandbox for: ${this.id}`);
739
+ const expectedPaths = Array.from(this.mounts.entries.keys());
740
+ this.logger.debug(`${LOG_PREFIX} Running mount reconciliation...`);
741
+ await this.reconcileMounts(expectedPaths);
742
+ this.logger.debug(`${LOG_PREFIX} Mount reconciliation complete`);
743
+ return;
744
+ }
745
+ let resolvedTemplateId = templateId;
746
+ if (!resolvedTemplateId) {
747
+ this.logger.debug(`${LOG_PREFIX} Template preparation failed earlier, retrying...`);
748
+ resolvedTemplateId = await this.resolveTemplate();
749
+ }
750
+ this.logger.debug(`${LOG_PREFIX} Creating new sandbox for: ${this.id} with template: ${resolvedTemplateId}`);
751
+ try {
752
+ this._sandbox = await Sandbox.create(resolvedTemplateId, {
753
+ ...this.connectionOpts,
754
+ lifecycle: { onTimeout: "pause" },
755
+ metadata: {
756
+ ...this.metadata,
757
+ "mastra-sandbox-id": this.id
758
+ },
759
+ ...this.network && { network: this.network },
760
+ timeoutMs: this.timeout
761
+ });
762
+ } catch (createError) {
763
+ const errorStr = String(createError);
764
+ if (errorStr.includes("404") && errorStr.includes("not found") && !this.templateSpec) {
765
+ this.logger.debug(`${LOG_PREFIX} Template not found, rebuilding: ${templateId}`);
766
+ this._resolvedTemplateId = void 0;
767
+ const rebuiltTemplateId = await this.buildDefaultTemplate();
768
+ this.logger.debug(`${LOG_PREFIX} Retrying sandbox creation with rebuilt template: ${rebuiltTemplateId}`);
769
+ this._sandbox = await Sandbox.create(rebuiltTemplateId, {
770
+ ...this.connectionOpts,
771
+ lifecycle: { onTimeout: "pause" },
772
+ metadata: {
773
+ ...this.metadata,
774
+ "mastra-sandbox-id": this.id
775
+ },
776
+ ...this.network && { network: this.network },
777
+ timeoutMs: this.timeout
778
+ });
779
+ } else throw createError;
780
+ }
781
+ this.logger.debug(`${LOG_PREFIX} Created sandbox ${this._sandbox.sandboxId} for logical ID: ${this.id}`);
782
+ this._createdAt = /* @__PURE__ */ new Date();
783
+ }
784
+ /**
785
+ * Stop the E2B sandbox by pausing it (snapshot-stop).
786
+ *
787
+ * Pausing freezes the whole VM — filesystem, memory, and running processes —
788
+ * and stops billing immediately. The next `start()` reconnects and resumes it,
789
+ * with background processes still running. Filesystem mounts are unmounted
790
+ * first (FUSE mounts don't survive pause) and reconciled again on start.
791
+ *
792
+ * Status management is handled by the base class.
793
+ */
794
+ async stop() {
795
+ for (const mountPath of [...this.mounts.entries.keys()]) try {
796
+ await this.unmount(mountPath);
797
+ } catch {}
798
+ if (this._sandbox) {
799
+ await this._sandbox.pause();
800
+ this.logger.debug(`${LOG_PREFIX} Paused sandbox ${this._sandbox.sandboxId} for: ${this.id}`);
801
+ } else {
802
+ const info = await this.lookupExistingSandboxInfo();
803
+ if (info?.state === "running") {
804
+ await Sandbox.pause(info.sandboxId, this.connectionOpts);
805
+ this.logger.debug(`${LOG_PREFIX} Paused detached sandbox ${info.sandboxId} for: ${this.id}`);
806
+ }
807
+ }
808
+ this._sandbox = null;
809
+ }
810
+ /**
811
+ * Destroy the E2B sandbox and clean up all resources.
812
+ * Unmounts filesystems, kills the sandbox, and clears mount state.
813
+ * Status management is handled by the base class.
814
+ */
815
+ async destroy() {
816
+ if (this._sandbox) {
817
+ try {
818
+ const procs = await this.processes.list();
819
+ await Promise.all(procs.map((p) => this.processes.kill(p.pid)));
820
+ } catch {}
821
+ for (const mountPath of [...this.mounts.entries.keys()]) try {
822
+ await this.unmount(mountPath);
823
+ } catch {}
824
+ await this._sandbox.kill();
825
+ this._sandbox = null;
826
+ } else {
827
+ const info = await this.lookupExistingSandboxInfo();
828
+ if (info) {
829
+ await Sandbox.kill(info.sandboxId, this.connectionOpts);
830
+ this.logger.debug(`${LOG_PREFIX} Killed detached sandbox ${info.sandboxId} for: ${this.id}`);
831
+ }
832
+ }
833
+ this.mounts.clear();
834
+ }
835
+ async getInfo() {
836
+ return {
837
+ id: this.id,
838
+ name: this.name,
839
+ provider: this.provider,
840
+ status: this.status,
841
+ createdAt: this._createdAt ?? /* @__PURE__ */ new Date(),
842
+ mounts: Array.from(this.mounts.entries).map(([path, entry]) => ({
843
+ path,
844
+ filesystem: entry.filesystem?.provider ?? entry.config?.type ?? "unknown"
845
+ })),
846
+ metadata: { ...this.metadata }
847
+ };
848
+ }
849
+ /**
850
+ * Bulk-write files into the sandbox filesystem via the SDK's native upload.
851
+ */
852
+ async writeFiles(files) {
853
+ await this.ensureRunning();
854
+ await this.e2b.files.write(files.map((f) => ({
855
+ path: f.path,
856
+ data: typeof f.content === "string" ? f.content : new Blob([new Uint8Array(f.content)])
857
+ })));
858
+ }
859
+ /**
860
+ * Get instructions describing this E2B sandbox.
861
+ * Used by agents to understand the execution environment.
862
+ */
863
+ getInstructions(opts) {
864
+ if (this._instructionsOverride === void 0) return this._getDefaultInstructions();
865
+ if (typeof this._instructionsOverride === "string") return this._instructionsOverride;
866
+ const defaultInstructions = this._getDefaultInstructions();
867
+ return this._instructionsOverride({
868
+ defaultInstructions,
869
+ requestContext: opts?.requestContext
870
+ });
871
+ }
872
+ _getDefaultInstructions() {
873
+ const mountCount = this.mounts.entries.size;
874
+ return `Cloud sandbox.${mountCount > 0 ? ` ${mountCount} filesystem(s) mounted via FUSE.` : ""}`;
875
+ }
876
+ /**
877
+ * Mount a filesystem at a path in the sandbox.
878
+ * Uses FUSE tools (s3fs, gcsfuse) to mount cloud storage.
879
+ */
880
+ async mount(filesystem, mountPath) {
881
+ validateMountPath(mountPath);
882
+ if (!this._sandbox) throw new SandboxNotReadyError(this.id);
883
+ this.logger.debug(`${LOG_PREFIX} Mounting "${mountPath}"...`);
884
+ const config = filesystem.getMountConfig?.();
885
+ if (!config) {
886
+ const error = `Filesystem "${filesystem.id}" does not provide a mount config`;
887
+ this.logger.error(`${LOG_PREFIX} ${error}`);
888
+ this.mounts.set(mountPath, {
889
+ filesystem,
890
+ state: "error",
891
+ error
892
+ });
893
+ return {
894
+ success: false,
895
+ mountPath,
896
+ error
897
+ };
898
+ }
899
+ const existingMount = await this.checkExistingMount(mountPath, config);
900
+ if (existingMount === "matching") {
901
+ this.logger.debug(`${LOG_PREFIX} Detected existing mount for ${filesystem.provider} ("${filesystem.id}") at "${mountPath}" with correct config, skipping`);
902
+ this.mounts.set(mountPath, {
903
+ state: "mounted",
904
+ config
905
+ });
906
+ return {
907
+ success: true,
908
+ mountPath
909
+ };
910
+ } else if (existingMount === "mismatched") {
911
+ this.logger.debug(`${LOG_PREFIX} Config mismatch, unmounting to re-mount with new config...`);
912
+ await this.unmount(mountPath);
913
+ }
914
+ this.logger.debug(`${LOG_PREFIX} Config type: ${config.type}`);
915
+ this.mounts.set(mountPath, {
916
+ filesystem,
917
+ state: "mounting",
918
+ config
919
+ });
920
+ try {
921
+ if ((await this._sandbox.commands.run(`[ -d "${mountPath}" ] && [ "$(ls -A "${mountPath}" 2>/dev/null)" ] && echo "non-empty" || echo "ok"`)).stdout.trim() === "non-empty") {
922
+ 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.`;
923
+ this.logger.error(`${LOG_PREFIX} ${error}`);
924
+ this.mounts.set(mountPath, {
925
+ filesystem,
926
+ state: "error",
927
+ config,
928
+ error
929
+ });
930
+ return {
931
+ success: false,
932
+ mountPath,
933
+ error
934
+ };
935
+ }
936
+ } catch {}
937
+ try {
938
+ this.logger.debug(`${LOG_PREFIX} Creating mount directory for ${mountPath}...`);
939
+ const mkdirCommand = `sudo mkdir -p "${mountPath}" && sudo chown $(id -u):$(id -g) "${mountPath}"`;
940
+ this.logger.debug(`${LOG_PREFIX} Running command: ${mkdirCommand}`);
941
+ const mkdirResult = await this._sandbox.commands.run(mkdirCommand);
942
+ this.logger.debug(`${LOG_PREFIX} Created mount directory for mount path "${mountPath}":`, mkdirResult);
943
+ } catch (mkdirError) {
944
+ this.logger.debug(`${LOG_PREFIX} mkdir error for "${mountPath}":`, mkdirError);
945
+ this.mounts.set(mountPath, {
946
+ filesystem,
947
+ state: "error",
948
+ config,
949
+ error: String(mkdirError)
950
+ });
951
+ return {
952
+ success: false,
953
+ mountPath,
954
+ error: String(mkdirError)
955
+ };
956
+ }
957
+ const mountCtx = {
958
+ sandbox: this._sandbox,
959
+ logger: this.logger
960
+ };
961
+ try {
962
+ switch (config.type) {
963
+ case "s3":
964
+ this.logger.debug(`${LOG_PREFIX} Mounting S3 bucket at ${mountPath}...`);
965
+ await mountS3(mountPath, config, mountCtx);
966
+ this.logger.debug(`${LOG_PREFIX} Mounted S3 bucket at ${mountPath}`);
967
+ break;
968
+ case "gcs":
969
+ this.logger.debug(`${LOG_PREFIX} Mounting GCS bucket at ${mountPath}...`);
970
+ await mountGCS(mountPath, config, mountCtx);
971
+ this.logger.debug(`${LOG_PREFIX} Mounted GCS bucket at ${mountPath}`);
972
+ break;
973
+ case "azure-blob":
974
+ this.logger.debug(`${LOG_PREFIX} Mounting Azure Blob container at ${mountPath}...`);
975
+ await mountAzure(mountPath, config, mountCtx);
976
+ this.logger.debug(`${LOG_PREFIX} Mounted Azure Blob container at ${mountPath}`);
977
+ break;
978
+ default:
979
+ this.mounts.set(mountPath, {
980
+ filesystem,
981
+ state: "unsupported",
982
+ config,
983
+ error: `Unsupported mount type: ${config.type}`
984
+ });
985
+ return {
986
+ success: false,
987
+ mountPath,
988
+ error: `Unsupported mount type: ${config.type}`
989
+ };
990
+ }
991
+ } catch (error) {
992
+ this.logger.error(`${LOG_PREFIX} Error mounting "${filesystem.provider}" (${filesystem.id}) at "${mountPath}":`, error);
993
+ this.mounts.set(mountPath, {
994
+ filesystem,
995
+ state: "error",
996
+ config,
997
+ error: String(error)
998
+ });
999
+ try {
1000
+ await this._sandbox.commands.run(`sudo rmdir "${mountPath}" 2>/dev/null || true`);
1001
+ this.logger.debug(`${LOG_PREFIX} Cleaned up directory after failed mount: ${mountPath}`);
1002
+ } catch {}
1003
+ return {
1004
+ success: false,
1005
+ mountPath,
1006
+ error: String(error)
1007
+ };
1008
+ }
1009
+ this.mounts.set(mountPath, {
1010
+ state: "mounted",
1011
+ config
1012
+ });
1013
+ await this.writeMarkerFile(mountPath);
1014
+ this.logger.debug(`${LOG_PREFIX} Mounted ${mountPath}`);
1015
+ return {
1016
+ success: true,
1017
+ mountPath
1018
+ };
1019
+ }
1020
+ /**
1021
+ * Unmount a filesystem from a path in the sandbox.
1022
+ */
1023
+ async unmount(mountPath) {
1024
+ validateMountPath(mountPath);
1025
+ if (!this._sandbox) throw new SandboxNotReadyError(this.id);
1026
+ this.logger.debug(`${LOG_PREFIX} Unmounting ${mountPath}...`);
1027
+ try {
1028
+ const result = await this._sandbox.commands.run(`sudo fusermount -u "${mountPath}" 2>/dev/null || sudo umount "${mountPath}"`);
1029
+ if (result.exitCode !== 0) this.logger.debug(`${LOG_PREFIX} Unmount warning: ${result.stderr || result.stdout}`);
1030
+ } catch (error) {
1031
+ this.logger.debug(`${LOG_PREFIX} Unmount error:`, error);
1032
+ await this._sandbox.commands.run(`sudo umount -l "${mountPath}" 2>/dev/null || true`);
1033
+ }
1034
+ this.mounts.delete(mountPath);
1035
+ const markerPath = `/tmp/.mastra-mounts/${this.mounts.markerFilename(mountPath)}`;
1036
+ await this._sandbox.commands.run(`rm -f "${markerPath}" 2>/dev/null || true`);
1037
+ const rmdirResult = await this._sandbox.commands.run(`sudo rmdir "${mountPath}" 2>&1`);
1038
+ if (rmdirResult.exitCode === 0) this.logger.debug(`${LOG_PREFIX} Unmounted and removed ${mountPath}`);
1039
+ else this.logger.debug(`${LOG_PREFIX} Unmounted ${mountPath} (directory not removed: ${rmdirResult.stderr?.trim() || "not empty"})`);
1040
+ }
1041
+ /**
1042
+ * Unmount all stale mounts that are not in the expected mounts list.
1043
+ * Also cleans up orphaned directories and marker files from failed mount attempts.
1044
+ * Call this after reconnecting to an existing sandbox to clean up old mounts.
1045
+ */
1046
+ async reconcileMounts(expectedMountPaths) {
1047
+ if (!this._sandbox) throw new SandboxNotReadyError(this.id);
1048
+ this.logger.debug(`${LOG_PREFIX} Reconciling mounts. Expected paths:`, expectedMountPaths);
1049
+ const currentMounts = (await this._sandbox.commands.run(`grep -E 'fuse\\.(s3fs|gcsfuse|blobfuse2)' /proc/mounts | awk '{print $2}'`)).stdout.trim().split("\n").filter((p) => p.length > 0);
1050
+ this.logger.debug(`${LOG_PREFIX} Current FUSE mounts in sandbox:`, currentMounts);
1051
+ const markerFiles = (await this._sandbox.commands.run(`ls /tmp/.mastra-mounts/ 2>/dev/null || echo ""`)).stdout.trim().split("\n").filter((f) => f.length > 0 && SAFE_MARKER_NAME.test(f));
1052
+ const managedMountPaths = /* @__PURE__ */ new Map();
1053
+ for (const markerFile of markerFiles) {
1054
+ const markerResult = await this._sandbox.commands.run(`cat "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || echo ""`);
1055
+ const parsed = this.mounts.parseMarkerContent(markerResult.stdout.trim());
1056
+ if (parsed && SAFE_MOUNT_PATH.test(parsed.path)) managedMountPaths.set(parsed.path, markerFile);
1057
+ }
1058
+ const staleMounts = currentMounts.filter((path) => !expectedMountPaths.includes(path));
1059
+ for (const stalePath of staleMounts) if (managedMountPaths.has(stalePath)) {
1060
+ this.logger.debug(`${LOG_PREFIX} Found stale managed FUSE mount at ${stalePath}, unmounting...`);
1061
+ await this.unmount(stalePath);
1062
+ } else this.logger.debug(`${LOG_PREFIX} Found external FUSE mount at ${stalePath}, leaving untouched`);
1063
+ try {
1064
+ const expectedMarkerFiles = new Set(expectedMountPaths.map((p) => this.mounts.markerFilename(p)));
1065
+ const markerToPath = /* @__PURE__ */ new Map();
1066
+ for (const [path, file] of managedMountPaths) markerToPath.set(file, path);
1067
+ for (const markerFile of markerFiles) if (!expectedMarkerFiles.has(markerFile)) {
1068
+ const mountPath = markerToPath.get(markerFile);
1069
+ if (mountPath) {
1070
+ if (!currentMounts.includes(mountPath)) {
1071
+ this.logger.debug(`${LOG_PREFIX} Cleaning up orphaned marker and directory for ${mountPath}`);
1072
+ await this._sandbox.commands.run(`rm -f "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || true`);
1073
+ await this._sandbox.commands.run(`sudo rmdir "${mountPath}" 2>/dev/null || true`);
1074
+ }
1075
+ } else {
1076
+ this.logger.debug(`${LOG_PREFIX} Removing malformed marker file: ${markerFile}`);
1077
+ await this._sandbox.commands.run(`rm -f "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || true`);
1078
+ }
1079
+ }
1080
+ } catch {
1081
+ this.logger.debug(`${LOG_PREFIX} Error during orphan cleanup (non-fatal)`);
1082
+ }
1083
+ }
1084
+ /** @deprecated Use `e2b` instead. */
1085
+ get instance() {
1086
+ return this.e2b;
1087
+ }
1088
+ /** @deprecated Use `status === 'running'` instead. */
1089
+ async isReady() {
1090
+ return this.status === "running" && this._sandbox !== null;
1091
+ }
1092
+ generateId() {
1093
+ return `e2b-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1094
+ }
1095
+ /** Domain used to derive public sandbox hosts (self-hosted E2B or e2b.app). */
1096
+ get sandboxDomain() {
1097
+ return this.connectionOpts.domain ?? process.env.E2B_DOMAIN ?? "e2b.app";
1098
+ }
1099
+ /**
1100
+ * Look up an existing sandbox with matching mastra-sandbox-id metadata
1101
+ * WITHOUT connecting or resuming it. Returns its list info or null.
1102
+ */
1103
+ async lookupExistingSandboxInfo() {
1104
+ try {
1105
+ const sandboxes = await Sandbox.list({
1106
+ ...this.connectionOpts,
1107
+ query: {
1108
+ metadata: { "mastra-sandbox-id": this.id },
1109
+ state: ["running", "paused"]
1110
+ }
1111
+ }).nextItems();
1112
+ this.logger.debug(`${LOG_PREFIX} sandboxes:`, sandboxes);
1113
+ if (sandboxes.length > 0) {
1114
+ const existingSandbox = sandboxes[0];
1115
+ this.logger.debug(`${LOG_PREFIX} Found existing sandbox for ${this.id}: ${existingSandbox.sandboxId} (state: ${existingSandbox.state})`);
1116
+ return existingSandbox;
1117
+ }
1118
+ } catch (e) {
1119
+ this.logger.debug(`${LOG_PREFIX} Error querying for existing sandbox:`, e);
1120
+ }
1121
+ return null;
1122
+ }
1123
+ /**
1124
+ * Find an existing sandbox with matching mastra-sandbox-id metadata.
1125
+ * Returns the connected sandbox if found, null otherwise.
1126
+ * Connecting to a paused sandbox resumes it.
1127
+ */
1128
+ async findExistingSandbox() {
1129
+ const info = await this.lookupExistingSandboxInfo();
1130
+ if (!info) return null;
1131
+ try {
1132
+ return await Sandbox.connect(info.sandboxId, this.connectionOpts);
1133
+ } catch (e) {
1134
+ this.logger.debug(`${LOG_PREFIX} Error connecting to existing sandbox:`, e);
1135
+ return null;
1136
+ }
1137
+ }
1138
+ /**
1139
+ * Resolve the template specification to a template ID.
1140
+ *
1141
+ * - String: Use as-is (template ID)
1142
+ * - TemplateBuilder: Build and return the template ID
1143
+ * - Function: Apply to base mountable template, then build
1144
+ * - undefined: Use default mountable template (cached)
1145
+ */
1146
+ async resolveTemplate() {
1147
+ if (this._resolvedTemplateId) return this._resolvedTemplateId;
1148
+ if (!this.templateSpec) {
1149
+ const { template, id } = createDefaultMountableTemplate();
1150
+ if (await Template.exists(id, this.connectionOpts)) {
1151
+ this.logger.debug(`${LOG_PREFIX} Using cached mountable template: ${id}`);
1152
+ this._resolvedTemplateId = id;
1153
+ return id;
1154
+ }
1155
+ this.logger.debug(`${LOG_PREFIX} Building default mountable template: ${id}...`);
1156
+ const buildResult = await Template.build(template, id, this.connectionOpts);
1157
+ this._resolvedTemplateId = buildResult.templateId;
1158
+ this.logger.debug(`${LOG_PREFIX} Template built and cached: ${buildResult.templateId}`);
1159
+ return buildResult.templateId;
1160
+ }
1161
+ if (typeof this.templateSpec === "string") {
1162
+ this._resolvedTemplateId = this.templateSpec;
1163
+ return this.templateSpec;
1164
+ }
1165
+ let template;
1166
+ let templateName;
1167
+ if (typeof this.templateSpec === "function") {
1168
+ const { template: baseTemplate } = createDefaultMountableTemplate();
1169
+ template = this.templateSpec(baseTemplate);
1170
+ templateName = `mastra-custom-${this.id.replace(/[^a-zA-Z0-9-]/g, "-")}`;
1171
+ } else {
1172
+ template = this.templateSpec;
1173
+ templateName = `mastra-${this.id.replace(/[^a-zA-Z0-9-]/g, "-")}`;
1174
+ }
1175
+ this.logger.debug(`${LOG_PREFIX} Building custom template: ${templateName}...`);
1176
+ const buildResult = await Template.build(template, templateName, this.connectionOpts);
1177
+ this._resolvedTemplateId = buildResult.templateId;
1178
+ this.logger.debug(`${LOG_PREFIX} Template built: ${buildResult.templateId}`);
1179
+ return buildResult.templateId;
1180
+ }
1181
+ /**
1182
+ * Build the default mountable template (bypasses exists check).
1183
+ */
1184
+ async buildDefaultTemplate() {
1185
+ const { template, id } = createDefaultMountableTemplate();
1186
+ this.logger.debug(`${LOG_PREFIX} Building default mountable template: ${id}...`);
1187
+ const buildResult = await Template.build(template, id, this.connectionOpts);
1188
+ this._resolvedTemplateId = buildResult.templateId;
1189
+ this.logger.debug(`${LOG_PREFIX} Template built: ${buildResult.templateId}`);
1190
+ return buildResult.templateId;
1191
+ }
1192
+ /**
1193
+ * Write marker file for detecting config changes on reconnect.
1194
+ * Stores both the mount path and config hash in the file.
1195
+ */
1196
+ async writeMarkerFile(mountPath) {
1197
+ if (!this._sandbox) return;
1198
+ const markerContent = this.mounts.getMarkerContent(mountPath);
1199
+ if (!markerContent) return;
1200
+ const markerPath = `/tmp/.mastra-mounts/${this.mounts.markerFilename(mountPath)}`;
1201
+ try {
1202
+ await this._sandbox.commands.run("mkdir -p /tmp/.mastra-mounts");
1203
+ await this._sandbox.files.write(markerPath, markerContent);
1204
+ } catch {
1205
+ this.logger.debug(`${LOG_PREFIX} Warning: Could not write marker file at ${markerPath}`);
1206
+ }
1207
+ }
1208
+ /**
1209
+ * Check if a path is already mounted and if the config matches.
1210
+ */
1211
+ async checkExistingMount(mountPath, newConfig) {
1212
+ if (!this._sandbox) throw new SandboxNotReadyError(this.id);
1213
+ if ((await this._sandbox.commands.run(`mountpoint -q "${mountPath}" && echo "mounted" || echo "not mounted"`)).stdout.trim() !== "mounted") return "not_mounted";
1214
+ const markerPath = `/tmp/.mastra-mounts/${this.mounts.markerFilename(mountPath)}`;
1215
+ try {
1216
+ const markerResult = await this._sandbox.commands.run(`cat "${markerPath}" 2>/dev/null || echo ""`);
1217
+ const parsed = this.mounts.parseMarkerContent(markerResult.stdout.trim());
1218
+ if (!parsed) return "mismatched";
1219
+ const newConfigHash = this.mounts.computeConfigHash(newConfig);
1220
+ this.logger.debug(`${LOG_PREFIX} Marker check - stored hash: "${parsed.configHash}", new config hash: "${newConfigHash}"`);
1221
+ if (parsed.path === mountPath && parsed.configHash === newConfigHash) return "matching";
1222
+ } catch {}
1223
+ return "mismatched";
1224
+ }
1225
+ /**
1226
+ * Check if an error indicates the sandbox itself is dead/gone.
1227
+ * Does NOT include code execution timeouts (those are the user's code taking too long).
1228
+ * Does NOT include "port is not open" - that needs sandbox kill, not reconnect.
1229
+ */
1230
+ isSandboxDeadError(error) {
1231
+ if (!error) return false;
1232
+ const errorStr = String(error);
1233
+ 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");
1234
+ }
1235
+ /**
1236
+ * Handle sandbox timeout by clearing the instance and resetting state.
1237
+ *
1238
+ * Bypasses the normal stop() lifecycle because the sandbox is already dead —
1239
+ * we can't unmount filesystems or run cleanup commands. Instead we reset
1240
+ * mount states to 'pending' so they get re-mounted when start() runs again.
1241
+ */
1242
+ handleSandboxTimeout() {
1243
+ this._sandbox = null;
1244
+ for (const [path, entry] of this.mounts.entries) if (entry.state === "mounted" || entry.state === "mounting") this.mounts.set(path, { state: "pending" });
1245
+ this.status = "stopped";
1246
+ }
1247
+ /**
1248
+ * Execute an operation with automatic retry if the sandbox is found to be dead.
1249
+ *
1250
+ * When the E2B sandbox times out or crashes mid-operation, this method
1251
+ * resets sandbox state, restarts it, and retries the operation once.
1252
+ *
1253
+ * @internal Used by E2BProcessManager to handle dead sandboxes during spawn.
1254
+ */
1255
+ async retryOnDead(fn) {
1256
+ try {
1257
+ return await fn();
1258
+ } catch (error) {
1259
+ if (this.isSandboxDeadError(error) && !this._isRetrying) {
1260
+ this.handleSandboxTimeout();
1261
+ this._isRetrying = true;
1262
+ try {
1263
+ await this.ensureRunning();
1264
+ return await fn();
1265
+ } finally {
1266
+ this._isRetrying = false;
1267
+ }
1268
+ }
1269
+ throw error;
1270
+ }
1271
+ }
1281
1272
  };
1282
-
1283
- // src/provider.ts
1284
- var e2bSandboxProvider = {
1285
- id: "e2b",
1286
- name: "E2B Sandbox",
1287
- description: "Cloud sandbox powered by E2B",
1288
- configSchema: {
1289
- type: "object",
1290
- properties: {
1291
- template: { type: "string", description: "Sandbox template ID" },
1292
- timeout: { type: "number", description: "Execution timeout in milliseconds", default: 3e5 },
1293
- env: {
1294
- type: "object",
1295
- description: "Environment variables",
1296
- additionalProperties: { type: "string" }
1297
- },
1298
- metadata: {
1299
- type: "object",
1300
- description: "Custom metadata",
1301
- additionalProperties: true
1302
- },
1303
- domain: { type: "string", description: "Domain for self-hosted E2B" },
1304
- apiUrl: { type: "string", description: "API URL for self-hosted E2B" },
1305
- apiKey: { type: "string", description: "E2B API key" },
1306
- accessToken: { type: "string", description: "E2B access token" }
1307
- }
1308
- },
1309
- createSandbox: (config) => new E2BSandbox(config)
1273
+ //#endregion
1274
+ //#region src/provider.ts
1275
+ const e2bSandboxProvider = {
1276
+ id: "e2b",
1277
+ name: "E2B Sandbox",
1278
+ description: "Cloud sandbox powered by E2B",
1279
+ configSchema: {
1280
+ type: "object",
1281
+ properties: {
1282
+ template: {
1283
+ type: "string",
1284
+ description: "Sandbox template ID"
1285
+ },
1286
+ timeout: {
1287
+ type: "number",
1288
+ description: "Execution timeout in milliseconds",
1289
+ default: 3e5
1290
+ },
1291
+ env: {
1292
+ type: "object",
1293
+ description: "Environment variables",
1294
+ additionalProperties: { type: "string" }
1295
+ },
1296
+ metadata: {
1297
+ type: "object",
1298
+ description: "Custom metadata",
1299
+ additionalProperties: true
1300
+ },
1301
+ domain: {
1302
+ type: "string",
1303
+ description: "Domain for self-hosted E2B"
1304
+ },
1305
+ apiUrl: {
1306
+ type: "string",
1307
+ description: "API URL for self-hosted E2B"
1308
+ },
1309
+ apiKey: {
1310
+ type: "string",
1311
+ description: "E2B API key"
1312
+ },
1313
+ accessToken: {
1314
+ type: "string",
1315
+ description: "E2B access token"
1316
+ }
1317
+ }
1318
+ },
1319
+ createSandbox: (config) => new E2BSandbox(config)
1310
1320
  };
1311
- var SANDBOX_TMP = "/home/user/mastra-code-mode";
1312
- function sanitize(id) {
1313
- const cleaned = id.replace(/[^A-Za-z0-9_$]/g, "_");
1314
- return /^[A-Za-z_$]/.test(cleaned) ? cleaned : `_${cleaned}`;
1315
- }
1321
+ //#endregion
1322
+ //#region src/code-mode/transport.ts
1323
+ /**
1324
+ * Code Mode — E2B transport
1325
+ *
1326
+ * The default {@link StdioCodeModeTransport} in `@mastra/core` writes the
1327
+ * runner/program files to the *host* tmpdir and spawns `node <hostPath>`. That
1328
+ * only works when the sandbox shares the host filesystem (e.g. `LocalSandbox`).
1329
+ * E2B runs the program in a remote micro-VM with its own filesystem, so the
1330
+ * host paths don't exist there and `node` exits immediately.
1331
+ *
1332
+ * `E2BCodeModeTransport` writes the runner/program *into* the sandbox via the
1333
+ * E2B files API and runs plain `node <runnerPath>` inside the VM. TypeScript is
1334
+ * stripped on the host with esbuild before upload, so it doesn't depend on the
1335
+ * sandbox's Node version (the core transport relies on
1336
+ * `node --experimental-strip-types`, which needs Node >= 22.6).
1337
+ *
1338
+ * The RPC frame protocol (host <-> runner) is unchanged: it reuses
1339
+ * `buildProgramModule`, `buildRunner`, and `FRAME_PREFIX` from
1340
+ * `@mastra/core/tools`.
1341
+ */
1342
+ /** Base directory inside the E2B sandbox where Code Mode programs are written. */
1343
+ const SANDBOX_TMP = "/home/user/mastra-code-mode";
1344
+ /**
1345
+ * Code Mode transport for {@link E2BSandbox}.
1346
+ *
1347
+ * Writes the generated program and runner into the sandbox filesystem, runs
1348
+ * `node` there, and bridges `external_*` RPC calls back to the host over the
1349
+ * process's stdout/stdin — the same frame protocol as the core stdio transport.
1350
+ *
1351
+ * @example
1352
+ * ```typescript
1353
+ * import { createCodeMode } from '@mastra/core/tools';
1354
+ * import { E2BSandbox, E2BCodeModeTransport } from '@mastra/e2b';
1355
+ *
1356
+ * const { tool, instructions } = createCodeMode(
1357
+ * { tools: { getWeather, getForecast }, sandbox: new E2BSandbox() },
1358
+ * new E2BCodeModeTransport(),
1359
+ * );
1360
+ * ```
1361
+ */
1316
1362
  var E2BCodeModeTransport = class {
1317
- async run(opts) {
1318
- const { sandbox, program, toolIds, dispatch, timeout, abortSignal, onExternalCall, onExternalResult } = opts;
1319
- if (!(sandbox instanceof E2BSandbox)) {
1320
- throw new Error("E2BCodeModeTransport requires an E2BSandbox");
1321
- }
1322
- if (!sandbox.processes) {
1323
- throw new Error("Sandbox has no process manager");
1324
- }
1325
- if (sandbox.status !== "running") {
1326
- await sandbox.start();
1327
- }
1328
- const e2b = sandbox.e2b;
1329
- const externals = toolIds.map((toolId) => ({ toolId, externalName: sanitize(toolId) }));
1330
- const allowList = new Set(toolIds);
1331
- const suffix = randomBytes(4).toString("hex");
1332
- const dir = `${SANDBOX_TMP}/${suffix}`;
1333
- const programPath = `${dir}/program-${suffix}.mjs`;
1334
- const runnerPath = `${dir}/runner-${suffix}.mjs`;
1335
- const programSource = transformSync(buildProgramModule(program), { loader: "ts", target: "es2022" }).code;
1336
- const runnerSource = buildRunner({ programModule: `file://${programPath}`, externals });
1337
- const logs = [];
1338
- let stderr = "";
1339
- let done;
1340
- let stdoutBuffer = "";
1341
- let resolveDone;
1342
- const donePromise = new Promise((resolve) => {
1343
- resolveDone = resolve;
1344
- });
1345
- const notifyCall = (tool, args) => {
1346
- try {
1347
- onExternalCall?.(tool, args);
1348
- } catch {
1349
- }
1350
- };
1351
- const notifyResult = (tool, durationMs, error) => {
1352
- try {
1353
- onExternalResult?.(tool, durationMs, error);
1354
- } catch {
1355
- }
1356
- };
1357
- try {
1358
- await e2b.files.makeDir(dir);
1359
- await e2b.files.write(programPath, programSource);
1360
- await e2b.files.write(runnerPath, runnerSource);
1361
- let handle;
1362
- const respond = async (id, ok, result, error) => {
1363
- await handle.sendStdin(JSON.stringify({ type: "rpc-result", id, ok, result, error }) + "\n");
1364
- };
1365
- const serveRpc = async (id, tool, args) => {
1366
- const started = Date.now();
1367
- notifyCall(tool, args);
1368
- if (!allowList.has(tool)) {
1369
- notifyResult(tool, Date.now() - started, new Error("not allowed"));
1370
- await respond(id, false, void 0, {
1371
- message: `Tool "${tool}" is not available in Code Mode`,
1372
- name: "NotAllowedError"
1373
- });
1374
- return;
1375
- }
1376
- try {
1377
- const result = await dispatch(tool, args);
1378
- notifyResult(tool, Date.now() - started);
1379
- await respond(id, true, result);
1380
- } catch (error) {
1381
- const err = error;
1382
- notifyResult(tool, Date.now() - started, error instanceof Error ? error : new Error(String(error)));
1383
- await respond(id, false, void 0, {
1384
- message: err?.message ?? String(error),
1385
- name: err?.name
1386
- });
1387
- }
1388
- };
1389
- const handleFrame = (frame) => {
1390
- switch (frame.type) {
1391
- case "log":
1392
- logs.push(frame.message);
1393
- return;
1394
- case "done":
1395
- done = frame.ok ? { success: true, result: frame.result, logs } : { success: false, error: frame.error, logs };
1396
- resolveDone();
1397
- return;
1398
- case "rpc":
1399
- void serveRpc(frame.id, frame.tool, frame.args).catch(() => {
1400
- });
1401
- return;
1402
- }
1403
- };
1404
- handle = await sandbox.processes.spawn(`node ${runnerPath}`, {
1405
- cwd: dir,
1406
- abortSignal,
1407
- // E2B failures are otherwise silent, which makes them painful to debug.
1408
- // Capture stderr and surface it in Timeout/NoResult errors below.
1409
- onStderr: (chunk) => {
1410
- stderr += chunk;
1411
- },
1412
- onStdout: (chunk) => {
1413
- stdoutBuffer += chunk;
1414
- let idx;
1415
- while ((idx = stdoutBuffer.indexOf("\n")) >= 0) {
1416
- const line = stdoutBuffer.slice(0, idx);
1417
- stdoutBuffer = stdoutBuffer.slice(idx + 1);
1418
- if (!line.startsWith(FRAME_PREFIX)) continue;
1419
- let frame;
1420
- try {
1421
- frame = JSON.parse(line.slice(FRAME_PREFIX.length));
1422
- } catch {
1423
- continue;
1424
- }
1425
- handleFrame(frame);
1426
- }
1427
- }
1428
- });
1429
- let timer;
1430
- const timeoutPromise = new Promise((resolve) => {
1431
- timer = setTimeout(() => resolve("timeout"), timeout);
1432
- });
1433
- const exitPromise = handle.wait().then(() => "exited");
1434
- const outcome = await Promise.race([
1435
- donePromise.then(() => "done"),
1436
- exitPromise.catch(() => "exited"),
1437
- timeoutPromise
1438
- ]);
1439
- if (timer) clearTimeout(timer);
1440
- if (outcome === "timeout") {
1441
- await handle.kill().catch(() => {
1442
- });
1443
- return {
1444
- success: false,
1445
- logs,
1446
- error: {
1447
- message: `Code Mode execution timed out after ${timeout}ms${stderr ? `
1448
- stderr: ${stderr}` : ""}`,
1449
- name: "TimeoutError"
1450
- }
1451
- };
1452
- }
1453
- if (!done) {
1454
- await exitPromise.catch(() => {
1455
- });
1456
- }
1457
- return done ?? {
1458
- success: false,
1459
- logs,
1460
- error: {
1461
- message: `Program exited without returning a result${stderr ? `
1462
- stderr: ${stderr}` : ""}`,
1463
- name: "NoResultError"
1464
- }
1465
- };
1466
- } finally {
1467
- await e2b.files.remove(dir).catch(() => {
1468
- });
1469
- }
1470
- }
1363
+ async run(opts) {
1364
+ const { sandbox, program, toolIds, dispatch, timeout, abortSignal, onExternalCall, onExternalResult } = opts;
1365
+ if (!(sandbox instanceof E2BSandbox)) throw new Error("E2BCodeModeTransport requires an E2BSandbox");
1366
+ if (!sandbox.processes) throw new Error("Sandbox has no process manager");
1367
+ if (sandbox.status !== "running") await sandbox.start();
1368
+ const e2b = sandbox.e2b;
1369
+ const externals = toolIds.map((toolId) => ({
1370
+ toolId,
1371
+ externalName: sanitizeToolId(toolId)
1372
+ }));
1373
+ const allowList = new Set(toolIds);
1374
+ const suffix = randomBytes(4).toString("hex");
1375
+ const dir = `${SANDBOX_TMP}/${suffix}`;
1376
+ const programPath = `${dir}/program-${suffix}.mjs`;
1377
+ const runnerPath = `${dir}/runner-${suffix}.mjs`;
1378
+ const programSource = transformSync(buildProgramModule(program), {
1379
+ loader: "ts",
1380
+ target: "es2022"
1381
+ }).code;
1382
+ const runnerSource = buildRunner({
1383
+ programModule: `file://${programPath}`,
1384
+ externals
1385
+ });
1386
+ const logs = [];
1387
+ let stderr = "";
1388
+ let done;
1389
+ let stdoutBuffer = "";
1390
+ let resolveDone;
1391
+ const donePromise = new Promise((resolve) => {
1392
+ resolveDone = resolve;
1393
+ });
1394
+ const notifyCall = (tool, args) => {
1395
+ try {
1396
+ onExternalCall?.(tool, args);
1397
+ } catch {}
1398
+ };
1399
+ const notifyResult = (tool, durationMs, error) => {
1400
+ try {
1401
+ onExternalResult?.(tool, durationMs, error);
1402
+ } catch {}
1403
+ };
1404
+ try {
1405
+ await e2b.files.makeDir(dir);
1406
+ await e2b.files.write(programPath, programSource);
1407
+ await e2b.files.write(runnerPath, runnerSource);
1408
+ let handle;
1409
+ const respond = async (id, ok, result, error) => {
1410
+ await handle.sendStdin(JSON.stringify({
1411
+ type: "rpc-result",
1412
+ id,
1413
+ ok,
1414
+ result,
1415
+ error
1416
+ }) + "\n");
1417
+ };
1418
+ const serveRpc = async (id, tool, args) => {
1419
+ const started = Date.now();
1420
+ notifyCall(tool, args);
1421
+ if (!allowList.has(tool)) {
1422
+ notifyResult(tool, Date.now() - started, /* @__PURE__ */ new Error("not allowed"));
1423
+ await respond(id, false, void 0, {
1424
+ message: `Tool "${tool}" is not available in Code Mode`,
1425
+ name: "NotAllowedError"
1426
+ });
1427
+ return;
1428
+ }
1429
+ try {
1430
+ const result = await dispatch(tool, args);
1431
+ notifyResult(tool, Date.now() - started);
1432
+ await respond(id, true, result);
1433
+ } catch (error) {
1434
+ const err = error;
1435
+ notifyResult(tool, Date.now() - started, error instanceof Error ? error : new Error(String(error)));
1436
+ await respond(id, false, void 0, {
1437
+ message: err?.message ?? String(error),
1438
+ name: err?.name
1439
+ });
1440
+ }
1441
+ };
1442
+ const handleFrame = (frame) => {
1443
+ switch (frame.type) {
1444
+ case "log":
1445
+ logs.push(frame.message);
1446
+ return;
1447
+ case "done":
1448
+ done = frame.ok ? {
1449
+ success: true,
1450
+ result: frame.result,
1451
+ logs
1452
+ } : {
1453
+ success: false,
1454
+ error: frame.error,
1455
+ logs
1456
+ };
1457
+ resolveDone();
1458
+ return;
1459
+ case "rpc":
1460
+ serveRpc(frame.id, frame.tool, frame.args).catch(() => {});
1461
+ return;
1462
+ }
1463
+ };
1464
+ handle = await sandbox.processes.spawn(`node ${runnerPath}`, {
1465
+ cwd: dir,
1466
+ abortSignal,
1467
+ onStderr: (chunk) => {
1468
+ stderr += chunk;
1469
+ },
1470
+ onStdout: (chunk) => {
1471
+ stdoutBuffer += chunk;
1472
+ let idx;
1473
+ while ((idx = stdoutBuffer.indexOf("\n")) >= 0) {
1474
+ const line = stdoutBuffer.slice(0, idx);
1475
+ stdoutBuffer = stdoutBuffer.slice(idx + 1);
1476
+ if (!line.startsWith(FRAME_PREFIX)) continue;
1477
+ let frame;
1478
+ try {
1479
+ frame = JSON.parse(line.slice(FRAME_PREFIX.length));
1480
+ } catch {
1481
+ continue;
1482
+ }
1483
+ handleFrame(frame);
1484
+ }
1485
+ }
1486
+ });
1487
+ let timer;
1488
+ const timeoutPromise = new Promise((resolve) => {
1489
+ timer = setTimeout(() => resolve("timeout"), timeout);
1490
+ });
1491
+ const exitPromise = handle.wait().then(() => "exited");
1492
+ const outcome = await Promise.race([
1493
+ donePromise.then(() => "done"),
1494
+ exitPromise.catch(() => "exited"),
1495
+ timeoutPromise
1496
+ ]);
1497
+ if (timer) clearTimeout(timer);
1498
+ if (outcome === "timeout") {
1499
+ await handle.kill().catch(() => {});
1500
+ return {
1501
+ success: false,
1502
+ logs,
1503
+ error: {
1504
+ message: `Code Mode execution timed out after ${timeout}ms${stderr ? `\nstderr: ${stderr}` : ""}`,
1505
+ name: "TimeoutError"
1506
+ }
1507
+ };
1508
+ }
1509
+ if (!done) await exitPromise.catch(() => {});
1510
+ return done ?? {
1511
+ success: false,
1512
+ logs,
1513
+ error: {
1514
+ message: `Program exited without returning a result${stderr ? `\nstderr: ${stderr}` : ""}`,
1515
+ name: "NoResultError"
1516
+ }
1517
+ };
1518
+ } finally {
1519
+ await e2b.files.remove(dir).catch(() => {});
1520
+ }
1521
+ }
1471
1522
  };
1472
-
1523
+ //#endregion
1473
1524
  export { E2BCodeModeTransport, E2BProcessManager, E2BSandbox, createDefaultMountableTemplate, e2bSandboxProvider };
1474
- //# sourceMappingURL=index.js.map
1525
+
1475
1526
  //# sourceMappingURL=index.js.map