@mastra/daytona 0.0.1 → 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,6 @@
1
- import { Daytona, SandboxState, DaytonaNotFoundError } from '@daytonaio/sdk';
1
+ import { Daytona, DaytonaNotFoundError, DaytonaError, SandboxState } from '@daytonaio/sdk';
2
2
  import { SandboxProcessManager, MastraSandbox, SandboxNotReadyError, ProcessHandle } from '@mastra/core/workspace';
3
+ import { createHash } from 'crypto';
3
4
 
4
5
  // src/sandbox/index.ts
5
6
 
@@ -14,7 +15,237 @@ function shellQuote(arg) {
14
15
  return "'" + arg.replace(/'/g, "'\\''") + "'";
15
16
  }
16
17
 
17
- // src/sandbox/process-manager.ts
18
+ // src/sandbox/mounts/types.ts
19
+ var LOG_PREFIX = "[@mastra/daytona]";
20
+ var SAFE_BUCKET_NAME = /^[a-z0-9][a-z0-9.\-]{1,61}[a-z0-9]$/;
21
+ function validateBucketName(bucket) {
22
+ if (!SAFE_BUCKET_NAME.test(bucket)) {
23
+ throw new Error(
24
+ `Invalid bucket name: "${bucket}". Bucket names must be 3-63 characters, lowercase alphanumeric, hyphens, or dots.`
25
+ );
26
+ }
27
+ }
28
+ function validateEndpoint(endpoint) {
29
+ let parsed;
30
+ try {
31
+ parsed = new URL(endpoint);
32
+ } catch {
33
+ throw new Error(`Invalid endpoint URL: "${endpoint}"`);
34
+ }
35
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
36
+ throw new Error(`Invalid endpoint URL scheme: "${parsed.protocol}". Only http: and https: are allowed.`);
37
+ }
38
+ }
39
+ async function runCommand(sandbox, command, options) {
40
+ const result = await sandbox.process.executeCommand(
41
+ command,
42
+ void 0,
43
+ // cwd
44
+ void 0,
45
+ // env
46
+ options?.timeout !== void 0 ? Math.ceil(options.timeout / 1e3) : void 0
47
+ );
48
+ return {
49
+ exitCode: result.exitCode,
50
+ output: result.result ?? ""
51
+ };
52
+ }
53
+ async function mountS3(mountPath, config, ctx) {
54
+ const { run, writeFile, logger } = ctx;
55
+ validateBucketName(config.bucket);
56
+ if (config.endpoint) {
57
+ validateEndpoint(config.endpoint);
58
+ }
59
+ const quotedMountPath = shellQuote(mountPath);
60
+ const hasAccessKey = !!config.accessKeyId;
61
+ const hasSecretKey = !!config.secretAccessKey;
62
+ if (hasAccessKey !== hasSecretKey) {
63
+ throw new Error("Both accessKeyId and secretAccessKey must be provided together.");
64
+ }
65
+ const hasCredentials = hasAccessKey && hasSecretKey;
66
+ if (!hasCredentials && config.endpoint) {
67
+ throw new Error(
68
+ `S3-compatible storage requires credentials. Detected endpoint: ${config.endpoint}. The public_bucket option only works for AWS S3 public buckets, not R2, MinIO, etc.`
69
+ );
70
+ }
71
+ if (config.endpoint) {
72
+ const endpoint = config.endpoint.replace(/\/$/, "");
73
+ const connectivityCheck = await run(`curl -sS --max-time 5 ${shellQuote(endpoint)} 2>&1`, 1e4);
74
+ const checkOutput = connectivityCheck.stdout.trim();
75
+ if (connectivityCheck.exitCode !== 0 || checkOutput.toLowerCase().includes("restricted") || checkOutput.toLowerCase().includes("blocked")) {
76
+ throw new Error(
77
+ `Cannot reach ${endpoint} from this sandbox. S3-compatible storage mounting requires network access to the configured endpoint, which may be blocked on Daytona's restricted tiers. Upgrade to a tier with unrestricted internet access, or contact Daytona support to remove the network restriction.` + (checkOutput ? `
78
+
79
+ Sandbox network response: ${checkOutput}` : "")
80
+ );
81
+ }
82
+ }
83
+ const checkResult = await run('which s3fs 2>/dev/null || echo "not found"', 3e4);
84
+ if (checkResult.stdout.includes("not found")) {
85
+ logger.warn(`${LOG_PREFIX} s3fs not found, attempting runtime installation...`);
86
+ logger.info(`${LOG_PREFIX} Tip: For faster startup, pre-install s3fs in your sandbox image`);
87
+ await run("sudo apt-get update -qq 2>&1", 6e4);
88
+ await run("sudo apt-get install -y s3fs fuse 2>&1 || sudo apt-get install -y s3fs-fuse fuse 2>&1 || true", 12e4);
89
+ const s3fsCheck = await run('which s3fs 2>/dev/null || echo "not found"', 3e4);
90
+ if (s3fsCheck.stdout.includes("not found")) {
91
+ throw new Error("Failed to install s3fs: binary not found after install attempt");
92
+ }
93
+ }
94
+ await run("sudo chmod u+s /usr/bin/fusermount3 /usr/bin/fusermount 2>/dev/null || true", 3e4);
95
+ const idResult = await run("id -u && id -g", 3e4);
96
+ const [uid, gid] = idResult.stdout.trim().split("\n");
97
+ const validUidGid = uid && gid && /^\d+$/.test(uid) && /^\d+$/.test(gid);
98
+ if (!validUidGid) {
99
+ logger.warn(
100
+ `${LOG_PREFIX} Unexpected uid/gid format: "${idResult.stdout.trim()}" \u2014 mounted files will be owned by root`
101
+ );
102
+ }
103
+ const mountHash = createHash("md5").update(mountPath).digest("hex").slice(0, 8);
104
+ const credentialsPath = `/tmp/.passwd-s3fs-${mountHash}`;
105
+ await run(
106
+ `sudo chmod a+rw /dev/fuse 2>/dev/null || true; sudo bash -c 'grep -q "^user_allow_other" /etc/fuse.conf 2>/dev/null || echo "user_allow_other" >> /etc/fuse.conf' 2>/dev/null || true`
107
+ );
108
+ if (hasCredentials) {
109
+ await run(`sudo rm -f ${shellQuote(credentialsPath)}`, 3e4);
110
+ await writeFile(credentialsPath, `${config.accessKeyId}:${config.secretAccessKey}`);
111
+ await run(`chmod 600 ${shellQuote(credentialsPath)}`, 3e4);
112
+ }
113
+ const mountOptions = [];
114
+ if (hasCredentials) {
115
+ mountOptions.push(`passwd_file=${credentialsPath}`);
116
+ } else {
117
+ mountOptions.push("public_bucket=1");
118
+ logger.debug(`${LOG_PREFIX} No credentials provided, mounting as public bucket (read-only)`);
119
+ }
120
+ mountOptions.push("allow_other");
121
+ if (validUidGid) {
122
+ mountOptions.push(`uid=${uid}`, `gid=${gid}`);
123
+ }
124
+ if (config.endpoint) {
125
+ const endpoint = config.endpoint.replace(/\/$/, "");
126
+ mountOptions.push(`url=${shellQuote(endpoint)}`, "use_path_request_style", "sigv4", "nomultipart");
127
+ }
128
+ if (config.readOnly) {
129
+ mountOptions.push("ro");
130
+ logger.debug(`${LOG_PREFIX} Mounting as read-only`);
131
+ }
132
+ const mountCmd = `s3fs ${shellQuote(config.bucket)} ${quotedMountPath} -o ${mountOptions.join(" -o ")}`;
133
+ logger.debug(`${LOG_PREFIX} Mounting S3:`, hasCredentials ? mountCmd.replace(credentialsPath, "***") : mountCmd);
134
+ const result = await run(mountCmd, 6e4);
135
+ logger.debug(`${LOG_PREFIX} s3fs result:`, {
136
+ exitCode: result.exitCode,
137
+ stdout: result.stdout,
138
+ stderr: result.stderr
139
+ });
140
+ if (result.exitCode !== 0) {
141
+ throw new Error(`Failed to mount S3 bucket: ${result.stderr || result.stdout}`);
142
+ }
143
+ }
144
+ async function mountGCS(mountPath, config, ctx) {
145
+ const { run, writeFile, logger } = ctx;
146
+ validateBucketName(config.bucket);
147
+ const quotedMountPath = shellQuote(mountPath);
148
+ const connectivityCheck = await run("curl -sS --max-time 5 http://storage.googleapis.com 2>&1", 1e4);
149
+ const checkOutput = connectivityCheck.stdout.trim();
150
+ if (connectivityCheck.exitCode !== 0 || checkOutput.toLowerCase().includes("restricted") || checkOutput.toLowerCase().includes("blocked")) {
151
+ throw new Error(
152
+ `Cannot reach Google Cloud Storage from this sandbox. GCS mounting requires network access to storage.googleapis.com, which may be blocked on Daytona's restricted tiers. Upgrade to a tier with unrestricted internet access, or contact Daytona support to remove the network restriction.` + (checkOutput ? `
153
+
154
+ Sandbox network response: ${checkOutput}` : "")
155
+ );
156
+ }
157
+ const checkResult = await run('which gcsfuse 2>/dev/null || echo "not found"', 3e4);
158
+ if (checkResult.stdout.includes("not found")) {
159
+ logger.warn(`${LOG_PREFIX} gcsfuse not found, attempting runtime installation...`);
160
+ logger.info(`${LOG_PREFIX} Tip: For faster startup, pre-install gcsfuse in your sandbox image`);
161
+ await run("sudo apt-get update -qq 2>&1", 6e4);
162
+ const prepResult = await run("sudo apt-get install -y curl gnupg 2>&1", 12e4);
163
+ if (prepResult.exitCode !== 0) {
164
+ throw new Error(
165
+ `Failed to install gcsfuse prerequisites (curl, gnupg): ${prepResult.stderr || prepResult.stdout}`
166
+ );
167
+ }
168
+ const distroIdResult = await run(
169
+ 'cat /etc/os-release 2>/dev/null | grep "^ID=" | cut -d= -f2 || echo debian',
170
+ 3e4
171
+ );
172
+ const distroId = distroIdResult.stdout.trim().replace(/"/g, "") || "debian";
173
+ const fallbackCodename = distroId === "ubuntu" ? "jammy" : "bookworm";
174
+ const codenameResult = await run(
175
+ `cat /etc/os-release 2>/dev/null | grep "^VERSION_CODENAME=" | cut -d= -f2 || echo ${fallbackCodename}`,
176
+ 3e4
177
+ );
178
+ const detectedCodename = codenameResult.stdout.trim() || fallbackCodename;
179
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(detectedCodename)) {
180
+ throw new Error(`Invalid distro codename for gcsfuse repo: "${detectedCodename}"`);
181
+ }
182
+ logger.debug(`${LOG_PREFIX} Detected distro: ${distroId}/${detectedCodename}, fallback: ${fallbackCodename}`);
183
+ const repoSetup = await run(
184
+ `sudo mkdir -p /etc/apt/keyrings && curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg -o /tmp/gcsfuse-key.gpg && sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/gcsfuse.gpg /tmp/gcsfuse-key.gpg && echo "deb [signed-by=/etc/apt/keyrings/gcsfuse.gpg] https://packages.cloud.google.com/apt gcsfuse-${detectedCodename} main" | sudo tee /etc/apt/sources.list.d/gcsfuse.list`,
185
+ 3e4
186
+ );
187
+ if (repoSetup.exitCode !== 0) {
188
+ throw new Error(`Failed to set up gcsfuse apt repository: ${repoSetup.stderr || repoSetup.stdout}`);
189
+ }
190
+ await run("sudo apt-get update -qq 2>&1 || true", 6e4);
191
+ let installResult = await run("sudo apt-get install -y gcsfuse 2>&1", 12e4);
192
+ if (installResult.exitCode !== 0 && detectedCodename !== fallbackCodename) {
193
+ logger.warn(
194
+ `${LOG_PREFIX} gcsfuse install failed for "${detectedCodename}", retrying with "${fallbackCodename}" fallback`
195
+ );
196
+ await run(
197
+ `sudo rm -f /etc/apt/sources.list.d/gcsfuse.list && echo "deb [signed-by=/etc/apt/keyrings/gcsfuse.gpg] https://packages.cloud.google.com/apt gcsfuse-${fallbackCodename} main" | sudo tee /etc/apt/sources.list.d/gcsfuse.list`,
198
+ 1e4
199
+ );
200
+ await run("sudo apt-get update -qq 2>&1 || true", 6e4);
201
+ installResult = await run("sudo apt-get install -y gcsfuse 2>&1", 12e4);
202
+ }
203
+ const verifyResult = await run('which gcsfuse 2>/dev/null || echo "not found"', 3e4);
204
+ if (verifyResult.stdout.includes("not found")) {
205
+ throw new Error(`Failed to install gcsfuse: ${installResult.stderr || installResult.stdout}`);
206
+ }
207
+ if (installResult.exitCode !== 0) {
208
+ logger.warn(
209
+ `${LOG_PREFIX} gcsfuse install reported dpkg errors (likely fuse post-install in container) but binary is present \u2014 proceeding`
210
+ );
211
+ }
212
+ }
213
+ const idResult = await run("id -u && id -g", 3e4);
214
+ const [uid, gid] = idResult.stdout.trim().split("\n");
215
+ const validUidGid = uid && gid && /^\d+$/.test(uid) && /^\d+$/.test(gid);
216
+ if (!validUidGid) {
217
+ logger.warn(
218
+ `${LOG_PREFIX} Unexpected uid/gid format: "${idResult.stdout.trim()}" \u2014 mounted files will be owned by root`
219
+ );
220
+ }
221
+ const uidGidFlags = validUidGid ? `--uid=${uid} --gid=${gid}` : "";
222
+ await run(
223
+ `sudo chmod a+rw /dev/fuse 2>/dev/null || true; sudo bash -c 'grep -q "^user_allow_other" /etc/fuse.conf 2>/dev/null || echo "user_allow_other" >> /etc/fuse.conf' 2>/dev/null || true`
224
+ );
225
+ const hasCredentials = !!config.serviceAccountKey;
226
+ let mountCmd;
227
+ if (hasCredentials) {
228
+ const mountHash = createHash("md5").update(mountPath).digest("hex").slice(0, 8);
229
+ const keyPath = `/tmp/gcs-key-${mountHash}.json`;
230
+ await run(`sudo rm -f ${shellQuote(keyPath)}`, 3e4);
231
+ await writeFile(keyPath, config.serviceAccountKey);
232
+ await run(`chmod 600 ${shellQuote(keyPath)}`, 3e4);
233
+ mountCmd = `gcsfuse --key-file=${shellQuote(keyPath)} -o allow_other ${uidGidFlags} ${shellQuote(config.bucket)} ${quotedMountPath}`;
234
+ } else {
235
+ logger.debug(`${LOG_PREFIX} No credentials provided, mounting GCS as public bucket (read-only)`);
236
+ mountCmd = `gcsfuse --anonymous-access -o allow_other ${uidGidFlags} ${shellQuote(config.bucket)} ${quotedMountPath}`;
237
+ }
238
+ logger.debug(`${LOG_PREFIX} Mounting GCS:`, mountCmd);
239
+ const result = await run(mountCmd, 6e4);
240
+ logger.debug(`${LOG_PREFIX} gcsfuse result:`, {
241
+ exitCode: result.exitCode,
242
+ stdout: result.stdout,
243
+ stderr: result.stderr
244
+ });
245
+ if (result.exitCode !== 0) {
246
+ throw new Error(`Failed to mount GCS bucket: ${result.stderr || result.stdout}`);
247
+ }
248
+ }
18
249
  var DaytonaProcessHandle = class extends ProcessHandle {
19
250
  pid;
20
251
  _sessionId;
@@ -193,22 +424,51 @@ function buildSpawnCommand(command, cwd, envs) {
193
424
  }
194
425
 
195
426
  // src/sandbox/index.ts
196
- var LOG_PREFIX = "[@mastra/daytona]";
427
+ var SAFE_MOUNT_PATH = /^\/[a-zA-Z0-9_.\-/]+$/;
428
+ var MOUNT_COMMAND_TIMEOUT_MS = 3e4;
429
+ function errorToString(error) {
430
+ if (error instanceof Error) return error.message;
431
+ if (typeof error === "string") return error;
432
+ if (error && typeof error === "object" && "message" in error) {
433
+ const maybeError = error;
434
+ if (typeof maybeError.message === "string") {
435
+ return maybeError.message;
436
+ }
437
+ }
438
+ try {
439
+ return JSON.stringify(error);
440
+ } catch {
441
+ return String(error);
442
+ }
443
+ }
444
+ function validateMountPath(mountPath) {
445
+ if (!SAFE_MOUNT_PATH.test(mountPath)) {
446
+ throw new Error(
447
+ `Invalid mount path: ${mountPath}. Must be an absolute path with alphanumeric, dash, dot, underscore, or slash characters only.`
448
+ );
449
+ }
450
+ const segments = mountPath.split("/");
451
+ if (mountPath.includes("//") || segments.some((segment) => segment === "." || segment === "..")) {
452
+ throw new Error(`Invalid mount path: ${mountPath}. Path traversal segments are not allowed.`);
453
+ }
454
+ }
455
+ var SAFE_MARKER_NAME = /^mount-[a-z0-9]+$/;
197
456
  var SANDBOX_DEAD_PATTERNS = [
198
457
  /sandbox is not running/i,
199
458
  /sandbox already destroyed/i,
200
459
  /sandbox.*not found/i
201
460
  ];
202
- var DaytonaSandbox = class extends MastraSandbox {
461
+ var DaytonaSandbox = class _DaytonaSandbox extends MastraSandbox {
203
462
  id;
204
463
  name = "DaytonaSandbox";
205
464
  provider = "daytona";
465
+ // Non-optional (initialized by base class when mount() exists)
206
466
  status = "pending";
207
467
  _daytona = null;
208
468
  _sandbox = null;
209
469
  _createdAt = null;
210
- _isRetrying = false;
211
470
  _workingDir = null;
471
+ _isRetrying = false;
212
472
  timeout;
213
473
  language;
214
474
  resources;
@@ -303,13 +563,16 @@ var DaytonaSandbox = class extends MastraSandbox {
303
563
  this._sandbox = existing;
304
564
  this._createdAt = existing.createdAt ? new Date(existing.createdAt) : /* @__PURE__ */ new Date();
305
565
  this.logger.debug(`${LOG_PREFIX} Reconnected to existing sandbox ${existing.id} for: ${this.id}`);
566
+ const expectedPaths = Array.from(this.mounts.entries.keys());
567
+ this.logger.debug(`${LOG_PREFIX} Running mount reconciliation...`);
568
+ await this.reconcileMounts(expectedPaths);
569
+ this.logger.debug(`${LOG_PREFIX} Mount reconciliation complete`);
306
570
  await this.detectWorkingDir();
307
571
  return;
308
572
  }
309
573
  this.logger.debug(`${LOG_PREFIX} Creating sandbox for: ${this.id}`);
310
574
  const baseParams = compact({
311
575
  language: this.language,
312
- envVars: this.env,
313
576
  labels: { ...this.labels, "mastra-sandbox-id": this.id },
314
577
  ephemeral: this.ephemeral,
315
578
  autoStopInterval: this.autoStopInterval,
@@ -339,9 +602,15 @@ var DaytonaSandbox = class extends MastraSandbox {
339
602
  }
340
603
  /**
341
604
  * Stop the Daytona sandbox.
342
- * Stops the sandbox instance and releases the reference.
605
+ * Unmounts all filesystems, then stops the sandbox.
343
606
  */
344
607
  async stop() {
608
+ for (const mountPath of [...this.mounts.entries.keys()]) {
609
+ try {
610
+ await this.unmount(mountPath);
611
+ } catch {
612
+ }
613
+ }
345
614
  if (this._sandbox && this._daytona) {
346
615
  try {
347
616
  await this._daytona.stop(this._sandbox);
@@ -415,7 +684,9 @@ var DaytonaSandbox = class extends MastraSandbox {
415
684
  */
416
685
  getInstructions() {
417
686
  const parts = [];
418
- parts.push(`Cloud sandbox with isolated execution (${this.language} runtime).`);
687
+ const mountCount = this.mounts.entries.size;
688
+ const mountInfo = mountCount > 0 ? ` ${mountCount} filesystem(s) mounted via FUSE.` : "";
689
+ parts.push(`Cloud sandbox with isolated execution (${this.language} runtime).${mountInfo}`);
419
690
  if (this._workingDir) {
420
691
  parts.push(`Default working directory: ${this._workingDir}.`);
421
692
  }
@@ -430,17 +701,339 @@ var DaytonaSandbox = class extends MastraSandbox {
430
701
  return parts.join(" ");
431
702
  }
432
703
  // ---------------------------------------------------------------------------
704
+ // Command Execution
705
+ // ---------------------------------------------------------------------------
706
+ /**
707
+ * Execute a command in the sandbox and return the result.
708
+ */
709
+ async executeCommand(command, args = [], options = {}) {
710
+ await this.ensureRunning();
711
+ const fullCommand = args.length > 0 ? `${command} ${args.map(shellQuote).join(" ")}` : command;
712
+ const handle = await this.processes.spawn(fullCommand, options);
713
+ const result = await handle.wait();
714
+ return { ...result, command, args };
715
+ }
716
+ // ---------------------------------------------------------------------------
717
+ // Mount Support
718
+ // ---------------------------------------------------------------------------
719
+ /**
720
+ * Mount a filesystem at a path in the sandbox.
721
+ * Uses FUSE tools (s3fs, gcsfuse) to mount cloud storage.
722
+ */
723
+ async mount(filesystem, mountPath) {
724
+ validateMountPath(mountPath);
725
+ if (!this._sandbox) {
726
+ throw new SandboxNotReadyError(this.id);
727
+ }
728
+ const sandbox = this._sandbox;
729
+ this.logger.debug(`${LOG_PREFIX} Mounting "${mountPath}"...`);
730
+ const config = filesystem.getMountConfig?.();
731
+ if (!config) {
732
+ const error = `Filesystem "${filesystem.id}" does not provide a mount config`;
733
+ this.logger.error(`${LOG_PREFIX} ${error}`);
734
+ this.mounts.set(mountPath, { filesystem, state: "error", error });
735
+ return { success: false, mountPath, error };
736
+ }
737
+ const existingMount = await this.checkExistingMount(mountPath, config);
738
+ if (existingMount === "matching") {
739
+ this.logger.debug(
740
+ `${LOG_PREFIX} Detected existing mount for ${filesystem.provider} ("${filesystem.id}") at "${mountPath}" with correct config, skipping`
741
+ );
742
+ this.mounts.set(mountPath, { state: "mounted", config });
743
+ return { success: true, mountPath };
744
+ } else if (existingMount === "mismatched") {
745
+ this.logger.debug(`${LOG_PREFIX} Config mismatch at "${mountPath}", unmounting to re-mount with new config...`);
746
+ await this.unmount(mountPath);
747
+ } else if (existingMount === "unmanaged") {
748
+ const error = `Mount path "${mountPath}" is already mounted by an unmanaged source`;
749
+ this.logger.error(`${LOG_PREFIX} ${error}`);
750
+ this.mounts.set(mountPath, { filesystem, state: "error", config, error });
751
+ return { success: false, mountPath, error };
752
+ }
753
+ this.mounts.set(mountPath, { filesystem, state: "mounting", config });
754
+ this.logger.debug(`${LOG_PREFIX} Config type: ${config.type}`);
755
+ try {
756
+ const quotedPath = shellQuote(mountPath);
757
+ const checkResult = await runCommand(
758
+ sandbox,
759
+ `[ -d ${quotedPath} ] && ! mountpoint -q ${quotedPath} 2>/dev/null && [ "$(ls -A ${quotedPath} 2>/dev/null)" ] && echo "non-empty" || echo "ok"`,
760
+ { timeout: MOUNT_COMMAND_TIMEOUT_MS }
761
+ );
762
+ if (checkResult.output.trim() === "non-empty") {
763
+ 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.`;
764
+ this.logger.error(`${LOG_PREFIX} ${error}`);
765
+ this.mounts.set(mountPath, { filesystem, state: "error", config, error });
766
+ return { success: false, mountPath, error };
767
+ }
768
+ } catch {
769
+ }
770
+ this.logger.debug(`${LOG_PREFIX} Creating mount directory for "${mountPath}"...`);
771
+ try {
772
+ const quotedPath = shellQuote(mountPath);
773
+ const mkdirResult = await runCommand(
774
+ sandbox,
775
+ `mountpoint -q ${quotedPath} 2>/dev/null && sudo mount -t tmpfs tmpfs ${quotedPath} 2>/dev/null; sudo mkdir -p ${quotedPath} 2>/dev/null; sudo chown $(id -u):$(id -g) ${quotedPath}`,
776
+ { timeout: MOUNT_COMMAND_TIMEOUT_MS }
777
+ );
778
+ if (mkdirResult.exitCode !== 0) {
779
+ const error = mkdirResult.output || "Failed to create mount directory";
780
+ this.logger.debug(`${LOG_PREFIX} mkdir error for "${mountPath}":`, error);
781
+ this.mounts.set(mountPath, { filesystem, state: "error", config, error });
782
+ return { success: false, mountPath, error };
783
+ }
784
+ } catch (err) {
785
+ const error = `Failed to create mount directory: ${err}`;
786
+ this.mounts.set(mountPath, { filesystem, state: "error", config, error });
787
+ return { success: false, mountPath, error };
788
+ }
789
+ const mountCtx = {
790
+ run: async (cmd, timeoutMs) => {
791
+ const result = await runCommand(sandbox, cmd, timeoutMs !== void 0 ? { timeout: timeoutMs } : void 0);
792
+ return {
793
+ exitCode: result.exitCode,
794
+ stdout: result.output,
795
+ stderr: result.exitCode !== 0 ? result.output : ""
796
+ };
797
+ },
798
+ writeFile: async (path, content) => {
799
+ await sandbox.fs.uploadFile(Buffer.from(content), path);
800
+ },
801
+ logger: this.logger
802
+ };
803
+ try {
804
+ switch (config.type) {
805
+ case "s3":
806
+ this.logger.debug(`${LOG_PREFIX} Mounting S3 at "${mountPath}"...`);
807
+ await mountS3(mountPath, config, mountCtx);
808
+ this.logger.debug(`${LOG_PREFIX} Mounted S3 bucket at ${mountPath}`);
809
+ break;
810
+ case "gcs":
811
+ this.logger.debug(`${LOG_PREFIX} Mounting GCS at "${mountPath}"...`);
812
+ await mountGCS(mountPath, config, mountCtx);
813
+ this.logger.debug(`${LOG_PREFIX} Mounted GCS bucket at ${mountPath}`);
814
+ break;
815
+ default: {
816
+ const error = `Unsupported mount type: ${config.type}`;
817
+ this.mounts.set(mountPath, { filesystem, state: "unsupported", config, error });
818
+ return { success: false, mountPath, error };
819
+ }
820
+ }
821
+ } catch (error) {
822
+ this.logger.error(
823
+ `${LOG_PREFIX} Error mounting "${filesystem.provider}" (${filesystem.id}) at "${mountPath}":`,
824
+ error
825
+ );
826
+ this.mounts.set(mountPath, { filesystem, state: "error", config, error: errorToString(error) });
827
+ await runCommand(sandbox, `sudo rmdir ${shellQuote(mountPath)} 2>/dev/null || true`, {
828
+ timeout: MOUNT_COMMAND_TIMEOUT_MS
829
+ });
830
+ this.logger.debug(`${LOG_PREFIX} Cleaned up directory after failed mount: ${mountPath}`);
831
+ return { success: false, mountPath, error: errorToString(error) };
832
+ }
833
+ this.mounts.set(mountPath, { state: "mounted", config });
834
+ await this.writeMarkerFile(mountPath);
835
+ this.logger.debug(`${LOG_PREFIX} Mounted "${mountPath}"`);
836
+ return { success: true, mountPath };
837
+ }
838
+ /**
839
+ * Unmount a filesystem from a path in the sandbox.
840
+ */
841
+ async unmount(mountPath) {
842
+ validateMountPath(mountPath);
843
+ if (!this._sandbox) {
844
+ throw new SandboxNotReadyError(this.id);
845
+ }
846
+ const sandbox = this._sandbox;
847
+ this.logger.debug(`${LOG_PREFIX} Unmounting "${mountPath}"...`);
848
+ const quotedPath = shellQuote(mountPath);
849
+ await runCommand(
850
+ sandbox,
851
+ `sudo fusermount -u ${quotedPath} 2>/dev/null; sudo umount -l ${quotedPath} 2>/dev/null; mountpoint -q ${quotedPath} 2>/dev/null && { _p="/tmp/.mastra-defunct-$$"; sudo mkdir -p "$_p" && sudo mount --move ${quotedPath} "$_p" 2>/dev/null; sudo umount -l "$_p" 2>/dev/null; sudo rmdir "$_p" 2>/dev/null; }`,
852
+ { timeout: MOUNT_COMMAND_TIMEOUT_MS }
853
+ );
854
+ this.mounts.delete(mountPath);
855
+ const markerPath = `/tmp/.mastra-mounts/${this.mounts.markerFilename(mountPath)}`;
856
+ const rmdirResult = await runCommand(
857
+ sandbox,
858
+ `rm -f ${shellQuote(markerPath)} 2>/dev/null; sudo rmdir ${quotedPath} 2>&1`,
859
+ {
860
+ timeout: MOUNT_COMMAND_TIMEOUT_MS
861
+ }
862
+ );
863
+ if (rmdirResult.exitCode === 0) {
864
+ this.logger.debug(`${LOG_PREFIX} Unmounted and removed ${mountPath}`);
865
+ } else {
866
+ this.logger.debug(
867
+ `${LOG_PREFIX} Unmounted ${mountPath} (directory not removed: ${rmdirResult.output.trim() || "not empty"})`
868
+ );
869
+ }
870
+ }
871
+ /**
872
+ * Unmount all stale mounts that are not in the expected mounts list.
873
+ * Also cleans up orphaned directories and marker files from failed mount attempts.
874
+ * Call this after reconnecting to an existing sandbox to clean up old mounts.
875
+ */
876
+ async reconcileMounts(expectedMountPaths) {
877
+ if (!this._sandbox) return;
878
+ const sandbox = this._sandbox;
879
+ this.logger.debug(`${LOG_PREFIX} Reconciling mounts. Expected paths:`, expectedMountPaths);
880
+ let currentMounts = [];
881
+ try {
882
+ const mountsResult = await runCommand(
883
+ sandbox,
884
+ `grep -E 'fuse\\.(s3fs|gcsfuse)' /proc/mounts | awk '{print $2}'`,
885
+ { timeout: MOUNT_COMMAND_TIMEOUT_MS }
886
+ );
887
+ currentMounts = mountsResult.output.trim().split("\n").filter((p) => p.length > 0);
888
+ } catch (err) {
889
+ this.logger.debug(`${LOG_PREFIX} Could not read /proc/mounts: ${err}`);
890
+ return;
891
+ }
892
+ this.logger.debug(`${LOG_PREFIX} Current FUSE mounts in sandbox:`, currentMounts);
893
+ let markerFiles = [];
894
+ try {
895
+ const markersResult = await runCommand(sandbox, 'ls /tmp/.mastra-mounts/ 2>/dev/null || echo ""', {
896
+ timeout: MOUNT_COMMAND_TIMEOUT_MS
897
+ });
898
+ markerFiles = markersResult.output.trim().split("\n").filter((f) => f.length > 0 && SAFE_MARKER_NAME.test(f));
899
+ } catch (err) {
900
+ this.logger.debug(`${LOG_PREFIX} Could not read marker files: ${err}`);
901
+ }
902
+ const managedMountPaths = /* @__PURE__ */ new Map();
903
+ for (const markerFile of markerFiles) {
904
+ const markerResult = await runCommand(sandbox, `cat "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || echo ""`, {
905
+ timeout: MOUNT_COMMAND_TIMEOUT_MS
906
+ });
907
+ const parsed = this.mounts.parseMarkerContent(markerResult.output.trim());
908
+ if (parsed && SAFE_MOUNT_PATH.test(parsed.path)) {
909
+ managedMountPaths.set(parsed.path, markerFile);
910
+ }
911
+ }
912
+ const staleMounts = currentMounts.filter((path) => !expectedMountPaths.includes(path));
913
+ for (const stalePath of staleMounts) {
914
+ if (managedMountPaths.has(stalePath)) {
915
+ this.logger.debug(`${LOG_PREFIX} Found stale managed FUSE mount at "${stalePath}", unmounting...`);
916
+ try {
917
+ await this.unmount(stalePath);
918
+ } catch (err) {
919
+ this.logger.debug(`${LOG_PREFIX} Failed to unmount stale mount at "${stalePath}": ${err}`);
920
+ }
921
+ } else {
922
+ this.logger.debug(`${LOG_PREFIX} Found external FUSE mount at "${stalePath}", leaving untouched`);
923
+ }
924
+ }
925
+ try {
926
+ const expectedMarkerFiles = new Set(expectedMountPaths.map((p) => this.mounts.markerFilename(p)));
927
+ const markerToPath = /* @__PURE__ */ new Map();
928
+ for (const [path, file] of managedMountPaths) {
929
+ markerToPath.set(file, path);
930
+ }
931
+ for (const markerFile of markerFiles) {
932
+ if (!expectedMarkerFiles.has(markerFile)) {
933
+ const mountPath = markerToPath.get(markerFile);
934
+ if (mountPath) {
935
+ if (!currentMounts.includes(mountPath)) {
936
+ this.logger.debug(`${LOG_PREFIX} Cleaning up orphaned marker and directory for ${mountPath}`);
937
+ await runCommand(
938
+ sandbox,
939
+ `rm -f "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null; sudo rmdir ${shellQuote(mountPath)} 2>/dev/null`,
940
+ { timeout: MOUNT_COMMAND_TIMEOUT_MS }
941
+ );
942
+ }
943
+ } else {
944
+ this.logger.debug(`${LOG_PREFIX} Removing malformed marker file: ${markerFile}`);
945
+ await runCommand(sandbox, `rm -f "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || true`, {
946
+ timeout: MOUNT_COMMAND_TIMEOUT_MS
947
+ });
948
+ }
949
+ }
950
+ }
951
+ } catch {
952
+ this.logger.debug(`${LOG_PREFIX} Error during orphan cleanup (non-fatal)`);
953
+ }
954
+ }
955
+ /**
956
+ * Write marker file for detecting config changes on reconnect.
957
+ * Stores both the mount path and config hash in the file.
958
+ */
959
+ async writeMarkerFile(mountPath) {
960
+ if (!this._sandbox) return;
961
+ const markerContent = this.mounts.getMarkerContent(mountPath);
962
+ if (!markerContent) return;
963
+ const filename = this.mounts.markerFilename(mountPath);
964
+ const markerPath = `/tmp/.mastra-mounts/${filename}`;
965
+ try {
966
+ await runCommand(this._sandbox, "mkdir -p /tmp/.mastra-mounts", { timeout: MOUNT_COMMAND_TIMEOUT_MS });
967
+ await this._sandbox.fs.uploadFile(Buffer.from(markerContent, "utf-8"), markerPath);
968
+ } catch {
969
+ this.logger.debug(`${LOG_PREFIX} Warning: Could not write marker file at ${markerPath}`);
970
+ }
971
+ }
972
+ /**
973
+ * Check if a path is already mounted and whether the config matches.
974
+ *
975
+ * @param mountPath - The mount path to check
976
+ * @param newConfig - The new config to compare against the stored config
977
+ * @returns 'not_mounted' | 'matching' | 'mismatched' | 'unmanaged'
978
+ */
979
+ async checkExistingMount(mountPath, newConfig) {
980
+ if (!this._sandbox) throw new SandboxNotReadyError(this.id);
981
+ const sandbox = this._sandbox;
982
+ try {
983
+ const mountCheck = await runCommand(
984
+ sandbox,
985
+ `mountpoint -q ${shellQuote(mountPath)} && echo "mounted" || echo "not mounted"`,
986
+ { timeout: MOUNT_COMMAND_TIMEOUT_MS }
987
+ );
988
+ if (mountCheck.output.trim() !== "mounted") {
989
+ return "not_mounted";
990
+ }
991
+ } catch {
992
+ return "not_mounted";
993
+ }
994
+ const filename = this.mounts.markerFilename(mountPath);
995
+ const markerPath = `/tmp/.mastra-mounts/${filename}`;
996
+ let parsed;
997
+ try {
998
+ const markerResult = await runCommand(sandbox, `cat ${shellQuote(markerPath)} 2>/dev/null || echo ""`, {
999
+ timeout: MOUNT_COMMAND_TIMEOUT_MS
1000
+ });
1001
+ parsed = this.mounts.parseMarkerContent(markerResult.output.trim());
1002
+ } catch {
1003
+ return "unmanaged";
1004
+ }
1005
+ if (!parsed) return "unmanaged";
1006
+ const newConfigHash = this.mounts.computeConfigHash(newConfig);
1007
+ this.logger.debug(
1008
+ `${LOG_PREFIX} Marker check - stored hash: "${parsed.configHash}", new config hash: "${newConfigHash}"`
1009
+ );
1010
+ if (parsed.path === mountPath && parsed.configHash === newConfigHash) {
1011
+ return "matching";
1012
+ }
1013
+ return "mismatched";
1014
+ }
1015
+ // ---------------------------------------------------------------------------
433
1016
  // Internal Helpers
434
1017
  // ---------------------------------------------------------------------------
435
1018
  /**
436
- * Detect the actual working directory inside the sandbox via `pwd`.
437
- * Stores the result for use in `getInstructions()`.
1019
+ * Try to find and reconnect to an existing Daytona sandbox.
1020
+ *
1021
+ * Uses two strategies:
1022
+ * 1. `get()` by sandbox name — calls the getSandbox API directly, which
1023
+ * returns sandboxes in ANY state (including stopped). This is the
1024
+ * primary path and fixes reconnection after stop/start cycles.
1025
+ * 2. `findOne()` by label — falls back to label-based search. Note: the
1026
+ * SDK's list() API only returns started sandboxes by default (no states
1027
+ * param in v0.143.0), so this only finds running sandboxes.
1028
+ *
1029
+ * Returns the sandbox if found and usable, or null if a fresh one should
1030
+ * be created.
438
1031
  */
439
1032
  async detectWorkingDir() {
440
1033
  if (!this._sandbox) return;
441
1034
  try {
442
- const result = await this._sandbox.process.executeCommand("pwd");
443
- const dir = result.result?.trim();
1035
+ const result = await runCommand(this._sandbox, "pwd", { timeout: MOUNT_COMMAND_TIMEOUT_MS });
1036
+ const dir = result.output?.trim();
444
1037
  if (dir) {
445
1038
  this._workingDir = dir;
446
1039
  this.logger.debug(`${LOG_PREFIX} Detected working directory: ${dir}`);
@@ -449,11 +1042,6 @@ var DaytonaSandbox = class extends MastraSandbox {
449
1042
  this.logger.debug(`${LOG_PREFIX} Could not detect working directory, will omit from instructions`);
450
1043
  }
451
1044
  }
452
- /**
453
- * Try to find and reconnect to an existing Daytona sandbox with the same
454
- * logical ID (via the mastra-sandbox-id label). Returns the sandbox if
455
- * found and usable, or null if a fresh sandbox should be created.
456
- */
457
1045
  async findExistingSandbox() {
458
1046
  const DEAD_STATES = [
459
1047
  SandboxState.DESTROYED,
@@ -461,27 +1049,102 @@ var DaytonaSandbox = class extends MastraSandbox {
461
1049
  SandboxState.ERROR,
462
1050
  SandboxState.BUILD_FAILED
463
1051
  ];
464
- try {
465
- const sandbox = await this._daytona.findOne({ labels: { "mastra-sandbox-id": this.id } });
466
- const state = sandbox.state;
467
- if (state && DEAD_STATES.includes(state)) {
468
- this.logger.debug(
469
- `${LOG_PREFIX} Existing sandbox ${sandbox.id} is dead (${state}), deleting and creating fresh`
470
- );
471
- try {
472
- await this._daytona.delete(sandbox);
473
- } catch {
1052
+ let sandbox = null;
1053
+ if (this.sandboxName) {
1054
+ try {
1055
+ sandbox = await this._daytona.get(this.sandboxName);
1056
+ } catch (error) {
1057
+ if (error instanceof DaytonaNotFoundError) ; else if (error instanceof DaytonaError && (error.statusCode === 401 || error.statusCode === 403)) {
1058
+ throw error;
1059
+ } else {
1060
+ this.logger.debug(`${LOG_PREFIX} Transient error looking up sandbox by name: ${error}`);
1061
+ }
1062
+ }
1063
+ }
1064
+ if (!sandbox) {
1065
+ try {
1066
+ sandbox = await this._daytona.findOne({ labels: { "mastra-sandbox-id": this.id } });
1067
+ } catch (error) {
1068
+ if (error instanceof DaytonaNotFoundError || error instanceof Error && error.message.includes("No sandbox found")) {
1069
+ return null;
1070
+ }
1071
+ if (error instanceof DaytonaError && (error.statusCode === 401 || error.statusCode === 403)) {
1072
+ throw error;
474
1073
  }
1074
+ this.logger.debug(`${LOG_PREFIX} Transient error looking up sandbox by label: ${error}`);
475
1075
  return null;
476
1076
  }
477
- if (state !== SandboxState.STARTED) {
478
- this.logger.debug(`${LOG_PREFIX} Restarting sandbox ${sandbox.id} (state: ${state})`);
479
- await this._daytona.start(sandbox);
1077
+ }
1078
+ const state = sandbox.state;
1079
+ if (state && DEAD_STATES.includes(state)) {
1080
+ this.logger.debug(`${LOG_PREFIX} Existing sandbox ${sandbox.id} is dead (${state}), deleting and creating fresh`);
1081
+ try {
1082
+ await this._daytona.delete(sandbox);
1083
+ } catch {
480
1084
  }
481
- return sandbox;
482
- } catch {
483
1085
  return null;
484
1086
  }
1087
+ if (state !== SandboxState.STARTED) {
1088
+ this.logger.debug(`${LOG_PREFIX} Restarting sandbox ${sandbox.id} (state: ${state})`);
1089
+ await this.waitForStableStateAndStart(sandbox);
1090
+ }
1091
+ return sandbox;
1092
+ }
1093
+ /**
1094
+ * Transitional states where the Daytona API will reject start() with
1095
+ * "State change in progress". We poll until the sandbox reaches a stable
1096
+ * state before attempting start().
1097
+ */
1098
+ static TRANSITIONAL_STATES = [
1099
+ SandboxState.STARTING,
1100
+ SandboxState.STOPPING,
1101
+ SandboxState.CREATING,
1102
+ SandboxState.RESTORING,
1103
+ SandboxState.ARCHIVING,
1104
+ SandboxState.RESIZING,
1105
+ SandboxState.PULLING_SNAPSHOT,
1106
+ SandboxState.BUILDING_SNAPSHOT
1107
+ ];
1108
+ /**
1109
+ * Wait for the sandbox to leave a transitional state, then start it if needed.
1110
+ * Polls every 2s for up to 120s. If the sandbox reaches STARTED on its own
1111
+ * (e.g. it was STARTING), we skip the start() call. If start() still fails
1112
+ * with "State change in progress", we retry with backoff.
1113
+ */
1114
+ async waitForStableStateAndStart(sandbox) {
1115
+ const MAX_WAIT_MS = 12e4;
1116
+ const POLL_INTERVAL_MS = 2e3;
1117
+ const deadline = Date.now() + MAX_WAIT_MS;
1118
+ let current = sandbox;
1119
+ while (current.state && _DaytonaSandbox.TRANSITIONAL_STATES.includes(current.state) && Date.now() < deadline) {
1120
+ this.logger.debug(`${LOG_PREFIX} Sandbox ${current.id} is in transitional state (${current.state}), waiting...`);
1121
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1122
+ current = await this._daytona.get(current.id);
1123
+ }
1124
+ if (current.state === SandboxState.STARTED) {
1125
+ Object.assign(sandbox, current);
1126
+ return;
1127
+ }
1128
+ while (Date.now() < deadline) {
1129
+ try {
1130
+ await this._daytona.start(current);
1131
+ return;
1132
+ } catch (error) {
1133
+ const msg = error instanceof Error ? error.message : String(error);
1134
+ if (msg.includes("State change in progress") && Date.now() < deadline) {
1135
+ this.logger.debug(`${LOG_PREFIX} start() returned "State change in progress", retrying...`);
1136
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1137
+ current = await this._daytona.get(current.id);
1138
+ if (current.state === SandboxState.STARTED) {
1139
+ Object.assign(sandbox, current);
1140
+ return;
1141
+ }
1142
+ continue;
1143
+ }
1144
+ throw error;
1145
+ }
1146
+ }
1147
+ await this._daytona.start(current);
485
1148
  }
486
1149
  /**
487
1150
  * Check if an error indicates the sandbox is dead/gone.