@mastra/blaxel 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { SandboxInstance } from '@blaxel/core';
2
- import { MastraSandbox, SandboxNotReadyError } from '@mastra/core/workspace';
2
+ import { SandboxProcessManager, MastraSandbox, SandboxNotReadyError, ProcessHandle } from '@mastra/core/workspace';
3
3
  import crypto from 'crypto';
4
4
 
5
5
  // src/sandbox/index.ts
@@ -31,6 +31,16 @@ function validateEndpoint(endpoint) {
31
31
  throw new Error(`Invalid endpoint URL scheme: "${parsed.protocol}". Only http: and https: are allowed.`);
32
32
  }
33
33
  }
34
+ async function detectPackageManager(sandbox) {
35
+ const result = await runCommand(
36
+ sandbox,
37
+ 'which apt-get >/dev/null 2>&1 && echo "apt" || (which apk >/dev/null 2>&1 && echo "apk" || echo "unknown")'
38
+ );
39
+ const pm = result.stdout.trim();
40
+ if (pm === "apt") return "apt";
41
+ if (pm === "apk") return "apk";
42
+ return "unknown";
43
+ }
34
44
  async function runCommand(sandbox, command, options) {
35
45
  const result = await sandbox.process.exec({
36
46
  command,
@@ -53,25 +63,45 @@ async function mountS3(mountPath, config, ctx) {
53
63
  if (checkResult.stdout.includes("not found")) {
54
64
  logger.warn(`${LOG_PREFIX} s3fs not found, attempting runtime installation...`);
55
65
  logger.info(`${LOG_PREFIX} Tip: For faster startup, pre-install s3fs in your sandbox image`);
56
- const updateResult = await runCommand(sandbox, "apt-get update 2>&1", { timeout: 6e4 });
57
- if (updateResult.exitCode !== 0) {
58
- throw new Error(
59
- `Failed to update package lists for s3fs installation.
66
+ const pm = await detectPackageManager(sandbox);
67
+ logger.debug(`${LOG_PREFIX} Detected package manager: ${pm}`);
68
+ if (pm === "apt") {
69
+ const updateResult = await runCommand(sandbox, "apt-get update 2>&1", { timeout: 6e4 });
70
+ if (updateResult.exitCode !== 0) {
71
+ throw new Error(
72
+ `Failed to update package lists for s3fs installation.
60
73
  Error details: ${updateResult.stderr || updateResult.stdout}`
74
+ );
75
+ }
76
+ const installResult = await runCommand(
77
+ sandbox,
78
+ "apt-get install -y s3fs fuse 2>&1 || apt-get install -y s3fs-fuse fuse 2>&1",
79
+ { timeout: 12e4 }
61
80
  );
62
- }
63
- const installResult = await runCommand(
64
- sandbox,
65
- "apt-get install -y s3fs fuse 2>&1 || apt-get install -y s3fs-fuse fuse 2>&1",
66
- { timeout: 12e4 }
67
- );
68
- if (installResult.exitCode !== 0) {
69
- throw new Error(
70
- `Failed to install s3fs. For S3 mounting, your sandbox image needs s3fs and fuse packages.
81
+ if (installResult.exitCode !== 0) {
82
+ throw new Error(
83
+ `Failed to install s3fs. For S3 mounting, your sandbox image needs s3fs and fuse packages.
71
84
 
72
85
  Pre-install in your image: apt-get install -y s3fs fuse
73
86
 
74
87
  Error details: ${installResult.stderr || installResult.stdout}`
88
+ );
89
+ }
90
+ } else if (pm === "apk") {
91
+ const installResult = await runCommand(sandbox, "apk add --no-cache s3fs-fuse fuse 2>&1", { timeout: 12e4 });
92
+ if (installResult.exitCode !== 0) {
93
+ throw new Error(
94
+ `Failed to install s3fs on Alpine Linux. Ensure the Alpine community repository is enabled.
95
+
96
+ Pre-install in your image: apk add --no-cache s3fs-fuse fuse
97
+
98
+ Error details: ${installResult.stderr || installResult.stdout}`
99
+ );
100
+ }
101
+ } else {
102
+ throw new Error(
103
+ `Cannot install s3fs: no supported package manager found (need apt-get or apk).
104
+ 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.`
75
105
  );
76
106
  }
77
107
  }
@@ -133,6 +163,26 @@ async function mountGCS(mountPath, config, ctx) {
133
163
  const checkResult = await runCommand(sandbox, 'which gcsfuse || echo "not found"');
134
164
  if (checkResult.stdout.includes("not found")) {
135
165
  logger.warn(`${LOG_PREFIX} gcsfuse not found, attempting runtime installation...`);
166
+ const pm = await detectPackageManager(sandbox);
167
+ logger.debug(`${LOG_PREFIX} Detected package manager: ${pm}`);
168
+ if (pm === "apk") {
169
+ throw new Error(
170
+ `gcsfuse is not available on Alpine Linux. Google only provides gcsfuse packages for Debian/Ubuntu.
171
+
172
+ Use a Debian-based Blaxel image for GCS mounts:
173
+ new BlaxelSandbox({ image: 'blaxel/ts-app:latest' })
174
+ new BlaxelSandbox({ image: 'blaxel/py-app:latest' })`
175
+ );
176
+ }
177
+ if (pm !== "apt") {
178
+ throw new Error(
179
+ `Cannot install gcsfuse: no supported package manager found (need apt-get).
180
+ gcsfuse is only available on Debian/Ubuntu-based images.
181
+
182
+ Use a Debian-based Blaxel image:
183
+ new BlaxelSandbox({ image: 'blaxel/ts-app:latest' })`
184
+ );
185
+ }
136
186
  logger.info(`${LOG_PREFIX} Tip: For faster startup, pre-install gcsfuse in your sandbox image`);
137
187
  const codenameResult = await runCommand(
138
188
  sandbox,
@@ -203,6 +253,138 @@ ${installResult.stderr}`
203
253
  throw new Error(`Failed to mount GCS bucket: ${result.stderr || result.stdout}`);
204
254
  }
205
255
  }
256
+ var BlaxelProcessHandle = class extends ProcessHandle {
257
+ pid;
258
+ _identifier;
259
+ _sandbox;
260
+ _startTime;
261
+ _exitCode;
262
+ _waitPromise = null;
263
+ _streamingDone = null;
264
+ _closeStream = null;
265
+ _killed = false;
266
+ constructor(pid, identifier, sandbox, startTime, options) {
267
+ super(options);
268
+ this.pid = pid;
269
+ this._identifier = identifier;
270
+ this._sandbox = sandbox;
271
+ this._startTime = startTime;
272
+ }
273
+ get exitCode() {
274
+ return this._exitCode;
275
+ }
276
+ /** @internal Set by the process manager after streaming starts. */
277
+ set streamControl(control) {
278
+ this._closeStream = control.close;
279
+ this._streamingDone = control.wait();
280
+ this._streamingDone.then(() => this._resolveExitCode()).catch(() => this._resolveExitCode());
281
+ }
282
+ /** Fetch exit code from Blaxel and set _exitCode. No-op if already set. */
283
+ async _resolveExitCode() {
284
+ if (this._exitCode !== void 0) return;
285
+ try {
286
+ const proc = await this._sandbox.process.get(this._identifier);
287
+ this._exitCode = proc.status === "completed" ? proc.exitCode ?? 0 : proc.exitCode ?? 1;
288
+ } catch {
289
+ if (this._exitCode === void 0) {
290
+ this._exitCode = 1;
291
+ }
292
+ }
293
+ }
294
+ async wait() {
295
+ if (!this._waitPromise) {
296
+ this._waitPromise = this._doWait();
297
+ }
298
+ return this._waitPromise;
299
+ }
300
+ async _doWait() {
301
+ if (this._streamingDone) {
302
+ await this._streamingDone.catch(() => {
303
+ });
304
+ }
305
+ if (this._killed) {
306
+ return {
307
+ success: false,
308
+ exitCode: this._exitCode ?? 137,
309
+ stdout: this.stdout,
310
+ stderr: this.stderr,
311
+ executionTimeMs: Date.now() - this._startTime
312
+ };
313
+ }
314
+ await this._resolveExitCode();
315
+ return {
316
+ success: this._exitCode === 0,
317
+ exitCode: this._exitCode ?? 1,
318
+ stdout: this.stdout,
319
+ stderr: this.stderr,
320
+ executionTimeMs: Date.now() - this._startTime
321
+ };
322
+ }
323
+ async kill() {
324
+ if (this._exitCode !== void 0) return false;
325
+ this._killed = true;
326
+ this._exitCode = 137;
327
+ this._closeStream?.();
328
+ try {
329
+ await this._sandbox.process.kill(this._identifier);
330
+ } catch {
331
+ }
332
+ return true;
333
+ }
334
+ async sendStdin(_data) {
335
+ throw new Error("Blaxel sandboxes do not support stdin");
336
+ }
337
+ };
338
+ var BlaxelProcessManager = class extends SandboxProcessManager {
339
+ constructor(opts = {}) {
340
+ super({ env: opts.env });
341
+ }
342
+ async spawn(command, options = {}) {
343
+ return this.sandbox.retryOnDead(async () => {
344
+ const blaxel = this.sandbox.instance;
345
+ const mergedEnv = { ...this.env, ...options.env };
346
+ const envs = Object.fromEntries(
347
+ Object.entries(mergedEnv).filter((entry) => entry[1] !== void 0)
348
+ );
349
+ const result = await blaxel.process.exec({
350
+ command,
351
+ waitForCompletion: false,
352
+ workingDir: options.cwd,
353
+ ...Object.keys(envs).length > 0 && { env: envs },
354
+ ...options.timeout && { timeout: Math.ceil(options.timeout / 1e3) }
355
+ });
356
+ const identifier = result.pid;
357
+ const pid = parseInt(identifier, 10);
358
+ const handle = new BlaxelProcessHandle(pid, identifier, blaxel, Date.now(), options);
359
+ const streamControl = blaxel.process.streamLogs(identifier, {
360
+ onStdout: (data) => handle.emitStdout(data),
361
+ onStderr: (data) => handle.emitStderr(data),
362
+ onError: (err) => {
363
+ const msg = err instanceof Error ? err.message : String(err);
364
+ handle.emitStderr(msg);
365
+ }
366
+ });
367
+ handle.streamControl = streamControl;
368
+ this._tracked.set(pid, handle);
369
+ return handle;
370
+ });
371
+ }
372
+ async list() {
373
+ const result = [];
374
+ for (const [pid, handle] of this._tracked) {
375
+ result.push({
376
+ pid,
377
+ command: handle.command,
378
+ running: handle.exitCode === void 0,
379
+ exitCode: handle.exitCode
380
+ });
381
+ }
382
+ return result;
383
+ }
384
+ async get(pid) {
385
+ return this._tracked.get(pid);
386
+ }
387
+ };
206
388
 
207
389
  // src/sandbox/index.ts
208
390
  var SAFE_MOUNT_PATH = /^\/[a-zA-Z0-9_.\-/]+$/;
@@ -244,9 +426,13 @@ var BlaxelSandbox = class extends MastraSandbox {
244
426
  ports;
245
427
  // Non-optional (initialized by BaseSandbox)
246
428
  constructor(options = {}) {
247
- super({ ...options, name: "BlaxelSandbox" });
429
+ super({
430
+ ...options,
431
+ name: "BlaxelSandbox",
432
+ processes: new BlaxelProcessManager({ env: options.env })
433
+ });
248
434
  this.id = options.id ?? this.generateId();
249
- this.image = options.image ?? "blaxel/py-app:latest";
435
+ this.image = options.image ?? "blaxel/ts-app:latest";
250
436
  this.memory = options.memory ?? 4096;
251
437
  this.timeout = options.timeout;
252
438
  this.env = options.env ?? {};
@@ -589,7 +775,6 @@ var BlaxelSandbox = class extends MastraSandbox {
589
775
  image: this.image,
590
776
  memory: this.memory,
591
777
  ...this.timeout && { ttl: this.timeout },
592
- envs: Object.entries(this.env).map(([name, value]) => ({ name, value })),
593
778
  labels: {
594
779
  ...this.labels,
595
780
  "mastra-sandbox-id": this.id
@@ -647,6 +832,13 @@ var BlaxelSandbox = class extends MastraSandbox {
647
832
  * Status management is handled by the base class.
648
833
  */
649
834
  async stop() {
835
+ if (this.processes) {
836
+ try {
837
+ const procs = await this.processes.list();
838
+ await Promise.all(procs.filter((p) => p.running).map((p) => this.processes.kill(p.pid)));
839
+ } catch {
840
+ }
841
+ }
650
842
  for (const mountPath of [...this.mounts.entries.keys()]) {
651
843
  try {
652
844
  await this.unmount(mountPath);
@@ -661,6 +853,13 @@ var BlaxelSandbox = class extends MastraSandbox {
661
853
  * Status management is handled by the base class.
662
854
  */
663
855
  async destroy() {
856
+ if (this.processes) {
857
+ try {
858
+ const procs = await this.processes.list();
859
+ await Promise.all(procs.filter((p) => p.running).map((p) => this.processes.kill(p.pid)));
860
+ } catch {
861
+ }
862
+ }
664
863
  for (const mountPath of [...this.mounts.entries.keys()]) {
665
864
  try {
666
865
  await this.unmount(mountPath);
@@ -711,7 +910,7 @@ var BlaxelSandbox = class extends MastraSandbox {
711
910
  getInstructions() {
712
911
  const mountCount = this.mounts.entries.size;
713
912
  const mountInfo = mountCount > 0 ? ` ${mountCount} filesystem(s) mounted via FUSE.` : "";
714
- return `Cloud sandbox with /home/user as working directory.${mountInfo}`;
913
+ return `Cloud sandbox.${mountInfo}`;
715
914
  }
716
915
  // ---------------------------------------------------------------------------
717
916
  // Internal Helpers
@@ -731,8 +930,8 @@ var BlaxelSandbox = class extends MastraSandbox {
731
930
  */
732
931
  isSandboxDeadError(error) {
733
932
  if (!error) return false;
734
- const errorStr = errorToString(error);
735
- return errorStr.includes("TERMINATED") || errorStr.includes("sandbox was not found") || errorStr.includes("Sandbox not found");
933
+ const errorStr = errorToString(error).toLowerCase();
934
+ return errorStr.includes("terminated") || errorStr.includes("sandbox was not found") || errorStr.includes("sandbox not found") || errorStr.includes('"not found"');
736
935
  }
737
936
  /**
738
937
  * Handle sandbox timeout by clearing the instance and resetting state.
@@ -750,6 +949,31 @@ var BlaxelSandbox = class extends MastraSandbox {
750
949
  }
751
950
  this.status = "stopped";
752
951
  }
952
+ /**
953
+ * Execute an operation with automatic retry if the sandbox is found to be dead.
954
+ *
955
+ * When the Blaxel sandbox times out or crashes mid-operation, this method
956
+ * resets sandbox state, restarts it, and retries the operation once.
957
+ *
958
+ * @internal Used by BlaxelProcessManager to handle dead sandboxes during spawn.
959
+ */
960
+ async retryOnDead(fn) {
961
+ try {
962
+ return await fn();
963
+ } catch (error) {
964
+ if (this.isSandboxDeadError(error) && !this._isRetrying) {
965
+ this.handleSandboxTimeout();
966
+ this._isRetrying = true;
967
+ try {
968
+ await this.ensureRunning();
969
+ return await fn();
970
+ } finally {
971
+ this._isRetrying = false;
972
+ }
973
+ }
974
+ throw error;
975
+ }
976
+ }
753
977
  // ---------------------------------------------------------------------------
754
978
  // Command Execution
755
979
  // ---------------------------------------------------------------------------
@@ -764,26 +988,77 @@ var BlaxelSandbox = class extends MastraSandbox {
764
988
  const startTime = Date.now();
765
989
  const fullCommand = args.length > 0 ? `${command} ${args.map(shellQuote).join(" ")}` : command;
766
990
  this.logger.debug(`${LOG_PREFIX} Executing: ${fullCommand}`);
991
+ let capturedStdout = "";
992
+ let capturedStderr = "";
767
993
  try {
768
994
  const mergedEnv = { ...this.env, ...options.env };
769
995
  const envRecord = Object.fromEntries(
770
996
  Object.entries(mergedEnv).filter((entry) => entry[1] !== void 0)
771
997
  );
772
- const result = await sandbox.process.exec({
998
+ const apiTimeout = options.timeout ? Math.ceil(options.timeout / 1e3) : void 0;
999
+ const execPromise = sandbox.process.exec({
773
1000
  command: fullCommand,
774
1001
  workingDir: options.cwd,
775
1002
  env: envRecord,
776
1003
  waitForCompletion: true,
777
- ...options.timeout && { timeout: Math.ceil(options.timeout / 1e3) },
778
- ...options.onStdout || options.onStderr ? {
779
- onStdout: options.onStdout,
780
- onStderr: options.onStderr
781
- } : {}
1004
+ ...apiTimeout && { timeout: apiTimeout },
1005
+ onStdout: (data) => {
1006
+ capturedStdout += data;
1007
+ options.onStdout?.(data);
1008
+ },
1009
+ onStderr: (data) => {
1010
+ capturedStderr += data;
1011
+ options.onStderr?.(data);
1012
+ }
782
1013
  });
1014
+ const racePromises = [];
1015
+ let timer;
1016
+ let abortHandler;
1017
+ if (options.timeout) {
1018
+ racePromises.push(
1019
+ new Promise((_, reject) => {
1020
+ timer = setTimeout(() => {
1021
+ runCommand(sandbox, `pkill -f ${shellQuote(fullCommand)}`, { timeout: 5e3 }).catch(() => {
1022
+ });
1023
+ reject(new Error(`Command timed out after ${options.timeout}ms`));
1024
+ }, options.timeout);
1025
+ })
1026
+ );
1027
+ }
1028
+ if (options.abortSignal) {
1029
+ if (options.abortSignal.aborted) {
1030
+ runCommand(sandbox, `pkill -f ${shellQuote(fullCommand)}`, { timeout: 5e3 }).catch(() => {
1031
+ });
1032
+ throw new Error("Process aborted");
1033
+ }
1034
+ racePromises.push(
1035
+ new Promise((_, reject) => {
1036
+ abortHandler = () => {
1037
+ runCommand(sandbox, `pkill -f ${shellQuote(fullCommand)}`, { timeout: 5e3 }).catch(() => {
1038
+ });
1039
+ reject(new Error("Process aborted"));
1040
+ };
1041
+ options.abortSignal.addEventListener("abort", abortHandler, { once: true });
1042
+ })
1043
+ );
1044
+ }
1045
+ let result;
1046
+ try {
1047
+ if (racePromises.length > 0) {
1048
+ result = await Promise.race([execPromise, ...racePromises]);
1049
+ } else {
1050
+ result = await execPromise;
1051
+ }
1052
+ } finally {
1053
+ if (timer) clearTimeout(timer);
1054
+ if (abortHandler && options.abortSignal) {
1055
+ options.abortSignal.removeEventListener("abort", abortHandler);
1056
+ }
1057
+ }
783
1058
  const executionTimeMs = Date.now() - startTime;
784
1059
  const exitCode = result.exitCode ?? 0;
785
- const stdout = result.stdout ?? "";
786
- const stderr = result.stderr ?? "";
1060
+ const stdout = capturedStdout || result.stdout || "";
1061
+ const stderr = capturedStderr || result.stderr || "";
787
1062
  this.logger.debug(`${LOG_PREFIX} Exit code: ${exitCode} (${executionTimeMs}ms)`);
788
1063
  if (stdout) this.logger.debug(`${LOG_PREFIX} stdout:
789
1064
  ${stdout}`);
@@ -809,13 +1084,11 @@ ${stderr}`);
809
1084
  }
810
1085
  }
811
1086
  const executionTimeMs = Date.now() - startTime;
812
- const stderr = errorToString(error);
813
- this.logger.debug(`${LOG_PREFIX} Execution error (${executionTimeMs}ms): ${stderr}`);
814
1087
  return {
815
1088
  success: false,
816
1089
  exitCode: 1,
817
- stdout: "",
818
- stderr,
1090
+ stdout: capturedStdout,
1091
+ stderr: capturedStderr || errorToString(error),
819
1092
  executionTimeMs,
820
1093
  command,
821
1094
  args
@@ -824,6 +1097,6 @@ ${stderr}`);
824
1097
  }
825
1098
  };
826
1099
 
827
- export { BlaxelSandbox };
1100
+ export { BlaxelProcessManager, BlaxelSandbox };
828
1101
  //# sourceMappingURL=index.js.map
829
1102
  //# sourceMappingURL=index.js.map