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