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