@mastra/blaxel 0.6.0 → 0.6.1

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,1156 +1,1065 @@
1
- import { SandboxInstance } from '@blaxel/core';
2
- import { SandboxProcessManager, MastraSandbox, SandboxNotReadyError, ProcessHandle } from '@mastra/core/workspace';
3
- import crypto from 'crypto';
4
-
5
- // src/sandbox/index.ts
6
-
7
- // src/utils/shell-quote.ts
1
+ import { SandboxInstance } from "@blaxel/core";
2
+ import { MastraSandbox, ProcessHandle, SandboxNotReadyError, SandboxProcessManager } from "@mastra/core/workspace";
3
+ import crypto from "crypto";
4
+ //#region src/utils/shell-quote.ts
5
+ /**
6
+ * Shell-quote a single argument for safe use in a command string.
7
+ *
8
+ * Arguments containing only safe characters are returned as-is.
9
+ * All others are wrapped in single quotes with embedded single quotes escaped.
10
+ */
8
11
  function shellQuote(arg) {
9
- if (/^[a-zA-Z0-9._\-/@:=]+$/.test(arg)) return arg;
10
- return "'" + arg.replace(/'/g, "'\\''") + "'";
12
+ if (/^[a-zA-Z0-9._\-/@:=]+$/.test(arg)) return arg;
13
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
11
14
  }
12
-
13
- // src/sandbox/mounts/types.ts
14
- var LOG_PREFIX = "[@mastra/blaxel]";
15
- var SAFE_BUCKET_NAME = /^[a-z0-9][a-z0-9.\-]{1,61}[a-z0-9]$/;
16
- function validateBucketName(bucket) {
17
- if (!SAFE_BUCKET_NAME.test(bucket)) {
18
- throw new Error(
19
- `Invalid bucket name: "${bucket}". Bucket names must be 3-63 characters, lowercase alphanumeric, hyphens, or dots.`
20
- );
21
- }
15
+ //#endregion
16
+ //#region src/sandbox/mounts/types.ts
17
+ const LOG_PREFIX = "[@mastra/blaxel]";
18
+ const SAFE_S3_BUCKET_NAME = /^[a-z0-9][a-z0-9.\-]{1,61}[a-z0-9]$/;
19
+ const SAFE_GCS_BUCKET_NAME = /^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$/;
20
+ function validateS3BucketName(bucket) {
21
+ if (!SAFE_S3_BUCKET_NAME.test(bucket)) throw new Error(`Invalid S3 bucket name: "${bucket}". Bucket names must be 3-63 characters, lowercase alphanumeric, hyphens, or dots.`);
22
22
  }
23
+ function validateGCSBucketName(bucket) {
24
+ if (!SAFE_GCS_BUCKET_NAME.test(bucket)) throw new Error(`Invalid GCS bucket name: "${bucket}". Bucket names must be 3-63 characters, lowercase alphanumeric, hyphens, underscores, or dots.`);
25
+ }
26
+ /**
27
+ * Validate an endpoint URL before interpolating into shell commands.
28
+ * Only http and https schemes are allowed.
29
+ */
23
30
  function validateEndpoint(endpoint) {
24
- let parsed;
25
- try {
26
- parsed = new URL(endpoint);
27
- } catch {
28
- throw new Error(`Invalid endpoint URL: "${endpoint}"`);
29
- }
30
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
31
- throw new Error(`Invalid endpoint URL scheme: "${parsed.protocol}". Only http: and https: are allowed.`);
32
- }
31
+ let parsed;
32
+ try {
33
+ parsed = new URL(endpoint);
34
+ } catch {
35
+ throw new Error(`Invalid endpoint URL: "${endpoint}"`);
36
+ }
37
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid endpoint URL scheme: "${parsed.protocol}". Only http: and https: are allowed.`);
33
38
  }
39
+ /**
40
+ * Validate and normalize a mount prefix before interpolating into shell commands.
41
+ * Returns the normalized prefix (no leading/trailing slashes).
42
+ *
43
+ * Shell safety is handled by shellQuote() at the call site, so this function
44
+ * only enforces path-level rules (no traversal, no empty result, no control chars).
45
+ */
34
46
  function validatePrefix(prefix) {
35
- let normalized = prefix;
36
- while (normalized.startsWith("/")) normalized = normalized.slice(1);
37
- while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
38
- if (!normalized) {
39
- throw new Error("Mount prefix cannot be empty after normalization.");
40
- }
41
- if (normalized.includes("//") || normalized.split("/").some((s) => s === "." || s === "..")) {
42
- throw new Error(`Invalid mount prefix: "${prefix}". Path traversal is not allowed.`);
43
- }
44
- if (/[\x00-\x1f\x7f]/.test(normalized)) {
45
- throw new Error(`Invalid mount prefix: "${prefix}". Control characters are not allowed.`);
46
- }
47
- return normalized;
47
+ let normalized = prefix;
48
+ while (normalized.startsWith("/")) normalized = normalized.slice(1);
49
+ while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
50
+ if (!normalized) throw new Error("Mount prefix cannot be empty after normalization.");
51
+ if (normalized.includes("//") || normalized.split("/").some((s) => s === "." || s === "..")) throw new Error(`Invalid mount prefix: "${prefix}". Path traversal is not allowed.`);
52
+ if (/[\x00-\x1f\x7f]/.test(normalized)) throw new Error(`Invalid mount prefix: "${prefix}". Control characters are not allowed.`);
53
+ return normalized;
48
54
  }
55
+ /**
56
+ * Detect the system package manager available in the sandbox.
57
+ * Returns 'apt' for Debian/Ubuntu, 'apk' for Alpine, or 'unknown'.
58
+ */
49
59
  async function detectPackageManager(sandbox) {
50
- const result = await runCommand(
51
- sandbox,
52
- 'which apt-get >/dev/null 2>&1 && echo "apt" || (which apk >/dev/null 2>&1 && echo "apk" || echo "unknown")'
53
- );
54
- const pm = result.stdout.trim();
55
- if (pm === "apt") return "apt";
56
- if (pm === "apk") return "apk";
57
- return "unknown";
60
+ const pm = (await runCommand(sandbox, "which apt-get >/dev/null 2>&1 && echo \"apt\" || (which apk >/dev/null 2>&1 && echo \"apk\" || echo \"unknown\")")).stdout.trim();
61
+ if (pm === "apt") return "apt";
62
+ if (pm === "apk") return "apk";
63
+ return "unknown";
58
64
  }
65
+ /**
66
+ * Run a command in the Blaxel sandbox and return the result.
67
+ * Wraps the process.exec API to match the command execution pattern used in mount operations.
68
+ *
69
+ * Does NOT throw on non-zero exit codes — callers should check `exitCode` themselves.
70
+ */
59
71
  async function runCommand(sandbox, command, options) {
60
- const result = await sandbox.process.exec({
61
- command,
62
- waitForCompletion: true,
63
- ...options?.timeout && { timeout: Math.ceil(options.timeout / 1e3) }
64
- });
65
- return {
66
- exitCode: result.exitCode ?? 0,
67
- stdout: result.stdout ?? "",
68
- stderr: result.stderr ?? ""
69
- };
72
+ const result = await sandbox.process.exec({
73
+ command,
74
+ waitForCompletion: true,
75
+ ...options?.timeout && { timeout: Math.ceil(options.timeout / 1e3) }
76
+ });
77
+ return {
78
+ exitCode: result.exitCode ?? 0,
79
+ stdout: result.stdout ?? "",
80
+ stderr: result.stderr ?? ""
81
+ };
70
82
  }
83
+ //#endregion
84
+ //#region src/sandbox/mounts/s3.ts
85
+ /**
86
+ * Mount an S3 bucket using s3fs-fuse.
87
+ */
71
88
  async function mountS3(mountPath, config, ctx) {
72
- const { sandbox, logger } = ctx;
73
- validateBucketName(config.bucket);
74
- if (config.endpoint) {
75
- validateEndpoint(config.endpoint);
76
- }
77
- const checkResult = await runCommand(sandbox, 'which s3fs || echo "not found"');
78
- if (checkResult.stdout.includes("not found")) {
79
- logger.warn(`${LOG_PREFIX} s3fs not found, attempting runtime installation...`);
80
- logger.info(`${LOG_PREFIX} Tip: For faster startup, pre-install s3fs in your sandbox image`);
81
- const pm = await detectPackageManager(sandbox);
82
- logger.debug(`${LOG_PREFIX} Detected package manager: ${pm}`);
83
- if (pm === "apt") {
84
- const updateResult = await runCommand(sandbox, "apt-get update 2>&1", { timeout: 6e4 });
85
- if (updateResult.exitCode !== 0) {
86
- throw new Error(
87
- `Failed to update package lists for s3fs installation.
88
- Error details: ${updateResult.stderr || updateResult.stdout}`
89
- );
90
- }
91
- const installResult = await runCommand(
92
- sandbox,
93
- "apt-get install -y s3fs fuse 2>&1 || apt-get install -y s3fs-fuse fuse 2>&1",
94
- { timeout: 12e4 }
95
- );
96
- if (installResult.exitCode !== 0) {
97
- throw new Error(
98
- `Failed to install s3fs. For S3 mounting, your sandbox image needs s3fs and fuse packages.
89
+ const { sandbox, logger } = ctx;
90
+ validateS3BucketName(config.bucket);
91
+ if (config.endpoint) validateEndpoint(config.endpoint);
92
+ if ((await runCommand(sandbox, "which s3fs || echo \"not found\"")).stdout.includes("not found")) {
93
+ logger.warn(`${LOG_PREFIX} s3fs not found, attempting runtime installation...`);
94
+ logger.info(`${LOG_PREFIX} Tip: For faster startup, pre-install s3fs in your sandbox image`);
95
+ const pm = await detectPackageManager(sandbox);
96
+ logger.debug(`${LOG_PREFIX} Detected package manager: ${pm}`);
97
+ if (pm === "apt") {
98
+ const updateResult = await runCommand(sandbox, "apt-get update 2>&1", { timeout: 6e4 });
99
+ if (updateResult.exitCode !== 0) throw new Error(`Failed to update package lists for s3fs installation.\nError details: ${updateResult.stderr || updateResult.stdout}`);
100
+ const installResult = await runCommand(sandbox, "apt-get install -y s3fs fuse 2>&1 || apt-get install -y s3fs-fuse fuse 2>&1", { timeout: 12e4 });
101
+ if (installResult.exitCode !== 0) throw new Error(`Failed to install s3fs. For S3 mounting, your sandbox image needs s3fs and fuse packages.
99
102
 
100
103
  Pre-install in your image: apt-get install -y s3fs fuse
101
104
 
102
- Error details: ${installResult.stderr || installResult.stdout}`
103
- );
104
- }
105
- } else if (pm === "apk") {
106
- const installResult = await runCommand(sandbox, "apk add --no-cache s3fs-fuse fuse 2>&1", { timeout: 12e4 });
107
- if (installResult.exitCode !== 0) {
108
- throw new Error(
109
- `Failed to install s3fs on Alpine Linux. Ensure the Alpine community repository is enabled.
105
+ Error details: ${installResult.stderr || installResult.stdout}`);
106
+ } else if (pm === "apk") {
107
+ const installResult = await runCommand(sandbox, "apk add --no-cache s3fs-fuse fuse 2>&1", { timeout: 12e4 });
108
+ if (installResult.exitCode !== 0) throw new Error(`Failed to install s3fs on Alpine Linux. Ensure the Alpine community repository is enabled.
110
109
 
111
110
  Pre-install in your image: apk add --no-cache s3fs-fuse fuse
112
111
 
113
- Error details: ${installResult.stderr || installResult.stdout}`
114
- );
115
- }
116
- } else {
117
- throw new Error(
118
- `Cannot install s3fs: no supported package manager found (need apt-get or apk).
119
- Use a Debian-based image (e.g. blaxel/ts-app:latest) or Alpine-based image (e.g. blaxel/node:latest), or pre-install s3fs in your custom image.`
120
- );
121
- }
122
- }
123
- const idResult = await runCommand(sandbox, "id -u && id -g");
124
- if (idResult.exitCode !== 0) {
125
- throw new Error(`Failed to get uid/gid: ${idResult.stderr || idResult.stdout}`);
126
- }
127
- const [uid, gid] = idResult.stdout.trim().split("\n");
128
- const hasAccessKey = !!config.accessKeyId;
129
- const hasSecretKey = !!config.secretAccessKey;
130
- if (hasAccessKey !== hasSecretKey) {
131
- throw new Error("Both accessKeyId and secretAccessKey must be provided together.");
132
- }
133
- const hasCredentials = hasAccessKey && hasSecretKey;
134
- const mountHash = crypto.createHash("md5").update(mountPath).digest("hex").slice(0, 8);
135
- const credentialsPath = `/tmp/.passwd-s3fs-${mountHash}`;
136
- if (!hasCredentials && config.endpoint) {
137
- throw new Error(
138
- `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
- );
140
- }
141
- if (hasCredentials) {
142
- const credentialsContent = `${config.accessKeyId}:${config.secretAccessKey}`;
143
- await runCommand(sandbox, `rm -f ${credentialsPath}`);
144
- await sandbox.fs.write(credentialsPath, credentialsContent);
145
- await runCommand(sandbox, `chmod 600 ${credentialsPath}`);
146
- }
147
- const mountOptions = [];
148
- if (hasCredentials) {
149
- mountOptions.push(`passwd_file=${credentialsPath}`);
150
- } else {
151
- mountOptions.push("public_bucket=1");
152
- logger.debug(`${LOG_PREFIX} No credentials provided, mounting as public bucket (read-only)`);
153
- }
154
- mountOptions.push("allow_other");
155
- if (uid && gid) {
156
- mountOptions.push(`uid=${uid}`, `gid=${gid}`);
157
- }
158
- if (config.endpoint) {
159
- const endpoint = config.endpoint.replace(/\/$/, "");
160
- mountOptions.push(`url=${endpoint}`, "use_path_request_style", "sigv4", "nomultipart");
161
- }
162
- if (config.readOnly) {
163
- mountOptions.push("ro");
164
- logger.debug(`${LOG_PREFIX} Mounting as read-only`);
165
- }
166
- let bucketArg = config.bucket;
167
- if (config.prefix) {
168
- const normalizedPrefix = validatePrefix(config.prefix);
169
- bucketArg = `${config.bucket}:/${normalizedPrefix}`;
170
- }
171
- const quotedMountPath = shellQuote(mountPath);
172
- const mountCmd = `s3fs ${shellQuote(bucketArg)} ${quotedMountPath} -o ${mountOptions.join(" -o ")}`;
173
- logger.debug(`${LOG_PREFIX} Mounting S3:`, hasCredentials ? mountCmd.replace(credentialsPath, "***") : mountCmd);
174
- const result = await runCommand(sandbox, mountCmd, { timeout: 6e4 });
175
- logger.debug(`${LOG_PREFIX} s3fs result:`, {
176
- exitCode: result.exitCode,
177
- stdout: result.stdout,
178
- stderr: result.stderr
179
- });
180
- if (result.exitCode !== 0) {
181
- throw new Error(`Failed to mount S3 bucket: ${result.stderr || result.stdout}`);
182
- }
112
+ Error details: ${installResult.stderr || installResult.stdout}`);
113
+ } else throw new Error("Cannot install s3fs: no supported package manager found (need apt-get or apk).\nUse a Debian-based image (e.g. blaxel/ts-app:latest) or Alpine-based image (e.g. blaxel/node:latest), or pre-install s3fs in your custom image.");
114
+ }
115
+ const idResult = await runCommand(sandbox, "id -u && id -g");
116
+ if (idResult.exitCode !== 0) throw new Error(`Failed to get uid/gid: ${idResult.stderr || idResult.stdout}`);
117
+ const [uid, gid] = idResult.stdout.trim().split("\n");
118
+ const hasAccessKey = !!config.accessKeyId;
119
+ const hasSecretKey = !!config.secretAccessKey;
120
+ if (hasAccessKey !== hasSecretKey) throw new Error("Both accessKeyId and secretAccessKey must be provided together.");
121
+ const hasCredentials = hasAccessKey && hasSecretKey;
122
+ const credentialsPath = `/tmp/.passwd-s3fs-${crypto.createHash("md5").update(mountPath).digest("hex").slice(0, 8)}`;
123
+ 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.`);
124
+ if (hasCredentials) {
125
+ const credentialsContent = `${config.accessKeyId}:${config.secretAccessKey}`;
126
+ await runCommand(sandbox, `rm -f ${credentialsPath}`);
127
+ await sandbox.fs.write(credentialsPath, credentialsContent);
128
+ await runCommand(sandbox, `chmod 600 ${credentialsPath}`);
129
+ }
130
+ const mountOptions = [];
131
+ if (hasCredentials) mountOptions.push(`passwd_file=${credentialsPath}`);
132
+ else {
133
+ mountOptions.push("public_bucket=1");
134
+ logger.debug(`${LOG_PREFIX} No credentials provided, mounting as public bucket (read-only)`);
135
+ }
136
+ mountOptions.push("allow_other");
137
+ if (uid && gid) mountOptions.push(`uid=${uid}`, `gid=${gid}`);
138
+ if (config.endpoint) {
139
+ const endpoint = config.endpoint.replace(/\/$/, "");
140
+ mountOptions.push(`url=${endpoint}`, "use_path_request_style", "sigv4", "nomultipart");
141
+ }
142
+ if (config.readOnly) {
143
+ mountOptions.push("ro");
144
+ logger.debug(`${LOG_PREFIX} Mounting as read-only`);
145
+ }
146
+ let bucketArg = config.bucket;
147
+ if (config.prefix) {
148
+ const normalizedPrefix = validatePrefix(config.prefix);
149
+ bucketArg = `${config.bucket}:/${normalizedPrefix}`;
150
+ }
151
+ const quotedMountPath = shellQuote(mountPath);
152
+ const mountCmd = `s3fs ${shellQuote(bucketArg)} ${quotedMountPath} -o ${mountOptions.join(" -o ")}`;
153
+ logger.debug(`${LOG_PREFIX} Mounting S3:`, hasCredentials ? mountCmd.replace(credentialsPath, "***") : mountCmd);
154
+ const result = await runCommand(sandbox, mountCmd, { timeout: 6e4 });
155
+ logger.debug(`${LOG_PREFIX} s3fs result:`, {
156
+ exitCode: result.exitCode,
157
+ stdout: result.stdout,
158
+ stderr: result.stderr
159
+ });
160
+ if (result.exitCode !== 0) throw new Error(`Failed to mount S3 bucket: ${result.stderr || result.stdout}`);
183
161
  }
162
+ //#endregion
163
+ //#region src/sandbox/mounts/gcs.ts
164
+ /**
165
+ * Mount a GCS bucket using gcsfuse.
166
+ */
184
167
  async function mountGCS(mountPath, config, ctx) {
185
- const { sandbox, logger } = ctx;
186
- validateBucketName(config.bucket);
187
- const quotedMountPath = shellQuote(mountPath);
188
- const checkResult = await runCommand(sandbox, 'which gcsfuse || echo "not found"');
189
- if (checkResult.stdout.includes("not found")) {
190
- logger.warn(`${LOG_PREFIX} gcsfuse not found, attempting runtime installation...`);
191
- const pm = await detectPackageManager(sandbox);
192
- logger.debug(`${LOG_PREFIX} Detected package manager: ${pm}`);
193
- if (pm === "apk") {
194
- throw new Error(
195
- `gcsfuse is not available on Alpine Linux. Google only provides gcsfuse packages for Debian/Ubuntu.
196
-
197
- Use a Debian-based Blaxel image for GCS mounts:
198
- new BlaxelSandbox({ image: 'blaxel/ts-app:latest' })
199
- new BlaxelSandbox({ image: 'blaxel/py-app:latest' })`
200
- );
201
- }
202
- if (pm !== "apt") {
203
- throw new Error(
204
- `Cannot install gcsfuse: no supported package manager found (need apt-get).
205
- gcsfuse is only available on Debian/Ubuntu-based images.
206
-
207
- Use a Debian-based Blaxel image:
208
- new BlaxelSandbox({ image: 'blaxel/ts-app:latest' })`
209
- );
210
- }
211
- logger.info(`${LOG_PREFIX} Tip: For faster startup, pre-install gcsfuse in your sandbox image`);
212
- const codenameResult = await runCommand(
213
- sandbox,
214
- "cat /etc/os-release 2>/dev/null | grep VERSION_CODENAME | cut -d= -f2 || echo bookworm"
215
- );
216
- const codename = codenameResult.stdout.trim() || "bookworm";
217
- logger.debug(`${LOG_PREFIX} Detected distro codename: ${codename}`);
218
- const prepResult = await runCommand(
219
- sandbox,
220
- "apt-get update && apt-get install -y curl gnupg lsb-release fuse && mkdir -p /etc/apt/keyrings",
221
- { timeout: 12e4 }
222
- );
223
- if (prepResult.exitCode !== 0) {
224
- throw new Error(
225
- `Failed to install gcsfuse prerequisites.
226
- Error details: ${prepResult.stderr || prepResult.stdout}`
227
- );
228
- }
229
- const installResult = await runCommand(
230
- sandbox,
231
- `curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | 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" | tee /etc/apt/sources.list.d/gcsfuse.list && apt-get update && apt-get install -y gcsfuse`,
232
- { timeout: 12e4 }
233
- );
234
- if (installResult.exitCode !== 0) {
235
- throw new Error(
236
- `Failed to install gcsfuse. For GCS mounting, your sandbox image needs gcsfuse and fuse packages.
168
+ const { sandbox, logger } = ctx;
169
+ validateGCSBucketName(config.bucket);
170
+ const quotedMountPath = shellQuote(mountPath);
171
+ if ((await runCommand(sandbox, "which gcsfuse || echo \"not found\"")).stdout.includes("not found")) {
172
+ logger.warn(`${LOG_PREFIX} gcsfuse not found, attempting runtime installation...`);
173
+ const pm = await detectPackageManager(sandbox);
174
+ logger.debug(`${LOG_PREFIX} Detected package manager: ${pm}`);
175
+ if (pm === "apk") throw new Error("gcsfuse is not available on Alpine Linux. Google only provides gcsfuse packages for Debian/Ubuntu.\n\nUse a Debian-based Blaxel image for GCS mounts:\n new BlaxelSandbox({ image: 'blaxel/ts-app:latest' })\n new BlaxelSandbox({ image: 'blaxel/py-app:latest' })");
176
+ if (pm !== "apt") throw new Error("Cannot install gcsfuse: no supported package manager found (need apt-get).\ngcsfuse is only available on Debian/Ubuntu-based images.\n\nUse a Debian-based Blaxel image:\n new BlaxelSandbox({ image: 'blaxel/ts-app:latest' })");
177
+ logger.info(`${LOG_PREFIX} Tip: For faster startup, pre-install gcsfuse in your sandbox image`);
178
+ const codename = (await runCommand(sandbox, "cat /etc/os-release 2>/dev/null | grep VERSION_CODENAME | cut -d= -f2 || echo bookworm")).stdout.trim() || "bookworm";
179
+ logger.debug(`${LOG_PREFIX} Detected distro codename: ${codename}`);
180
+ const prepResult = await runCommand(sandbox, "apt-get update && apt-get install -y curl gnupg lsb-release fuse && mkdir -p /etc/apt/keyrings", { timeout: 12e4 });
181
+ if (prepResult.exitCode !== 0) throw new Error(`Failed to install gcsfuse prerequisites.\nError details: ${prepResult.stderr || prepResult.stdout}`);
182
+ const installResult = await runCommand(sandbox, `curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | 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" | tee /etc/apt/sources.list.d/gcsfuse.list && apt-get update && apt-get install -y gcsfuse`, { timeout: 12e4 });
183
+ if (installResult.exitCode !== 0) throw new Error(`Failed to install gcsfuse. For GCS mounting, your sandbox image needs gcsfuse and fuse packages.
237
184
 
238
185
  Pre-install in your image: apt-get install -y gcsfuse fuse
239
186
 
240
- Error details: ${installResult.stderr || installResult.stdout}`
241
- );
242
- }
243
- const verifyResult = await runCommand(sandbox, "which gcsfuse");
244
- if (verifyResult.exitCode !== 0) {
245
- throw new Error(
246
- `gcsfuse installation appeared to succeed but binary not found on PATH.
247
- Install output: ${installResult.stdout}
248
- ${installResult.stderr}`
249
- );
250
- }
251
- }
252
- const idResult = await runCommand(sandbox, "id -u && id -g");
253
- if (idResult.exitCode !== 0) {
254
- throw new Error(`Failed to get uid/gid: ${idResult.stderr || idResult.stdout}`);
255
- }
256
- const [uid, gid] = idResult.stdout.trim().split("\n");
257
- const uidGidFlags = uid && gid ? `--uid=${uid} --gid=${gid}` : "";
258
- const hasCredentials = !!config.serviceAccountKey;
259
- let mountCmd;
260
- if (hasCredentials) {
261
- const mountHash = crypto.createHash("md5").update(mountPath).digest("hex").slice(0, 8);
262
- const keyPath = `/tmp/gcs-key-${mountHash}.json`;
263
- await runCommand(sandbox, `rm -f ${keyPath}`);
264
- await sandbox.fs.write(keyPath, config.serviceAccountKey);
265
- mountCmd = `gcsfuse --key-file=${keyPath} -o allow_other ${uidGidFlags} ${config.bucket} ${quotedMountPath}`;
266
- } else {
267
- logger.debug(`${LOG_PREFIX} No credentials provided, mounting GCS as public bucket (read-only)`);
268
- mountCmd = `gcsfuse --anonymous-access -o allow_other ${uidGidFlags} ${config.bucket} ${quotedMountPath}`;
269
- }
270
- logger.debug(`${LOG_PREFIX} Mounting GCS:`, mountCmd);
271
- const result = await runCommand(sandbox, mountCmd, { timeout: 6e4 });
272
- logger.debug(`${LOG_PREFIX} gcsfuse result:`, {
273
- exitCode: result.exitCode,
274
- stdout: result.stdout,
275
- stderr: result.stderr
276
- });
277
- if (result.exitCode !== 0) {
278
- throw new Error(`Failed to mount GCS bucket: ${result.stderr || result.stdout}`);
279
- }
187
+ Error details: ${installResult.stderr || installResult.stdout}`);
188
+ if ((await runCommand(sandbox, "which gcsfuse")).exitCode !== 0) throw new Error(`gcsfuse installation appeared to succeed but binary not found on PATH.\nInstall output: ${installResult.stdout}\n${installResult.stderr}`);
189
+ }
190
+ const idResult = await runCommand(sandbox, "id -u && id -g");
191
+ if (idResult.exitCode !== 0) throw new Error(`Failed to get uid/gid: ${idResult.stderr || idResult.stdout}`);
192
+ const [uid, gid] = idResult.stdout.trim().split("\n");
193
+ const uidGidFlags = uid && gid ? `--uid=${uid} --gid=${gid}` : "";
194
+ const hasCredentials = !!config.serviceAccountKey;
195
+ let mountCmd;
196
+ if (hasCredentials) {
197
+ const keyPath = `/tmp/gcs-key-${crypto.createHash("md5").update(mountPath).digest("hex").slice(0, 8)}.json`;
198
+ await runCommand(sandbox, `rm -f ${keyPath}`);
199
+ await sandbox.fs.write(keyPath, config.serviceAccountKey);
200
+ mountCmd = `gcsfuse --key-file=${keyPath} -o allow_other ${uidGidFlags} ${config.bucket} ${quotedMountPath}`;
201
+ } else {
202
+ logger.debug(`${LOG_PREFIX} No credentials provided, mounting GCS as public bucket (read-only)`);
203
+ mountCmd = `gcsfuse --anonymous-access -o allow_other ${uidGidFlags} ${config.bucket} ${quotedMountPath}`;
204
+ }
205
+ logger.debug(`${LOG_PREFIX} Mounting GCS:`, mountCmd);
206
+ const result = await runCommand(sandbox, mountCmd, { timeout: 6e4 });
207
+ logger.debug(`${LOG_PREFIX} gcsfuse result:`, {
208
+ exitCode: result.exitCode,
209
+ stdout: result.stdout,
210
+ stderr: result.stderr
211
+ });
212
+ if (result.exitCode !== 0) throw new Error(`Failed to mount GCS bucket: ${result.stderr || result.stdout}`);
280
213
  }
214
+ //#endregion
215
+ //#region src/sandbox/process-manager.ts
216
+ /**
217
+ * Wraps a Blaxel background process to conform to Mastra's ProcessHandle.
218
+ * Not exported — internal to this module.
219
+ *
220
+ * Uses streamLogs() for real-time output and get() for exit code resolution.
221
+ */
281
222
  var BlaxelProcessHandle = class extends ProcessHandle {
282
- pid;
283
- _sandbox;
284
- _startTime;
285
- _exitCode;
286
- _waitPromise = null;
287
- _streamingDone = null;
288
- _closeStream = null;
289
- _killed = false;
290
- constructor(pid, sandbox, startTime, options) {
291
- super(options);
292
- this.pid = pid;
293
- this._sandbox = sandbox;
294
- this._startTime = startTime;
295
- }
296
- get exitCode() {
297
- return this._exitCode;
298
- }
299
- /** @internal Set by the process manager after streaming starts. */
300
- set streamControl(control) {
301
- this._closeStream = control.close;
302
- this._streamingDone = control.wait();
303
- this._streamingDone.then(() => this._resolveExitCode()).catch(() => this._resolveExitCode());
304
- }
305
- /** Fetch exit code from Blaxel and set _exitCode. No-op if already set. */
306
- async _resolveExitCode() {
307
- if (this._exitCode !== void 0) return;
308
- try {
309
- const proc = await this._sandbox.process.get(this.pid);
310
- this._exitCode = proc.status === "completed" ? proc.exitCode ?? 0 : proc.exitCode ?? 1;
311
- } catch {
312
- if (this._exitCode === void 0) {
313
- this._exitCode = 1;
314
- }
315
- }
316
- }
317
- async wait() {
318
- if (!this._waitPromise) {
319
- this._waitPromise = this._doWait();
320
- }
321
- return this._waitPromise;
322
- }
323
- async _doWait() {
324
- if (this._streamingDone) {
325
- await this._streamingDone.catch(() => {
326
- });
327
- }
328
- if (this._killed) {
329
- return {
330
- success: false,
331
- exitCode: this._exitCode ?? 137,
332
- stdout: this.stdout,
333
- stderr: this.stderr,
334
- executionTimeMs: Date.now() - this._startTime
335
- };
336
- }
337
- await this._resolveExitCode();
338
- return {
339
- success: this._exitCode === 0,
340
- exitCode: this._exitCode ?? 1,
341
- stdout: this.stdout,
342
- stderr: this.stderr,
343
- executionTimeMs: Date.now() - this._startTime
344
- };
345
- }
346
- async kill() {
347
- if (this._exitCode !== void 0) return false;
348
- this._killed = true;
349
- this._exitCode = 137;
350
- this._closeStream?.();
351
- try {
352
- await this._sandbox.process.kill(this.pid);
353
- } catch {
354
- }
355
- return true;
356
- }
357
- async sendStdin(_data) {
358
- throw new Error("Blaxel sandboxes do not support stdin");
359
- }
223
+ pid;
224
+ _sandbox;
225
+ _startTime;
226
+ _exitCode;
227
+ _waitPromise = null;
228
+ _streamingDone = null;
229
+ _closeStream = null;
230
+ _killed = false;
231
+ constructor(pid, sandbox, startTime, options) {
232
+ super(options);
233
+ this.pid = pid;
234
+ this._sandbox = sandbox;
235
+ this._startTime = startTime;
236
+ }
237
+ get exitCode() {
238
+ return this._exitCode;
239
+ }
240
+ /** @internal Set by the process manager after streaming starts. */
241
+ set streamControl(control) {
242
+ this._closeStream = control.close;
243
+ this._streamingDone = control.wait();
244
+ this._streamingDone.then(() => this._resolveExitCode()).catch(() => this._resolveExitCode());
245
+ }
246
+ /** Fetch exit code from Blaxel and set _exitCode. No-op if already set. */
247
+ async _resolveExitCode() {
248
+ if (this._exitCode !== void 0) return;
249
+ try {
250
+ const proc = await this._sandbox.process.get(this.pid);
251
+ this._exitCode = proc.status === "completed" ? proc.exitCode ?? 0 : proc.exitCode ?? 1;
252
+ } catch {
253
+ if (this._exitCode === void 0) this._exitCode = 1;
254
+ }
255
+ }
256
+ async wait() {
257
+ if (!this._waitPromise) this._waitPromise = this._doWait();
258
+ return this._waitPromise;
259
+ }
260
+ async _doWait() {
261
+ if (this._streamingDone) await this._streamingDone.catch(() => {});
262
+ if (this._killed) return {
263
+ success: false,
264
+ exitCode: this._exitCode ?? 137,
265
+ stdout: this.stdout,
266
+ stderr: this.stderr,
267
+ executionTimeMs: Date.now() - this._startTime
268
+ };
269
+ await this._resolveExitCode();
270
+ return {
271
+ success: this._exitCode === 0,
272
+ exitCode: this._exitCode ?? 1,
273
+ stdout: this.stdout,
274
+ stderr: this.stderr,
275
+ executionTimeMs: Date.now() - this._startTime
276
+ };
277
+ }
278
+ async kill() {
279
+ if (this._exitCode !== void 0) return false;
280
+ this._killed = true;
281
+ this._exitCode = 137;
282
+ this._closeStream?.();
283
+ try {
284
+ await this._sandbox.process.kill(this.pid);
285
+ } catch {}
286
+ return true;
287
+ }
288
+ async sendStdin(_data) {
289
+ throw new Error("Blaxel sandboxes do not support stdin");
290
+ }
360
291
  };
292
+ /**
293
+ * Blaxel implementation of SandboxProcessManager.
294
+ * Uses the Blaxel SDK's process API for background process management.
295
+ */
361
296
  var BlaxelProcessManager = class extends SandboxProcessManager {
362
- constructor(opts = {}) {
363
- super({ env: opts.env });
364
- }
365
- async spawn(command, options = {}) {
366
- return this.sandbox.retryOnDead(async () => {
367
- const blaxel = this.sandbox.blaxel;
368
- const mergedEnv = { ...this.env, ...options.env };
369
- const envs = Object.fromEntries(
370
- Object.entries(mergedEnv).filter((entry) => entry[1] !== void 0)
371
- );
372
- const result = await blaxel.process.exec({
373
- command,
374
- waitForCompletion: false,
375
- workingDir: options.cwd,
376
- ...Object.keys(envs).length > 0 && { env: envs },
377
- ...options.timeout && { timeout: Math.ceil(options.timeout / 1e3) }
378
- });
379
- const pid = result.pid;
380
- const handle = new BlaxelProcessHandle(pid, blaxel, Date.now(), options);
381
- const streamControl = blaxel.process.streamLogs(pid, {
382
- onStdout: (data) => handle.emitStdout(data),
383
- onStderr: (data) => handle.emitStderr(data),
384
- onError: (err) => {
385
- const msg = err instanceof Error ? err.message : String(err);
386
- handle.emitStderr(msg);
387
- }
388
- });
389
- handle.streamControl = streamControl;
390
- this._tracked.set(pid, handle);
391
- return handle;
392
- });
393
- }
394
- async list() {
395
- const result = [];
396
- for (const [pid, handle] of this._tracked) {
397
- result.push({
398
- pid,
399
- command: handle.command,
400
- running: handle.exitCode === void 0,
401
- exitCode: handle.exitCode
402
- });
403
- }
404
- return result;
405
- }
297
+ constructor(opts = {}) {
298
+ super({ env: opts.env });
299
+ }
300
+ async spawn(command, options = {}) {
301
+ return this.sandbox.retryOnDead(async () => {
302
+ const blaxel = this.sandbox.blaxel;
303
+ const mergedEnv = {
304
+ ...this.env,
305
+ ...options.env
306
+ };
307
+ const envs = Object.fromEntries(Object.entries(mergedEnv).filter((entry) => entry[1] !== void 0));
308
+ const pid = (await blaxel.process.exec({
309
+ command,
310
+ waitForCompletion: false,
311
+ workingDir: options.cwd,
312
+ ...Object.keys(envs).length > 0 && { env: envs },
313
+ ...options.timeout && { timeout: Math.ceil(options.timeout / 1e3) }
314
+ })).pid;
315
+ const handle = new BlaxelProcessHandle(pid, blaxel, Date.now(), options);
316
+ handle.streamControl = blaxel.process.streamLogs(pid, {
317
+ onStdout: (data) => handle.emitStdout(data),
318
+ onStderr: (data) => handle.emitStderr(data),
319
+ onError: (err) => {
320
+ const msg = err instanceof Error ? err.message : String(err);
321
+ handle.emitStderr(msg);
322
+ }
323
+ });
324
+ this._tracked.set(pid, handle);
325
+ return handle;
326
+ });
327
+ }
328
+ async list() {
329
+ const result = [];
330
+ for (const [pid, handle] of this._tracked) result.push({
331
+ pid,
332
+ command: handle.command,
333
+ running: handle.exitCode === void 0,
334
+ exitCode: handle.exitCode
335
+ });
336
+ return result;
337
+ }
406
338
  };
407
-
408
- // src/sandbox/index.ts
409
- var SAFE_MOUNT_PATH = /^\/[a-zA-Z0-9_.\-/]+$/;
339
+ //#endregion
340
+ //#region src/sandbox/index.ts
341
+ /**
342
+ * Blaxel Sandbox Provider
343
+ *
344
+ * A Blaxel sandbox implementation that supports mounting
345
+ * cloud filesystems (S3, GCS, R2) via FUSE.
346
+ *
347
+ * @see https://docs.blaxel.ai
348
+ */
349
+ /** Allowlist pattern for mount paths — absolute path with safe characters only. */
350
+ const SAFE_MOUNT_PATH = /^\/[a-zA-Z0-9_.\-/]+$/;
351
+ /** Convert an unknown error to a readable string. */
410
352
  function errorToString(error) {
411
- if (error instanceof Error) return error.message;
412
- if (typeof error === "string") return error;
413
- if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
414
- return error.message;
415
- }
416
- try {
417
- return JSON.stringify(error);
418
- } catch {
419
- return String(error);
420
- }
353
+ if (error instanceof Error) return error.message;
354
+ if (typeof error === "string") return error;
355
+ if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message;
356
+ try {
357
+ return JSON.stringify(error);
358
+ } catch {
359
+ return String(error);
360
+ }
421
361
  }
422
362
  function validateMountPath(mountPath) {
423
- if (!SAFE_MOUNT_PATH.test(mountPath)) {
424
- throw new Error(
425
- `Invalid mount path: ${mountPath}. Must be an absolute path with alphanumeric, dash, dot, underscore, or slash characters only.`
426
- );
427
- }
363
+ 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.`);
428
364
  }
429
- var SAFE_MARKER_NAME = /^mount-[a-z0-9]+$/;
430
- var BlaxelSandbox = class _BlaxelSandbox extends MastraSandbox {
431
- id;
432
- name = "BlaxelSandbox";
433
- provider = "blaxel";
434
- // Status is managed by base class lifecycle methods
435
- status = "pending";
436
- _sandbox = null;
437
- _createdAt = null;
438
- _isRetrying = false;
439
- image;
440
- memory;
441
- timeout;
442
- region;
443
- env;
444
- labels;
445
- configuredRuntimes;
446
- ports;
447
- _constructorOptions;
448
- // Non-optional (initialized by BaseSandbox)
449
- constructor(options = {}) {
450
- super({
451
- ...options,
452
- name: "BlaxelSandbox",
453
- processes: new BlaxelProcessManager({ env: options.env })
454
- });
455
- this.id = options.id ?? this.generateId();
456
- this.image = options.image ?? "blaxel/ts-app:latest";
457
- this.memory = options.memory ?? 4096;
458
- this.timeout = options.timeout;
459
- this.region = options.region || process.env.BL_REGION || "auto";
460
- this.env = options.env ?? {};
461
- this.labels = options.labels ?? {};
462
- this.configuredRuntimes = options.runtimes ?? ["node", "python", "bash"];
463
- this.ports = options.ports ?? [];
464
- this._constructorOptions = { ...options };
465
- }
466
- generateId() {
467
- return `blaxel-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
468
- }
469
- /**
470
- * Construct a sibling `BlaxelSandbox` that inherits this sandbox's
471
- * configuration (image, memory, region, runtimes, ports, labels) with
472
- * per-instance overrides.
473
- *
474
- * Performs no I/O — the sandbox clone provisions (or reconnects to an
475
- * existing Blaxel sandbox with the same logical `id` via
476
- * `createIfNotExists`) on its own `start()`. Use it when one configured
477
- * sandbox acts as the template for a fleet of independent sandboxes
478
- * (e.g. one per project).
479
- *
480
- * `options.idleTimeoutMinutes` maps to Blaxel's `timeout` (TTL duration
481
- * string); `options.sandboxId` is ignored because Blaxel reconnects by
482
- * logical `id`.
483
- */
484
- clone(options = {}) {
485
- const { id: _id, ...base } = this._constructorOptions;
486
- return new _BlaxelSandbox({
487
- ...base,
488
- ...options.id !== void 0 && { id: options.id },
489
- ...options.env !== void 0 && { env: options.env },
490
- ...options.idleTimeoutMinutes !== void 0 && { timeout: `${options.idleTimeoutMinutes}m` }
491
- });
492
- }
493
- get supportedRuntimes() {
494
- return this.configuredRuntimes;
495
- }
496
- get defaultRuntime() {
497
- return this.configuredRuntimes[0] ?? "node";
498
- }
499
- /**
500
- * Get the underlying Blaxel SandboxInstance for direct access to Blaxel APIs.
501
- *
502
- * Use this when you need to access Blaxel features not exposed through the
503
- * WorkspaceSandbox interface (e.g., filesystem, process management, previews, etc.).
504
- *
505
- * @throws {SandboxNotReadyError} If the sandbox has not been started
506
- *
507
- * @example Direct file operations
508
- * ```typescript
509
- * await sandbox.start();
510
- * const blaxelSandbox = sandbox.blaxel;
511
- * await blaxelSandbox.fs.write('/tmp/test.txt', 'Hello');
512
- * const content = await blaxelSandbox.fs.read('/tmp/test.txt');
513
- * const files = await blaxelSandbox.fs.ls('/tmp');
514
- * ```
515
- *
516
- * @example Process management
517
- * ```typescript
518
- * await sandbox.start();
519
- * const blaxelSandbox = sandbox.blaxel;
520
- * const proc = await blaxelSandbox.process.exec({
521
- * command: 'node server.js',
522
- * waitForCompletion: false,
523
- * });
524
- * ```
525
- */
526
- get blaxel() {
527
- if (!this._sandbox) {
528
- throw new SandboxNotReadyError(this.id);
529
- }
530
- return this._sandbox;
531
- }
532
- /** @deprecated Use `blaxel` instead. */
533
- get instance() {
534
- return this.blaxel;
535
- }
536
- // ---------------------------------------------------------------------------
537
- // Mount Support
538
- // ---------------------------------------------------------------------------
539
- /**
540
- * Mount a filesystem at a path in the sandbox.
541
- * Uses FUSE tools (s3fs, gcsfuse) to mount cloud storage.
542
- */
543
- async mount(filesystem, mountPath) {
544
- validateMountPath(mountPath);
545
- if (!this._sandbox) {
546
- throw new SandboxNotReadyError(this.id);
547
- }
548
- this.logger.debug(`${LOG_PREFIX} Mounting "${mountPath}"...`);
549
- const config = filesystem.getMountConfig?.();
550
- if (!config) {
551
- const error = `Filesystem "${filesystem.id}" does not provide a mount config`;
552
- this.logger.error(`${LOG_PREFIX} ${error}`);
553
- this.mounts.set(mountPath, { filesystem, state: "error", error });
554
- return { success: false, mountPath, error };
555
- }
556
- const existingMount = await this.checkExistingMount(mountPath, config);
557
- if (existingMount === "matching") {
558
- this.logger.debug(
559
- `${LOG_PREFIX} Detected existing mount for ${filesystem.provider} ("${filesystem.id}") at "${mountPath}" with correct config, skipping`
560
- );
561
- this.mounts.set(mountPath, { state: "mounted", config });
562
- return { success: true, mountPath };
563
- } else if (existingMount === "mismatched") {
564
- this.logger.debug(`${LOG_PREFIX} Config mismatch, unmounting to re-mount with new config...`);
565
- await this.unmount(mountPath);
566
- }
567
- this.logger.debug(`${LOG_PREFIX} Config type: ${config.type}`);
568
- this.mounts.set(mountPath, { filesystem, state: "mounting", config });
569
- try {
570
- const checkResult = await runCommand(
571
- this._sandbox,
572
- `[ -d "${mountPath}" ] && [ "$(ls -A "${mountPath}" 2>/dev/null)" ] && echo "non-empty" || echo "ok"`
573
- );
574
- if (checkResult.stdout.trim() === "non-empty") {
575
- 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.`;
576
- this.logger.error(`${LOG_PREFIX} ${error}`);
577
- this.mounts.set(mountPath, { filesystem, state: "error", config, error });
578
- return { success: false, mountPath, error };
579
- }
580
- } catch {
581
- }
582
- this.logger.debug(`${LOG_PREFIX} Creating mount directory for ${mountPath}...`);
583
- const mkdirCommand = `mkdir -p "${mountPath}"`;
584
- this.logger.debug(`${LOG_PREFIX} Running command: ${mkdirCommand}`);
585
- const mkdirResult = await runCommand(this._sandbox, mkdirCommand);
586
- if (mkdirResult.exitCode !== 0) {
587
- const mkdirError = `Failed to create mount directory "${mountPath}": ${mkdirResult.stderr || mkdirResult.stdout}`;
588
- this.logger.debug(`${LOG_PREFIX} mkdir error for "${mountPath}":`, mkdirError);
589
- this.mounts.set(mountPath, { filesystem, state: "error", config, error: mkdirError });
590
- return { success: false, mountPath, error: mkdirError };
591
- }
592
- this.logger.debug(`${LOG_PREFIX} Created mount directory for mount path "${mountPath}":`, mkdirResult);
593
- const mountCtx = {
594
- sandbox: this._sandbox,
595
- logger: this.logger
596
- };
597
- try {
598
- switch (config.type) {
599
- case "s3":
600
- this.logger.debug(`${LOG_PREFIX} Mounting S3 bucket at ${mountPath}...`);
601
- await mountS3(mountPath, config, mountCtx);
602
- this.logger.debug(`${LOG_PREFIX} Mounted S3 bucket at ${mountPath}`);
603
- break;
604
- case "gcs":
605
- this.logger.debug(`${LOG_PREFIX} Mounting GCS bucket at ${mountPath}...`);
606
- await mountGCS(mountPath, config, mountCtx);
607
- this.logger.debug(`${LOG_PREFIX} Mounted GCS bucket at ${mountPath}`);
608
- break;
609
- default:
610
- this.mounts.set(mountPath, {
611
- filesystem,
612
- state: "unsupported",
613
- config,
614
- error: `Unsupported mount type: ${config.type}`
615
- });
616
- return {
617
- success: false,
618
- mountPath,
619
- error: `Unsupported mount type: ${config.type}`
620
- };
621
- }
622
- } catch (error) {
623
- this.logger.error(
624
- `${LOG_PREFIX} Error mounting "${filesystem.provider}" (${filesystem.id}) at "${mountPath}":`,
625
- error
626
- );
627
- this.mounts.set(mountPath, { filesystem, state: "error", config, error: errorToString(error) });
628
- try {
629
- await runCommand(this._sandbox, `rmdir "${mountPath}" 2>/dev/null || true`);
630
- this.logger.debug(`${LOG_PREFIX} Cleaned up directory after failed mount: ${mountPath}`);
631
- } catch {
632
- }
633
- return { success: false, mountPath, error: errorToString(error) };
634
- }
635
- this.mounts.set(mountPath, { state: "mounted", config });
636
- await this.writeMarkerFile(mountPath);
637
- this.logger.debug(`${LOG_PREFIX} Mounted ${mountPath}`);
638
- return { success: true, mountPath };
639
- }
640
- /**
641
- * Write marker file for detecting config changes on reconnect.
642
- * Stores both the mount path and config hash in the file.
643
- */
644
- async writeMarkerFile(mountPath) {
645
- if (!this._sandbox) return;
646
- const markerContent = this.mounts.getMarkerContent(mountPath);
647
- if (!markerContent) return;
648
- const filename = this.mounts.markerFilename(mountPath);
649
- const markerPath = `/tmp/.mastra-mounts/${filename}`;
650
- try {
651
- await runCommand(this._sandbox, "mkdir -p /tmp/.mastra-mounts");
652
- await this._sandbox.fs.write(markerPath, markerContent);
653
- } catch {
654
- this.logger.debug(`${LOG_PREFIX} Warning: Could not write marker file at ${markerPath}`);
655
- }
656
- }
657
- /**
658
- * Unmount a filesystem from a path in the sandbox.
659
- */
660
- async unmount(mountPath) {
661
- validateMountPath(mountPath);
662
- if (!this._sandbox) {
663
- throw new SandboxNotReadyError(this.id);
664
- }
665
- this.logger.debug(`${LOG_PREFIX} Unmounting ${mountPath}...`);
666
- try {
667
- const result = await runCommand(
668
- this._sandbox,
669
- `fusermount -u "${mountPath}" 2>/dev/null || umount "${mountPath}"`
670
- );
671
- if (result.exitCode !== 0) {
672
- this.logger.debug(`${LOG_PREFIX} Unmount warning: ${result.stderr || result.stdout}`);
673
- }
674
- } catch (error) {
675
- this.logger.debug(`${LOG_PREFIX} Unmount error:`, error);
676
- await runCommand(this._sandbox, `umount -l "${mountPath}" 2>/dev/null || true`);
677
- }
678
- this.mounts.delete(mountPath);
679
- const filename = this.mounts.markerFilename(mountPath);
680
- const markerPath = `/tmp/.mastra-mounts/${filename}`;
681
- await runCommand(this._sandbox, `rm -f "${markerPath}" 2>/dev/null || true`);
682
- const rmdirResult = await runCommand(this._sandbox, `rmdir "${mountPath}" 2>&1`);
683
- if (rmdirResult.exitCode === 0) {
684
- this.logger.debug(`${LOG_PREFIX} Unmounted and removed ${mountPath}`);
685
- } else {
686
- this.logger.debug(
687
- `${LOG_PREFIX} Unmounted ${mountPath} (directory not removed: ${rmdirResult.stderr?.trim() || "not empty"})`
688
- );
689
- }
690
- }
691
- /**
692
- * Get list of current mounts in the sandbox.
693
- */
694
- async getMounts() {
695
- return Array.from(this.mounts.entries).map(([path, entry]) => ({
696
- path,
697
- filesystem: entry.filesystem?.provider ?? entry.config?.type ?? "unknown"
698
- }));
699
- }
700
- /**
701
- * Unmount all stale mounts that are not in the expected mounts list.
702
- * Also cleans up orphaned directories and marker files from failed mount attempts.
703
- * Call this after reconnecting to an existing sandbox to clean up old mounts.
704
- */
705
- async reconcileMounts(expectedMountPaths) {
706
- if (!this._sandbox) {
707
- throw new SandboxNotReadyError(this.id);
708
- }
709
- this.logger.debug(`${LOG_PREFIX} Reconciling mounts. Expected paths:`, expectedMountPaths);
710
- const mountsResult = await runCommand(
711
- this._sandbox,
712
- `grep -E 'fuse\\.(s3fs|gcsfuse)' /proc/mounts | awk '{print $2}' || true`
713
- );
714
- const currentMounts = mountsResult.stdout.trim().split("\n").filter((p) => p.length > 0);
715
- this.logger.debug(`${LOG_PREFIX} Current FUSE mounts in sandbox:`, currentMounts);
716
- const markersResult = await runCommand(this._sandbox, `ls /tmp/.mastra-mounts/ 2>/dev/null || echo ""`);
717
- const markerFiles = markersResult.stdout.trim().split("\n").filter((f) => f.length > 0 && SAFE_MARKER_NAME.test(f));
718
- const managedMountPaths = /* @__PURE__ */ new Map();
719
- for (const markerFile of markerFiles) {
720
- const markerResult = await runCommand(
721
- this._sandbox,
722
- `cat "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || echo ""`
723
- );
724
- const parsed = this.mounts.parseMarkerContent(markerResult.stdout.trim());
725
- if (parsed && SAFE_MOUNT_PATH.test(parsed.path)) {
726
- managedMountPaths.set(parsed.path, markerFile);
727
- }
728
- }
729
- const staleMounts = currentMounts.filter((path) => !expectedMountPaths.includes(path));
730
- for (const stalePath of staleMounts) {
731
- if (managedMountPaths.has(stalePath)) {
732
- this.logger.debug(`${LOG_PREFIX} Found stale managed FUSE mount at ${stalePath}, unmounting...`);
733
- await this.unmount(stalePath);
734
- } else {
735
- this.logger.debug(`${LOG_PREFIX} Found external FUSE mount at ${stalePath}, leaving untouched`);
736
- }
737
- }
738
- try {
739
- const expectedMarkerFiles = new Set(expectedMountPaths.map((p) => this.mounts.markerFilename(p)));
740
- const markerToPath = /* @__PURE__ */ new Map();
741
- for (const [path, file] of managedMountPaths) {
742
- markerToPath.set(file, path);
743
- }
744
- for (const markerFile of markerFiles) {
745
- if (!expectedMarkerFiles.has(markerFile)) {
746
- const mountPath = markerToPath.get(markerFile);
747
- if (mountPath) {
748
- if (!currentMounts.includes(mountPath)) {
749
- this.logger.debug(`${LOG_PREFIX} Cleaning up orphaned marker and directory for ${mountPath}`);
750
- await runCommand(this._sandbox, `rm -f "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || true`);
751
- await runCommand(this._sandbox, `rmdir "${mountPath}" 2>/dev/null || true`);
752
- }
753
- } else {
754
- this.logger.debug(`${LOG_PREFIX} Removing malformed marker file: ${markerFile}`);
755
- await runCommand(this._sandbox, `rm -f "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || true`);
756
- }
757
- }
758
- }
759
- } catch {
760
- this.logger.debug(`${LOG_PREFIX} Error during orphan cleanup (non-fatal)`);
761
- }
762
- }
763
- /**
764
- * Check if a path is already mounted and if the config matches.
765
- *
766
- * @param mountPath - The mount path to check
767
- * @param newConfig - The new config to compare against the stored config
768
- * @returns 'not_mounted' | 'matching' | 'mismatched'
769
- */
770
- async checkExistingMount(mountPath, newConfig) {
771
- if (!this._sandbox) throw new SandboxNotReadyError(this.id);
772
- const mountCheck = await runCommand(
773
- this._sandbox,
774
- `mountpoint -q "${mountPath}" && echo "mounted" || echo "not mounted"`
775
- );
776
- if (mountCheck.stdout.trim() !== "mounted") {
777
- return "not_mounted";
778
- }
779
- const filename = this.mounts.markerFilename(mountPath);
780
- const markerPath = `/tmp/.mastra-mounts/${filename}`;
781
- try {
782
- const markerResult = await runCommand(this._sandbox, `cat "${markerPath}" 2>/dev/null || echo ""`);
783
- const parsed = this.mounts.parseMarkerContent(markerResult.stdout.trim());
784
- if (!parsed) {
785
- return "mismatched";
786
- }
787
- const newConfigHash = this.mounts.computeConfigHash(newConfig);
788
- this.logger.debug(
789
- `${LOG_PREFIX} Marker check - stored hash: "${parsed.configHash}", new config hash: "${newConfigHash}"`
790
- );
791
- if (parsed.path === mountPath && parsed.configHash === newConfigHash) {
792
- return "matching";
793
- }
794
- } catch {
795
- }
796
- return "mismatched";
797
- }
798
- // ---------------------------------------------------------------------------
799
- // Lifecycle (overrides base class protected methods)
800
- // ---------------------------------------------------------------------------
801
- /**
802
- * Start the Blaxel sandbox.
803
- * Uses `createIfNotExists` to reconnect to an existing sandbox or create a new one.
804
- *
805
- * Status management and mount processing are handled by the base class.
806
- */
807
- async start() {
808
- if (this._sandbox) {
809
- return;
810
- }
811
- const sandboxName = this.toSandboxName(this.id);
812
- this.logger.debug(`${LOG_PREFIX} Starting sandbox: ${sandboxName}`);
813
- const existingSandbox = await this.findExistingSandbox(sandboxName);
814
- if (existingSandbox) {
815
- this._sandbox = existingSandbox;
816
- this._createdAt = /* @__PURE__ */ new Date();
817
- this.logger.debug(`${LOG_PREFIX} Reconnected to existing sandbox: ${sandboxName}`);
818
- const expectedPaths = Array.from(this.mounts.entries.keys());
819
- this.logger.debug(`${LOG_PREFIX} Running mount reconciliation...`);
820
- await this.reconcileMounts(expectedPaths);
821
- this.logger.debug(`${LOG_PREFIX} Mount reconciliation complete`);
822
- return;
823
- }
824
- this.logger.debug(`${LOG_PREFIX} Creating new sandbox: ${sandboxName}`);
825
- try {
826
- this._sandbox = await SandboxInstance.create({
827
- name: sandboxName,
828
- image: this.image,
829
- memory: this.memory,
830
- region: this.region,
831
- ...this.timeout && { ttl: this.timeout },
832
- labels: {
833
- ...this.labels,
834
- "mastra-sandbox-id": this.id
835
- },
836
- ports: this.ports.map((p) => ({
837
- name: p.name,
838
- target: p.target,
839
- protocol: p.protocol ?? "HTTP"
840
- }))
841
- });
842
- } catch (createError) {
843
- if (createError instanceof Error) {
844
- throw createError;
845
- }
846
- throw new Error(errorToString(createError));
847
- }
848
- this._createdAt = /* @__PURE__ */ new Date();
849
- this.logger.debug(`${LOG_PREFIX} Sandbox ready: ${sandboxName} (status: ${this._sandbox.status})`);
850
- }
851
- /**
852
- * Convert a logical sandbox ID to a valid Blaxel sandbox name.
853
- * Blaxel sandbox names must be DNS-safe (lowercase alphanumeric and hyphens).
854
- */
855
- toSandboxName(id) {
856
- const name = id.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 63);
857
- if (!name) {
858
- throw new Error(
859
- `Cannot derive a valid sandbox name from id "${id}". ID must contain at least one alphanumeric character.`
860
- );
861
- }
862
- return name;
863
- }
864
- /**
865
- * Find an existing sandbox with the given name.
866
- * Returns the connected sandbox if found and running, null otherwise.
867
- */
868
- async findExistingSandbox(sandboxName) {
869
- try {
870
- const existing = await SandboxInstance.get(sandboxName);
871
- if (existing.status === "DEPLOYED") {
872
- this.logger.debug(`${LOG_PREFIX} Found existing sandbox: ${sandboxName} (status: ${existing.status})`);
873
- return existing;
874
- }
875
- this.logger.debug(
876
- `${LOG_PREFIX} Found sandbox ${sandboxName} but status is ${existing.status}, creating new one`
877
- );
878
- } catch (e) {
879
- this.logger.debug(`${LOG_PREFIX} No existing sandbox found for ${sandboxName}:`, e);
880
- }
881
- return null;
882
- }
883
- /**
884
- * Stop the Blaxel sandbox.
885
- * Unmounts all filesystems and releases the sandbox reference.
886
- * Status management is handled by the base class.
887
- */
888
- async stop() {
889
- if (this.processes) {
890
- try {
891
- const procs = await this.processes.list();
892
- await Promise.all(procs.filter((p) => p.running).map((p) => this.processes.kill(p.pid)));
893
- } catch {
894
- }
895
- }
896
- for (const mountPath of [...this.mounts.entries.keys()]) {
897
- try {
898
- await this.unmount(mountPath);
899
- } catch {
900
- }
901
- }
902
- this._sandbox = null;
903
- }
904
- /**
905
- * Destroy the Blaxel sandbox and clean up all resources.
906
- * Unmounts filesystems, deletes the sandbox, and clears mount state.
907
- * Status management is handled by the base class.
908
- */
909
- async destroy() {
910
- if (this.processes) {
911
- try {
912
- const procs = await this.processes.list();
913
- await Promise.all(procs.filter((p) => p.running).map((p) => this.processes.kill(p.pid)));
914
- } catch {
915
- }
916
- }
917
- for (const mountPath of [...this.mounts.entries.keys()]) {
918
- try {
919
- await this.unmount(mountPath);
920
- } catch {
921
- }
922
- }
923
- if (this._sandbox) {
924
- try {
925
- await this._sandbox.delete();
926
- } catch {
927
- }
928
- }
929
- this._sandbox = null;
930
- this.mounts.clear();
931
- }
932
- /**
933
- * Check if the sandbox is ready for operations.
934
- */
935
- async isReady() {
936
- return this.status === "running" && this._sandbox !== null;
937
- }
938
- /**
939
- * Get information about the current state of the sandbox.
940
- */
941
- async getInfo() {
942
- return {
943
- id: this.id,
944
- name: this.name,
945
- provider: this.provider,
946
- status: this.status,
947
- createdAt: this._createdAt ?? /* @__PURE__ */ new Date(),
948
- mounts: Array.from(this.mounts.entries).map(([path, entry]) => ({
949
- path,
950
- filesystem: entry.filesystem?.provider ?? entry.config?.type ?? "unknown"
951
- })),
952
- metadata: {
953
- ...this.labels,
954
- image: this.image,
955
- memory: this.memory,
956
- sandboxStatus: this._sandbox?.status
957
- }
958
- };
959
- }
960
- /**
961
- * Get instructions describing this Blaxel sandbox.
962
- * Used by agents to understand the execution environment.
963
- */
964
- getInstructions() {
965
- const mountCount = this.mounts.entries.size;
966
- const mountInfo = mountCount > 0 ? ` ${mountCount} filesystem(s) mounted via FUSE.` : "";
967
- return `Cloud sandbox.${mountInfo}`;
968
- }
969
- // ---------------------------------------------------------------------------
970
- // Internal Helpers
971
- // ---------------------------------------------------------------------------
972
- /**
973
- * Ensure the sandbox is started and return the Blaxel SandboxInstance.
974
- * Uses base class ensureRunning() for status management and error handling.
975
- * @throws {SandboxNotReadyError} if sandbox fails to start
976
- */
977
- async ensureSandbox() {
978
- await this.ensureRunning();
979
- return this._sandbox;
980
- }
981
- /**
982
- * Check if an error indicates the sandbox itself is dead/gone.
983
- * Does NOT include code execution timeouts (those are the user's code taking too long).
984
- */
985
- isSandboxDeadError(error) {
986
- if (!error) return false;
987
- const errorStr = errorToString(error).toLowerCase();
988
- return errorStr.includes("terminated") || errorStr.includes("sandbox was not found") || errorStr.includes("sandbox not found") || errorStr.includes('"not found"');
989
- }
990
- /**
991
- * Handle sandbox timeout by clearing the instance and resetting state.
992
- *
993
- * Bypasses the normal stop() lifecycle because the sandbox is already dead —
994
- * we can't unmount filesystems or run cleanup commands. Instead we reset
995
- * mount states to 'pending' so they get re-mounted when start() runs again.
996
- */
997
- handleSandboxTimeout() {
998
- this._sandbox = null;
999
- for (const [path, entry] of this.mounts.entries) {
1000
- if (entry.state === "mounted" || entry.state === "mounting") {
1001
- this.mounts.set(path, { state: "pending" });
1002
- }
1003
- }
1004
- this.status = "stopped";
1005
- }
1006
- /**
1007
- * Execute an operation with automatic retry if the sandbox is found to be dead.
1008
- *
1009
- * When the Blaxel sandbox times out or crashes mid-operation, this method
1010
- * resets sandbox state, restarts it, and retries the operation once.
1011
- *
1012
- * @internal Used by BlaxelProcessManager to handle dead sandboxes during spawn.
1013
- */
1014
- async retryOnDead(fn) {
1015
- try {
1016
- return await fn();
1017
- } catch (error) {
1018
- if (this.isSandboxDeadError(error) && !this._isRetrying) {
1019
- this.handleSandboxTimeout();
1020
- this._isRetrying = true;
1021
- try {
1022
- await this.ensureRunning();
1023
- return await fn();
1024
- } finally {
1025
- this._isRetrying = false;
1026
- }
1027
- }
1028
- throw error;
1029
- }
1030
- }
1031
- // ---------------------------------------------------------------------------
1032
- // Command Execution
1033
- // ---------------------------------------------------------------------------
1034
- /**
1035
- * Execute a shell command in the sandbox.
1036
- * Automatically starts the sandbox if not already running.
1037
- * Retries once if the sandbox is found to be dead.
1038
- */
1039
- async executeCommand(command, args = [], options = {}) {
1040
- this.logger.debug(`${LOG_PREFIX} Executing: ${command} ${args.join(" ")}`, options);
1041
- const sandbox = await this.ensureSandbox();
1042
- const startTime = Date.now();
1043
- const fullCommand = args.length > 0 ? `${command} ${args.map(shellQuote).join(" ")}` : command;
1044
- this.logger.debug(`${LOG_PREFIX} Executing: ${fullCommand}`);
1045
- let capturedStdout = "";
1046
- let capturedStderr = "";
1047
- try {
1048
- const mergedEnv = { ...this.env, ...options.env };
1049
- const envRecord = Object.fromEntries(
1050
- Object.entries(mergedEnv).filter((entry) => entry[1] !== void 0)
1051
- );
1052
- const apiTimeout = options.timeout ? Math.ceil(options.timeout / 1e3) : void 0;
1053
- const execPromise = sandbox.process.exec({
1054
- command: fullCommand,
1055
- workingDir: options.cwd,
1056
- env: envRecord,
1057
- waitForCompletion: true,
1058
- ...apiTimeout && { timeout: apiTimeout },
1059
- onStdout: (data) => {
1060
- capturedStdout += data;
1061
- options.onStdout?.(data);
1062
- },
1063
- onStderr: (data) => {
1064
- capturedStderr += data;
1065
- options.onStderr?.(data);
1066
- }
1067
- });
1068
- const racePromises = [];
1069
- let timer;
1070
- let abortHandler;
1071
- if (options.timeout) {
1072
- racePromises.push(
1073
- new Promise((_, reject) => {
1074
- timer = setTimeout(() => {
1075
- runCommand(sandbox, `pkill -f ${shellQuote(fullCommand)}`, { timeout: 5e3 }).catch(() => {
1076
- });
1077
- reject(new Error(`Command timed out after ${options.timeout}ms`));
1078
- }, options.timeout);
1079
- })
1080
- );
1081
- }
1082
- if (options.abortSignal) {
1083
- if (options.abortSignal.aborted) {
1084
- runCommand(sandbox, `pkill -f ${shellQuote(fullCommand)}`, { timeout: 5e3 }).catch(() => {
1085
- });
1086
- throw new Error("Process aborted");
1087
- }
1088
- racePromises.push(
1089
- new Promise((_, reject) => {
1090
- abortHandler = () => {
1091
- runCommand(sandbox, `pkill -f ${shellQuote(fullCommand)}`, { timeout: 5e3 }).catch(() => {
1092
- });
1093
- reject(new Error("Process aborted"));
1094
- };
1095
- options.abortSignal.addEventListener("abort", abortHandler, { once: true });
1096
- })
1097
- );
1098
- }
1099
- let result;
1100
- try {
1101
- if (racePromises.length > 0) {
1102
- result = await Promise.race([execPromise, ...racePromises]);
1103
- } else {
1104
- result = await execPromise;
1105
- }
1106
- } finally {
1107
- if (timer) clearTimeout(timer);
1108
- if (abortHandler && options.abortSignal) {
1109
- options.abortSignal.removeEventListener("abort", abortHandler);
1110
- }
1111
- }
1112
- const executionTimeMs = Date.now() - startTime;
1113
- const exitCode = result.exitCode ?? 0;
1114
- const stdout = capturedStdout || result.stdout || "";
1115
- const stderr = capturedStderr || result.stderr || "";
1116
- this.logger.debug(`${LOG_PREFIX} Exit code: ${exitCode} (${executionTimeMs}ms)`);
1117
- if (stdout) this.logger.debug(`${LOG_PREFIX} stdout:
1118
- ${stdout}`);
1119
- if (stderr) this.logger.debug(`${LOG_PREFIX} stderr:
1120
- ${stderr}`);
1121
- return {
1122
- success: exitCode === 0,
1123
- exitCode,
1124
- stdout,
1125
- stderr,
1126
- executionTimeMs,
1127
- command,
1128
- args
1129
- };
1130
- } catch (error) {
1131
- if (this.isSandboxDeadError(error) && !this._isRetrying) {
1132
- this.handleSandboxTimeout();
1133
- this._isRetrying = true;
1134
- try {
1135
- return await this.executeCommand(command, args, options);
1136
- } finally {
1137
- this._isRetrying = false;
1138
- }
1139
- }
1140
- const executionTimeMs = Date.now() - startTime;
1141
- return {
1142
- success: false,
1143
- exitCode: 1,
1144
- stdout: capturedStdout,
1145
- stderr: capturedStderr || errorToString(error),
1146
- executionTimeMs,
1147
- command,
1148
- args
1149
- };
1150
- }
1151
- }
365
+ /** Allowlist for marker filenames from ls output — e.g. "mount-abc123" */
366
+ const SAFE_MARKER_NAME = /^mount-[a-z0-9]+$/;
367
+ /**
368
+ * Blaxel cloud sandbox implementation.
369
+ *
370
+ * Features:
371
+ * - Single sandbox instance lifecycle
372
+ * - Supports mounting cloud filesystems (S3, GCS, R2) via FUSE
373
+ * - Automatic sandbox reconnection via `createIfNotExists`
374
+ * - Automatic sandbox timeout handling with retry
375
+ *
376
+ * @example Basic usage
377
+ * ```typescript
378
+ * import { Workspace } from '@mastra/core/workspace';
379
+ * import { BlaxelSandbox } from '@mastra/blaxel';
380
+ *
381
+ * const sandbox = new BlaxelSandbox({
382
+ * timeout: '5m',
383
+ * });
384
+ *
385
+ * const workspace = new Workspace({ sandbox });
386
+ * const result = await workspace.executeCode('console.log("Hello!")');
387
+ * ```
388
+ *
389
+ * @example With S3 filesystem mounting
390
+ * ```typescript
391
+ * import { Workspace } from '@mastra/core/workspace';
392
+ * import { BlaxelSandbox } from '@mastra/blaxel';
393
+ * import { S3Filesystem } from '@mastra/s3';
394
+ *
395
+ * const workspace = new Workspace({
396
+ * mounts: {
397
+ * '/bucket': new S3Filesystem({
398
+ * bucket: 'my-bucket',
399
+ * region: 'us-east-1',
400
+ * }),
401
+ * },
402
+ * sandbox: new BlaxelSandbox({ timeout: '5m' }),
403
+ * });
404
+ * ```
405
+ */
406
+ var BlaxelSandbox = class BlaxelSandbox extends MastraSandbox {
407
+ id;
408
+ name = "BlaxelSandbox";
409
+ provider = "blaxel";
410
+ status = "pending";
411
+ _sandbox = null;
412
+ _createdAt = null;
413
+ _isRetrying = false;
414
+ image;
415
+ memory;
416
+ timeout;
417
+ region;
418
+ env;
419
+ labels;
420
+ configuredRuntimes;
421
+ ports;
422
+ _constructorOptions;
423
+ constructor(options = {}) {
424
+ super({
425
+ ...options,
426
+ name: "BlaxelSandbox",
427
+ processes: new BlaxelProcessManager({ env: options.env })
428
+ });
429
+ this.id = options.id ?? this.generateId();
430
+ this.image = options.image ?? "blaxel/ts-app:latest";
431
+ this.memory = options.memory ?? 4096;
432
+ this.timeout = options.timeout;
433
+ this.region = options.region || process.env.BL_REGION || "auto";
434
+ this.env = options.env ?? {};
435
+ this.labels = options.labels ?? {};
436
+ this.configuredRuntimes = options.runtimes ?? [
437
+ "node",
438
+ "python",
439
+ "bash"
440
+ ];
441
+ this.ports = options.ports ?? [];
442
+ this._constructorOptions = { ...options };
443
+ }
444
+ generateId() {
445
+ return `blaxel-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
446
+ }
447
+ /**
448
+ * Construct a sibling `BlaxelSandbox` that inherits this sandbox's
449
+ * configuration (image, memory, region, runtimes, ports, labels) with
450
+ * per-instance overrides.
451
+ *
452
+ * Performs no I/O — the sandbox clone provisions (or reconnects to an
453
+ * existing Blaxel sandbox with the same logical `id` via
454
+ * `createIfNotExists`) on its own `start()`. Use it when one configured
455
+ * sandbox acts as the template for a fleet of independent sandboxes
456
+ * (e.g. one per project).
457
+ *
458
+ * `options.idleTimeoutMinutes` maps to Blaxel's `timeout` (TTL duration
459
+ * string); `options.sandboxId` is ignored because Blaxel reconnects by
460
+ * logical `id`.
461
+ */
462
+ clone(options = {}) {
463
+ const { id: _id, ...base } = this._constructorOptions;
464
+ return new BlaxelSandbox({
465
+ ...base,
466
+ ...options.id !== void 0 && { id: options.id },
467
+ ...options.env !== void 0 && { env: options.env },
468
+ ...options.idleTimeoutMinutes !== void 0 && { timeout: `${options.idleTimeoutMinutes}m` }
469
+ });
470
+ }
471
+ get supportedRuntimes() {
472
+ return this.configuredRuntimes;
473
+ }
474
+ get defaultRuntime() {
475
+ return this.configuredRuntimes[0] ?? "node";
476
+ }
477
+ /**
478
+ * Get the underlying Blaxel SandboxInstance for direct access to Blaxel APIs.
479
+ *
480
+ * Use this when you need to access Blaxel features not exposed through the
481
+ * WorkspaceSandbox interface (e.g., filesystem, process management, previews, etc.).
482
+ *
483
+ * @throws {SandboxNotReadyError} If the sandbox has not been started
484
+ *
485
+ * @example Direct file operations
486
+ * ```typescript
487
+ * await sandbox.start();
488
+ * const blaxelSandbox = sandbox.blaxel;
489
+ * await blaxelSandbox.fs.write('/tmp/test.txt', 'Hello');
490
+ * const content = await blaxelSandbox.fs.read('/tmp/test.txt');
491
+ * const files = await blaxelSandbox.fs.ls('/tmp');
492
+ * ```
493
+ *
494
+ * @example Process management
495
+ * ```typescript
496
+ * await sandbox.start();
497
+ * const blaxelSandbox = sandbox.blaxel;
498
+ * const proc = await blaxelSandbox.process.exec({
499
+ * command: 'node server.js',
500
+ * waitForCompletion: false,
501
+ * });
502
+ * ```
503
+ */
504
+ get blaxel() {
505
+ if (!this._sandbox) throw new SandboxNotReadyError(this.id);
506
+ return this._sandbox;
507
+ }
508
+ /** @deprecated Use `blaxel` instead. */
509
+ get instance() {
510
+ return this.blaxel;
511
+ }
512
+ /**
513
+ * Mount a filesystem at a path in the sandbox.
514
+ * Uses FUSE tools (s3fs, gcsfuse) to mount cloud storage.
515
+ */
516
+ async mount(filesystem, mountPath) {
517
+ validateMountPath(mountPath);
518
+ if (!this._sandbox) throw new SandboxNotReadyError(this.id);
519
+ this.logger.debug(`${LOG_PREFIX} Mounting "${mountPath}"...`);
520
+ const config = filesystem.getMountConfig?.();
521
+ if (!config) {
522
+ const error = `Filesystem "${filesystem.id}" does not provide a mount config`;
523
+ this.logger.error(`${LOG_PREFIX} ${error}`);
524
+ this.mounts.set(mountPath, {
525
+ filesystem,
526
+ state: "error",
527
+ error
528
+ });
529
+ return {
530
+ success: false,
531
+ mountPath,
532
+ error
533
+ };
534
+ }
535
+ const existingMount = await this.checkExistingMount(mountPath, config);
536
+ if (existingMount === "matching") {
537
+ this.logger.debug(`${LOG_PREFIX} Detected existing mount for ${filesystem.provider} ("${filesystem.id}") at "${mountPath}" with correct config, skipping`);
538
+ this.mounts.set(mountPath, {
539
+ state: "mounted",
540
+ config
541
+ });
542
+ return {
543
+ success: true,
544
+ mountPath
545
+ };
546
+ } else if (existingMount === "mismatched") {
547
+ this.logger.debug(`${LOG_PREFIX} Config mismatch, unmounting to re-mount with new config...`);
548
+ await this.unmount(mountPath);
549
+ }
550
+ this.logger.debug(`${LOG_PREFIX} Config type: ${config.type}`);
551
+ this.mounts.set(mountPath, {
552
+ filesystem,
553
+ state: "mounting",
554
+ config
555
+ });
556
+ try {
557
+ if ((await runCommand(this._sandbox, `[ -d "${mountPath}" ] && [ "$(ls -A "${mountPath}" 2>/dev/null)" ] && echo "non-empty" || echo "ok"`)).stdout.trim() === "non-empty") {
558
+ 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.`;
559
+ this.logger.error(`${LOG_PREFIX} ${error}`);
560
+ this.mounts.set(mountPath, {
561
+ filesystem,
562
+ state: "error",
563
+ config,
564
+ error
565
+ });
566
+ return {
567
+ success: false,
568
+ mountPath,
569
+ error
570
+ };
571
+ }
572
+ } catch {}
573
+ this.logger.debug(`${LOG_PREFIX} Creating mount directory for ${mountPath}...`);
574
+ const mkdirCommand = `mkdir -p "${mountPath}"`;
575
+ this.logger.debug(`${LOG_PREFIX} Running command: ${mkdirCommand}`);
576
+ const mkdirResult = await runCommand(this._sandbox, mkdirCommand);
577
+ if (mkdirResult.exitCode !== 0) {
578
+ const mkdirError = `Failed to create mount directory "${mountPath}": ${mkdirResult.stderr || mkdirResult.stdout}`;
579
+ this.logger.debug(`${LOG_PREFIX} mkdir error for "${mountPath}":`, mkdirError);
580
+ this.mounts.set(mountPath, {
581
+ filesystem,
582
+ state: "error",
583
+ config,
584
+ error: mkdirError
585
+ });
586
+ return {
587
+ success: false,
588
+ mountPath,
589
+ error: mkdirError
590
+ };
591
+ }
592
+ this.logger.debug(`${LOG_PREFIX} Created mount directory for mount path "${mountPath}":`, mkdirResult);
593
+ const mountCtx = {
594
+ sandbox: this._sandbox,
595
+ logger: this.logger
596
+ };
597
+ try {
598
+ switch (config.type) {
599
+ case "s3":
600
+ this.logger.debug(`${LOG_PREFIX} Mounting S3 bucket at ${mountPath}...`);
601
+ await mountS3(mountPath, config, mountCtx);
602
+ this.logger.debug(`${LOG_PREFIX} Mounted S3 bucket at ${mountPath}`);
603
+ break;
604
+ case "gcs":
605
+ this.logger.debug(`${LOG_PREFIX} Mounting GCS bucket at ${mountPath}...`);
606
+ await mountGCS(mountPath, config, mountCtx);
607
+ this.logger.debug(`${LOG_PREFIX} Mounted GCS bucket at ${mountPath}`);
608
+ break;
609
+ default:
610
+ this.mounts.set(mountPath, {
611
+ filesystem,
612
+ state: "unsupported",
613
+ config,
614
+ error: `Unsupported mount type: ${config.type}`
615
+ });
616
+ return {
617
+ success: false,
618
+ mountPath,
619
+ error: `Unsupported mount type: ${config.type}`
620
+ };
621
+ }
622
+ } catch (error) {
623
+ this.logger.error(`${LOG_PREFIX} Error mounting "${filesystem.provider}" (${filesystem.id}) at "${mountPath}":`, error);
624
+ this.mounts.set(mountPath, {
625
+ filesystem,
626
+ state: "error",
627
+ config,
628
+ error: errorToString(error)
629
+ });
630
+ try {
631
+ await runCommand(this._sandbox, `rmdir "${mountPath}" 2>/dev/null || true`);
632
+ this.logger.debug(`${LOG_PREFIX} Cleaned up directory after failed mount: ${mountPath}`);
633
+ } catch {}
634
+ return {
635
+ success: false,
636
+ mountPath,
637
+ error: errorToString(error)
638
+ };
639
+ }
640
+ this.mounts.set(mountPath, {
641
+ state: "mounted",
642
+ config
643
+ });
644
+ await this.writeMarkerFile(mountPath);
645
+ this.logger.debug(`${LOG_PREFIX} Mounted ${mountPath}`);
646
+ return {
647
+ success: true,
648
+ mountPath
649
+ };
650
+ }
651
+ /**
652
+ * Write marker file for detecting config changes on reconnect.
653
+ * Stores both the mount path and config hash in the file.
654
+ */
655
+ async writeMarkerFile(mountPath) {
656
+ if (!this._sandbox) return;
657
+ const markerContent = this.mounts.getMarkerContent(mountPath);
658
+ if (!markerContent) return;
659
+ const markerPath = `/tmp/.mastra-mounts/${this.mounts.markerFilename(mountPath)}`;
660
+ try {
661
+ await runCommand(this._sandbox, "mkdir -p /tmp/.mastra-mounts");
662
+ await this._sandbox.fs.write(markerPath, markerContent);
663
+ } catch {
664
+ this.logger.debug(`${LOG_PREFIX} Warning: Could not write marker file at ${markerPath}`);
665
+ }
666
+ }
667
+ /**
668
+ * Unmount a filesystem from a path in the sandbox.
669
+ */
670
+ async unmount(mountPath) {
671
+ validateMountPath(mountPath);
672
+ if (!this._sandbox) throw new SandboxNotReadyError(this.id);
673
+ this.logger.debug(`${LOG_PREFIX} Unmounting ${mountPath}...`);
674
+ try {
675
+ const result = await runCommand(this._sandbox, `fusermount -u "${mountPath}" 2>/dev/null || umount "${mountPath}"`);
676
+ if (result.exitCode !== 0) this.logger.debug(`${LOG_PREFIX} Unmount warning: ${result.stderr || result.stdout}`);
677
+ } catch (error) {
678
+ this.logger.debug(`${LOG_PREFIX} Unmount error:`, error);
679
+ await runCommand(this._sandbox, `umount -l "${mountPath}" 2>/dev/null || true`);
680
+ }
681
+ this.mounts.delete(mountPath);
682
+ const markerPath = `/tmp/.mastra-mounts/${this.mounts.markerFilename(mountPath)}`;
683
+ await runCommand(this._sandbox, `rm -f "${markerPath}" 2>/dev/null || true`);
684
+ const rmdirResult = await runCommand(this._sandbox, `rmdir "${mountPath}" 2>&1`);
685
+ if (rmdirResult.exitCode === 0) this.logger.debug(`${LOG_PREFIX} Unmounted and removed ${mountPath}`);
686
+ else this.logger.debug(`${LOG_PREFIX} Unmounted ${mountPath} (directory not removed: ${rmdirResult.stderr?.trim() || "not empty"})`);
687
+ }
688
+ /**
689
+ * Get list of current mounts in the sandbox.
690
+ */
691
+ async getMounts() {
692
+ return Array.from(this.mounts.entries).map(([path, entry]) => ({
693
+ path,
694
+ filesystem: entry.filesystem?.provider ?? entry.config?.type ?? "unknown"
695
+ }));
696
+ }
697
+ /**
698
+ * Unmount all stale mounts that are not in the expected mounts list.
699
+ * Also cleans up orphaned directories and marker files from failed mount attempts.
700
+ * Call this after reconnecting to an existing sandbox to clean up old mounts.
701
+ */
702
+ async reconcileMounts(expectedMountPaths) {
703
+ if (!this._sandbox) throw new SandboxNotReadyError(this.id);
704
+ this.logger.debug(`${LOG_PREFIX} Reconciling mounts. Expected paths:`, expectedMountPaths);
705
+ const currentMounts = (await runCommand(this._sandbox, `grep -E 'fuse\\.(s3fs|gcsfuse)' /proc/mounts | awk '{print $2}' || true`)).stdout.trim().split("\n").filter((p) => p.length > 0);
706
+ this.logger.debug(`${LOG_PREFIX} Current FUSE mounts in sandbox:`, currentMounts);
707
+ const markerFiles = (await runCommand(this._sandbox, `ls /tmp/.mastra-mounts/ 2>/dev/null || echo ""`)).stdout.trim().split("\n").filter((f) => f.length > 0 && SAFE_MARKER_NAME.test(f));
708
+ const managedMountPaths = /* @__PURE__ */ new Map();
709
+ for (const markerFile of markerFiles) {
710
+ const markerResult = await runCommand(this._sandbox, `cat "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || echo ""`);
711
+ const parsed = this.mounts.parseMarkerContent(markerResult.stdout.trim());
712
+ if (parsed && SAFE_MOUNT_PATH.test(parsed.path)) managedMountPaths.set(parsed.path, markerFile);
713
+ }
714
+ const staleMounts = currentMounts.filter((path) => !expectedMountPaths.includes(path));
715
+ for (const stalePath of staleMounts) if (managedMountPaths.has(stalePath)) {
716
+ this.logger.debug(`${LOG_PREFIX} Found stale managed FUSE mount at ${stalePath}, unmounting...`);
717
+ await this.unmount(stalePath);
718
+ } else this.logger.debug(`${LOG_PREFIX} Found external FUSE mount at ${stalePath}, leaving untouched`);
719
+ try {
720
+ const expectedMarkerFiles = new Set(expectedMountPaths.map((p) => this.mounts.markerFilename(p)));
721
+ const markerToPath = /* @__PURE__ */ new Map();
722
+ for (const [path, file] of managedMountPaths) markerToPath.set(file, path);
723
+ for (const markerFile of markerFiles) if (!expectedMarkerFiles.has(markerFile)) {
724
+ const mountPath = markerToPath.get(markerFile);
725
+ if (mountPath) {
726
+ if (!currentMounts.includes(mountPath)) {
727
+ this.logger.debug(`${LOG_PREFIX} Cleaning up orphaned marker and directory for ${mountPath}`);
728
+ await runCommand(this._sandbox, `rm -f "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || true`);
729
+ await runCommand(this._sandbox, `rmdir "${mountPath}" 2>/dev/null || true`);
730
+ }
731
+ } else {
732
+ this.logger.debug(`${LOG_PREFIX} Removing malformed marker file: ${markerFile}`);
733
+ await runCommand(this._sandbox, `rm -f "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || true`);
734
+ }
735
+ }
736
+ } catch {
737
+ this.logger.debug(`${LOG_PREFIX} Error during orphan cleanup (non-fatal)`);
738
+ }
739
+ }
740
+ /**
741
+ * Check if a path is already mounted and if the config matches.
742
+ *
743
+ * @param mountPath - The mount path to check
744
+ * @param newConfig - The new config to compare against the stored config
745
+ * @returns 'not_mounted' | 'matching' | 'mismatched'
746
+ */
747
+ async checkExistingMount(mountPath, newConfig) {
748
+ if (!this._sandbox) throw new SandboxNotReadyError(this.id);
749
+ if ((await runCommand(this._sandbox, `mountpoint -q "${mountPath}" && echo "mounted" || echo "not mounted"`)).stdout.trim() !== "mounted") return "not_mounted";
750
+ const markerPath = `/tmp/.mastra-mounts/${this.mounts.markerFilename(mountPath)}`;
751
+ try {
752
+ const markerResult = await runCommand(this._sandbox, `cat "${markerPath}" 2>/dev/null || echo ""`);
753
+ const parsed = this.mounts.parseMarkerContent(markerResult.stdout.trim());
754
+ if (!parsed) return "mismatched";
755
+ const newConfigHash = this.mounts.computeConfigHash(newConfig);
756
+ this.logger.debug(`${LOG_PREFIX} Marker check - stored hash: "${parsed.configHash}", new config hash: "${newConfigHash}"`);
757
+ if (parsed.path === mountPath && parsed.configHash === newConfigHash) return "matching";
758
+ } catch {}
759
+ return "mismatched";
760
+ }
761
+ /**
762
+ * Start the Blaxel sandbox.
763
+ * Uses `createIfNotExists` to reconnect to an existing sandbox or create a new one.
764
+ *
765
+ * Status management and mount processing are handled by the base class.
766
+ */
767
+ async start() {
768
+ if (this._sandbox) return;
769
+ const sandboxName = this.toSandboxName(this.id);
770
+ this.logger.debug(`${LOG_PREFIX} Starting sandbox: ${sandboxName}`);
771
+ const existingSandbox = await this.findExistingSandbox(sandboxName);
772
+ if (existingSandbox) {
773
+ this._sandbox = existingSandbox;
774
+ this._createdAt = /* @__PURE__ */ new Date();
775
+ this.logger.debug(`${LOG_PREFIX} Reconnected to existing sandbox: ${sandboxName}`);
776
+ const expectedPaths = Array.from(this.mounts.entries.keys());
777
+ this.logger.debug(`${LOG_PREFIX} Running mount reconciliation...`);
778
+ await this.reconcileMounts(expectedPaths);
779
+ this.logger.debug(`${LOG_PREFIX} Mount reconciliation complete`);
780
+ return;
781
+ }
782
+ this.logger.debug(`${LOG_PREFIX} Creating new sandbox: ${sandboxName}`);
783
+ try {
784
+ this._sandbox = await SandboxInstance.create({
785
+ name: sandboxName,
786
+ image: this.image,
787
+ memory: this.memory,
788
+ region: this.region,
789
+ ...this.timeout && { ttl: this.timeout },
790
+ labels: {
791
+ ...this.labels,
792
+ "mastra-sandbox-id": this.id
793
+ },
794
+ ports: this.ports.map((p) => ({
795
+ name: p.name,
796
+ target: p.target,
797
+ protocol: p.protocol ?? "HTTP"
798
+ }))
799
+ });
800
+ } catch (createError) {
801
+ if (createError instanceof Error) throw createError;
802
+ throw new Error(errorToString(createError));
803
+ }
804
+ this._createdAt = /* @__PURE__ */ new Date();
805
+ this.logger.debug(`${LOG_PREFIX} Sandbox ready: ${sandboxName} (status: ${this._sandbox.status})`);
806
+ }
807
+ /**
808
+ * Convert a logical sandbox ID to a valid Blaxel sandbox name.
809
+ * Blaxel sandbox names must be DNS-safe (lowercase alphanumeric and hyphens).
810
+ */
811
+ toSandboxName(id) {
812
+ const name = id.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 63);
813
+ if (!name) throw new Error(`Cannot derive a valid sandbox name from id "${id}". ID must contain at least one alphanumeric character.`);
814
+ return name;
815
+ }
816
+ /**
817
+ * Find an existing sandbox with the given name.
818
+ * Returns the connected sandbox if found and running, null otherwise.
819
+ */
820
+ async findExistingSandbox(sandboxName) {
821
+ try {
822
+ const existing = await SandboxInstance.get(sandboxName);
823
+ if (existing.status === "DEPLOYED") {
824
+ this.logger.debug(`${LOG_PREFIX} Found existing sandbox: ${sandboxName} (status: ${existing.status})`);
825
+ return existing;
826
+ }
827
+ this.logger.debug(`${LOG_PREFIX} Found sandbox ${sandboxName} but status is ${existing.status}, creating new one`);
828
+ } catch (e) {
829
+ this.logger.debug(`${LOG_PREFIX} No existing sandbox found for ${sandboxName}:`, e);
830
+ }
831
+ return null;
832
+ }
833
+ /**
834
+ * Stop the Blaxel sandbox.
835
+ * Unmounts all filesystems and releases the sandbox reference.
836
+ * Status management is handled by the base class.
837
+ */
838
+ async stop() {
839
+ if (this.processes) try {
840
+ const procs = await this.processes.list();
841
+ await Promise.all(procs.filter((p) => p.running).map((p) => this.processes.kill(p.pid)));
842
+ } catch {}
843
+ for (const mountPath of [...this.mounts.entries.keys()]) try {
844
+ await this.unmount(mountPath);
845
+ } catch {}
846
+ this._sandbox = null;
847
+ }
848
+ /**
849
+ * Destroy the Blaxel sandbox and clean up all resources.
850
+ * Unmounts filesystems, deletes the sandbox, and clears mount state.
851
+ * Status management is handled by the base class.
852
+ */
853
+ async destroy() {
854
+ if (this.processes) try {
855
+ const procs = await this.processes.list();
856
+ await Promise.all(procs.filter((p) => p.running).map((p) => this.processes.kill(p.pid)));
857
+ } catch {}
858
+ for (const mountPath of [...this.mounts.entries.keys()]) try {
859
+ await this.unmount(mountPath);
860
+ } catch {}
861
+ if (this._sandbox) try {
862
+ await this._sandbox.delete();
863
+ } catch {}
864
+ this._sandbox = null;
865
+ this.mounts.clear();
866
+ }
867
+ /**
868
+ * Check if the sandbox is ready for operations.
869
+ */
870
+ async isReady() {
871
+ return this.status === "running" && this._sandbox !== null;
872
+ }
873
+ /**
874
+ * Get information about the current state of the sandbox.
875
+ */
876
+ async getInfo() {
877
+ return {
878
+ id: this.id,
879
+ name: this.name,
880
+ provider: this.provider,
881
+ status: this.status,
882
+ createdAt: this._createdAt ?? /* @__PURE__ */ new Date(),
883
+ mounts: Array.from(this.mounts.entries).map(([path, entry]) => ({
884
+ path,
885
+ filesystem: entry.filesystem?.provider ?? entry.config?.type ?? "unknown"
886
+ })),
887
+ metadata: {
888
+ ...this.labels,
889
+ image: this.image,
890
+ memory: this.memory,
891
+ sandboxStatus: this._sandbox?.status
892
+ }
893
+ };
894
+ }
895
+ /**
896
+ * Get instructions describing this Blaxel sandbox.
897
+ * Used by agents to understand the execution environment.
898
+ */
899
+ getInstructions() {
900
+ const mountCount = this.mounts.entries.size;
901
+ return `Cloud sandbox.${mountCount > 0 ? ` ${mountCount} filesystem(s) mounted via FUSE.` : ""}`;
902
+ }
903
+ /**
904
+ * Ensure the sandbox is started and return the Blaxel SandboxInstance.
905
+ * Uses base class ensureRunning() for status management and error handling.
906
+ * @throws {SandboxNotReadyError} if sandbox fails to start
907
+ */
908
+ async ensureSandbox() {
909
+ await this.ensureRunning();
910
+ return this._sandbox;
911
+ }
912
+ /**
913
+ * Check if an error indicates the sandbox itself is dead/gone.
914
+ * Does NOT include code execution timeouts (those are the user's code taking too long).
915
+ */
916
+ isSandboxDeadError(error) {
917
+ if (!error) return false;
918
+ const errorStr = errorToString(error).toLowerCase();
919
+ return errorStr.includes("terminated") || errorStr.includes("sandbox was not found") || errorStr.includes("sandbox not found") || errorStr.includes("\"not found\"");
920
+ }
921
+ /**
922
+ * Handle sandbox timeout by clearing the instance and resetting state.
923
+ *
924
+ * Bypasses the normal stop() lifecycle because the sandbox is already dead —
925
+ * we can't unmount filesystems or run cleanup commands. Instead we reset
926
+ * mount states to 'pending' so they get re-mounted when start() runs again.
927
+ */
928
+ handleSandboxTimeout() {
929
+ this._sandbox = null;
930
+ for (const [path, entry] of this.mounts.entries) if (entry.state === "mounted" || entry.state === "mounting") this.mounts.set(path, { state: "pending" });
931
+ this.status = "stopped";
932
+ }
933
+ /**
934
+ * Execute an operation with automatic retry if the sandbox is found to be dead.
935
+ *
936
+ * When the Blaxel sandbox times out or crashes mid-operation, this method
937
+ * resets sandbox state, restarts it, and retries the operation once.
938
+ *
939
+ * @internal Used by BlaxelProcessManager to handle dead sandboxes during spawn.
940
+ */
941
+ async retryOnDead(fn) {
942
+ try {
943
+ return await fn();
944
+ } catch (error) {
945
+ if (this.isSandboxDeadError(error) && !this._isRetrying) {
946
+ this.handleSandboxTimeout();
947
+ this._isRetrying = true;
948
+ try {
949
+ await this.ensureRunning();
950
+ return await fn();
951
+ } finally {
952
+ this._isRetrying = false;
953
+ }
954
+ }
955
+ throw error;
956
+ }
957
+ }
958
+ /**
959
+ * Execute a shell command in the sandbox.
960
+ * Automatically starts the sandbox if not already running.
961
+ * Retries once if the sandbox is found to be dead.
962
+ */
963
+ async executeCommand(command, args = [], options = {}) {
964
+ this.logger.debug(`${LOG_PREFIX} Executing: ${command} ${args.join(" ")}`, options);
965
+ const sandbox = await this.ensureSandbox();
966
+ const startTime = Date.now();
967
+ const fullCommand = args.length > 0 ? `${command} ${args.map(shellQuote).join(" ")}` : command;
968
+ this.logger.debug(`${LOG_PREFIX} Executing: ${fullCommand}`);
969
+ let capturedStdout = "";
970
+ let capturedStderr = "";
971
+ try {
972
+ const mergedEnv = {
973
+ ...this.env,
974
+ ...options.env
975
+ };
976
+ const envRecord = Object.fromEntries(Object.entries(mergedEnv).filter((entry) => entry[1] !== void 0));
977
+ const apiTimeout = options.timeout ? Math.ceil(options.timeout / 1e3) : void 0;
978
+ const execPromise = sandbox.process.exec({
979
+ command: fullCommand,
980
+ workingDir: options.cwd,
981
+ env: envRecord,
982
+ waitForCompletion: true,
983
+ ...apiTimeout && { timeout: apiTimeout },
984
+ onStdout: (data) => {
985
+ capturedStdout += data;
986
+ options.onStdout?.(data);
987
+ },
988
+ onStderr: (data) => {
989
+ capturedStderr += data;
990
+ options.onStderr?.(data);
991
+ }
992
+ });
993
+ const racePromises = [];
994
+ let timer;
995
+ let abortHandler;
996
+ if (options.timeout) racePromises.push(new Promise((_, reject) => {
997
+ timer = setTimeout(() => {
998
+ runCommand(sandbox, `pkill -f ${shellQuote(fullCommand)}`, { timeout: 5e3 }).catch(() => {});
999
+ reject(/* @__PURE__ */ new Error(`Command timed out after ${options.timeout}ms`));
1000
+ }, options.timeout);
1001
+ }));
1002
+ if (options.abortSignal) {
1003
+ if (options.abortSignal.aborted) {
1004
+ runCommand(sandbox, `pkill -f ${shellQuote(fullCommand)}`, { timeout: 5e3 }).catch(() => {});
1005
+ throw new Error("Process aborted");
1006
+ }
1007
+ racePromises.push(new Promise((_, reject) => {
1008
+ abortHandler = () => {
1009
+ runCommand(sandbox, `pkill -f ${shellQuote(fullCommand)}`, { timeout: 5e3 }).catch(() => {});
1010
+ reject(/* @__PURE__ */ new Error("Process aborted"));
1011
+ };
1012
+ options.abortSignal.addEventListener("abort", abortHandler, { once: true });
1013
+ }));
1014
+ }
1015
+ let result;
1016
+ try {
1017
+ if (racePromises.length > 0) result = await Promise.race([execPromise, ...racePromises]);
1018
+ else result = await execPromise;
1019
+ } finally {
1020
+ if (timer) clearTimeout(timer);
1021
+ if (abortHandler && options.abortSignal) options.abortSignal.removeEventListener("abort", abortHandler);
1022
+ }
1023
+ const executionTimeMs = Date.now() - startTime;
1024
+ const exitCode = result.exitCode ?? 0;
1025
+ const stdout = capturedStdout || result.stdout || "";
1026
+ const stderr = capturedStderr || result.stderr || "";
1027
+ this.logger.debug(`${LOG_PREFIX} Exit code: ${exitCode} (${executionTimeMs}ms)`);
1028
+ if (stdout) this.logger.debug(`${LOG_PREFIX} stdout:\n${stdout}`);
1029
+ if (stderr) this.logger.debug(`${LOG_PREFIX} stderr:\n${stderr}`);
1030
+ return {
1031
+ success: exitCode === 0,
1032
+ exitCode,
1033
+ stdout,
1034
+ stderr,
1035
+ executionTimeMs,
1036
+ command,
1037
+ args
1038
+ };
1039
+ } catch (error) {
1040
+ if (this.isSandboxDeadError(error) && !this._isRetrying) {
1041
+ this.handleSandboxTimeout();
1042
+ this._isRetrying = true;
1043
+ try {
1044
+ return await this.executeCommand(command, args, options);
1045
+ } finally {
1046
+ this._isRetrying = false;
1047
+ }
1048
+ }
1049
+ const executionTimeMs = Date.now() - startTime;
1050
+ return {
1051
+ success: false,
1052
+ exitCode: 1,
1053
+ stdout: capturedStdout,
1054
+ stderr: capturedStderr || errorToString(error),
1055
+ executionTimeMs,
1056
+ command,
1057
+ args
1058
+ };
1059
+ }
1060
+ }
1152
1061
  };
1153
-
1062
+ //#endregion
1154
1063
  export { BlaxelProcessManager, BlaxelSandbox };
1155
- //# sourceMappingURL=index.js.map
1064
+
1156
1065
  //# sourceMappingURL=index.js.map