@mastra/daytona 0.6.0 → 0.7.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
@@ -1,1568 +1,1433 @@
1
- 'use strict';
2
-
3
- var sdk = require('@daytonaio/sdk');
4
- var workspace = require('@mastra/core/workspace');
5
- var crypto = require('crypto');
6
-
7
- // src/sandbox/index.ts
8
-
9
- // src/utils/compact.ts
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _daytonaio_sdk = require("@daytonaio/sdk");
3
+ let _mastra_core_workspace = require("@mastra/core/workspace");
4
+ let crypto = require("crypto");
5
+ //#region src/utils/compact.ts
6
+ /**
7
+ * Returns a shallow copy of the object with all undefined values removed.
8
+ */
10
9
  function compact(obj) {
11
- return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
10
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
12
11
  }
13
-
14
- // src/utils/shell-quote.ts
12
+ //#endregion
13
+ //#region src/utils/shell-quote.ts
14
+ /**
15
+ * Shell-quote a single argument for safe use in a command string.
16
+ *
17
+ * Arguments containing only safe characters are returned as-is.
18
+ * All others are wrapped in single quotes with embedded single quotes escaped.
19
+ */
15
20
  function shellQuote(arg) {
16
- if (/^[a-zA-Z0-9._\-/@:=]+$/.test(arg)) return arg;
17
- return "'" + arg.replace(/'/g, "'\\''") + "'";
21
+ if (/^[a-zA-Z0-9._\-/@:=]+$/.test(arg)) return arg;
22
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
18
23
  }
19
-
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]$/;
24
+ //#endregion
25
+ //#region src/sandbox/mounts/types.ts
26
+ const LOG_PREFIX = "[@mastra/daytona]";
27
+ /**
28
+ * Validate a bucket name before interpolating into shell commands.
29
+ * Covers S3, GCS, and S3-compatible (R2, MinIO) naming rules.
30
+ */
31
+ const SAFE_BUCKET_NAME = /^[a-z0-9][a-z0-9.\-]{1,61}[a-z0-9]$/;
23
32
  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
- }
33
+ if (!SAFE_BUCKET_NAME.test(bucket)) throw new Error(`Invalid bucket name: "${bucket}". Bucket names must be 3-63 characters, lowercase alphanumeric, hyphens, or dots.`);
29
34
  }
35
+ /**
36
+ * Validate an endpoint URL before interpolating into shell commands.
37
+ * Only http and https schemes are allowed.
38
+ */
30
39
  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
+ let parsed;
41
+ try {
42
+ parsed = new URL(endpoint);
43
+ } catch {
44
+ throw new Error(`Invalid endpoint URL: "${endpoint}"`);
45
+ }
46
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid endpoint URL scheme: "${parsed.protocol}". Only http: and https: are allowed.`);
40
47
  }
48
+ /**
49
+ * Validate and normalize a mount prefix before interpolating into shell commands.
50
+ * Returns the normalized prefix (no leading/trailing slashes).
51
+ *
52
+ * Shell safety is handled by shellQuote() at the call site, so this function
53
+ * only enforces path-level rules (no traversal, no empty result, no control chars).
54
+ */
41
55
  function validatePrefix(prefix) {
42
- let normalized = prefix;
43
- while (normalized.startsWith("/")) normalized = normalized.slice(1);
44
- while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
45
- if (!normalized) {
46
- throw new Error("Mount prefix cannot be empty after normalization.");
47
- }
48
- if (normalized.includes("//") || normalized.split("/").some((s) => s === "." || s === "..")) {
49
- throw new Error(`Invalid mount prefix: "${prefix}". Path traversal is not allowed.`);
50
- }
51
- if (/[\x00-\x1f\x7f]/.test(normalized)) {
52
- throw new Error(`Invalid mount prefix: "${prefix}". Control characters are not allowed.`);
53
- }
54
- return normalized;
56
+ let normalized = prefix;
57
+ while (normalized.startsWith("/")) normalized = normalized.slice(1);
58
+ while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
59
+ if (!normalized) throw new Error("Mount prefix cannot be empty after normalization.");
60
+ if (normalized.includes("//") || normalized.split("/").some((s) => s === "." || s === "..")) throw new Error(`Invalid mount prefix: "${prefix}". Path traversal is not allowed.`);
61
+ if (/[\x00-\x1f\x7f]/.test(normalized)) throw new Error(`Invalid mount prefix: "${prefix}". Control characters are not allowed.`);
62
+ return normalized;
55
63
  }
64
+ /**
65
+ * Run a command in the Daytona sandbox.
66
+ *
67
+ * Thin wrapper around `sandbox.process.executeCommand` that converts timeout
68
+ * from milliseconds to seconds and null-coalesces the output string.
69
+ *
70
+ * Does NOT throw on non-zero exit codes — callers should check `exitCode`.
71
+ */
56
72
  async function runCommand(sandbox, command, options) {
57
- const result = await sandbox.process.executeCommand(
58
- command,
59
- void 0,
60
- // cwd
61
- void 0,
62
- // env
63
- options?.timeout !== void 0 ? Math.ceil(options.timeout / 1e3) : void 0
64
- );
65
- return {
66
- exitCode: result.exitCode,
67
- output: result.result ?? ""
68
- };
73
+ const result = await sandbox.process.executeCommand(command, void 0, void 0, options?.timeout !== void 0 ? Math.ceil(options.timeout / 1e3) : void 0);
74
+ return {
75
+ exitCode: result.exitCode,
76
+ output: result.result ?? ""
77
+ };
69
78
  }
79
+ //#endregion
80
+ //#region src/sandbox/mounts/s3.ts
81
+ /**
82
+ * Mount an S3 bucket using s3fs-fuse.
83
+ */
70
84
  async function mountS3(mountPath, config, ctx) {
71
- const { run, writeFile, logger } = ctx;
72
- validateBucketName(config.bucket);
73
- if (config.endpoint) {
74
- validateEndpoint(config.endpoint);
75
- }
76
- const quotedMountPath = shellQuote(mountPath);
77
- const hasAccessKey = !!config.accessKeyId;
78
- const hasSecretKey = !!config.secretAccessKey;
79
- if (hasAccessKey !== hasSecretKey) {
80
- throw new Error("Both accessKeyId and secretAccessKey must be provided together.");
81
- }
82
- const hasCredentials = hasAccessKey && hasSecretKey;
83
- if (!hasCredentials && config.endpoint) {
84
- throw new Error(
85
- `S3-compatible storage requires credentials. Detected endpoint: ${config.endpoint}. The public_bucket option only works for AWS S3 public buckets, not R2, MinIO, etc.`
86
- );
87
- }
88
- if (config.endpoint) {
89
- const endpoint = config.endpoint.replace(/\/$/, "");
90
- const connectivityCheck = await run(`curl -sS --max-time 5 ${shellQuote(endpoint)} 2>&1`, 1e4);
91
- const checkOutput = connectivityCheck.stdout.trim();
92
- if (connectivityCheck.exitCode !== 0 || checkOutput.toLowerCase().includes("restricted") || checkOutput.toLowerCase().includes("blocked")) {
93
- throw new Error(
94
- `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 ? `
95
-
96
- Sandbox network response: ${checkOutput}` : "")
97
- );
98
- }
99
- }
100
- const checkResult = await run('which s3fs 2>/dev/null || echo "not found"', 3e4);
101
- if (checkResult.stdout.includes("not found")) {
102
- logger.warn(`${LOG_PREFIX} s3fs not found, attempting runtime installation...`);
103
- logger.info(`${LOG_PREFIX} Tip: For faster startup, pre-install s3fs in your sandbox image`);
104
- await run("sudo apt-get update -qq 2>&1", 6e4);
105
- await run("sudo apt-get install -y s3fs fuse 2>&1 || sudo apt-get install -y s3fs-fuse fuse 2>&1 || true", 12e4);
106
- const s3fsCheck = await run('which s3fs 2>/dev/null || echo "not found"', 3e4);
107
- if (s3fsCheck.stdout.includes("not found")) {
108
- throw new Error("Failed to install s3fs: binary not found after install attempt");
109
- }
110
- }
111
- await run("sudo chmod u+s /usr/bin/fusermount3 /usr/bin/fusermount 2>/dev/null || true", 3e4);
112
- const idResult = await run("id -u && id -g", 3e4);
113
- const [uid, gid] = idResult.stdout.trim().split("\n");
114
- const validUidGid = uid && gid && /^\d+$/.test(uid) && /^\d+$/.test(gid);
115
- if (!validUidGid) {
116
- logger.warn(
117
- `${LOG_PREFIX} Unexpected uid/gid format: "${idResult.stdout.trim()}" \u2014 mounted files will be owned by root`
118
- );
119
- }
120
- const mountHash = crypto.createHash("md5").update(mountPath).digest("hex").slice(0, 8);
121
- const credentialsPath = `/tmp/.passwd-s3fs-${mountHash}`;
122
- await run(
123
- `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`
124
- );
125
- if (hasCredentials) {
126
- await run(`sudo rm -f ${shellQuote(credentialsPath)}`, 3e4);
127
- await writeFile(credentialsPath, `${config.accessKeyId}:${config.secretAccessKey}`);
128
- await run(`chmod 600 ${shellQuote(credentialsPath)}`, 3e4);
129
- }
130
- const mountOptions = [];
131
- if (hasCredentials) {
132
- mountOptions.push(`passwd_file=${credentialsPath}`);
133
- } else {
134
- mountOptions.push("public_bucket=1");
135
- logger.debug(`${LOG_PREFIX} No credentials provided, mounting as public bucket (read-only)`);
136
- }
137
- mountOptions.push("allow_other");
138
- if (validUidGid) {
139
- mountOptions.push(`uid=${uid}`, `gid=${gid}`);
140
- }
141
- if (config.endpoint) {
142
- const endpoint = config.endpoint.replace(/\/$/, "");
143
- mountOptions.push(`url=${shellQuote(endpoint)}`, "use_path_request_style", "sigv4", "nomultipart");
144
- }
145
- if (config.readOnly) {
146
- mountOptions.push("ro");
147
- logger.debug(`${LOG_PREFIX} Mounting as read-only`);
148
- }
149
- let bucketArg = config.bucket;
150
- if (config.prefix) {
151
- const normalizedPrefix = validatePrefix(config.prefix);
152
- bucketArg = `${config.bucket}:/${normalizedPrefix}`;
153
- }
154
- const mountCmd = `s3fs ${shellQuote(bucketArg)} ${quotedMountPath} -o ${mountOptions.join(" -o ")}`;
155
- logger.debug(`${LOG_PREFIX} Mounting S3:`, hasCredentials ? mountCmd.replace(credentialsPath, "***") : mountCmd);
156
- const result = await run(mountCmd, 6e4);
157
- logger.debug(`${LOG_PREFIX} s3fs result:`, {
158
- exitCode: result.exitCode,
159
- stdout: result.stdout,
160
- stderr: result.stderr
161
- });
162
- if (result.exitCode !== 0) {
163
- throw new Error(`Failed to mount S3 bucket: ${result.stderr || result.stdout}`);
164
- }
85
+ const { run, writeFile, logger } = ctx;
86
+ validateBucketName(config.bucket);
87
+ if (config.endpoint) validateEndpoint(config.endpoint);
88
+ const quotedMountPath = shellQuote(mountPath);
89
+ const hasAccessKey = !!config.accessKeyId;
90
+ const hasSecretKey = !!config.secretAccessKey;
91
+ if (hasAccessKey !== hasSecretKey) throw new Error("Both accessKeyId and secretAccessKey must be provided together.");
92
+ const hasCredentials = hasAccessKey && hasSecretKey;
93
+ if (!hasCredentials && config.endpoint) throw new Error(`S3-compatible storage requires credentials. Detected endpoint: ${config.endpoint}. The public_bucket option only works for AWS S3 public buckets, not R2, MinIO, etc.`);
94
+ if (config.endpoint) {
95
+ const endpoint = config.endpoint.replace(/\/$/, "");
96
+ const connectivityCheck = await run(`curl -sS --max-time 5 ${shellQuote(endpoint)} 2>&1`, 1e4);
97
+ const checkOutput = connectivityCheck.stdout.trim();
98
+ if (connectivityCheck.exitCode !== 0 || checkOutput.toLowerCase().includes("restricted") || checkOutput.toLowerCase().includes("blocked")) throw new Error(`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 ? `\n\nSandbox network response: ${checkOutput}` : ""));
99
+ }
100
+ if ((await run("which s3fs 2>/dev/null || echo \"not found\"", 3e4)).stdout.includes("not found")) {
101
+ logger.warn(`${LOG_PREFIX} s3fs not found, attempting runtime installation...`);
102
+ logger.info(`${LOG_PREFIX} Tip: For faster startup, pre-install s3fs in your sandbox image`);
103
+ await run("sudo apt-get update -qq 2>&1", 6e4);
104
+ await run("sudo apt-get install -y s3fs fuse 2>&1 || sudo apt-get install -y s3fs-fuse fuse 2>&1 || true", 12e4);
105
+ if ((await run("which s3fs 2>/dev/null || echo \"not found\"", 3e4)).stdout.includes("not found")) throw new Error("Failed to install s3fs: binary not found after install attempt");
106
+ }
107
+ await run("sudo chmod u+s /usr/bin/fusermount3 /usr/bin/fusermount 2>/dev/null || true", 3e4);
108
+ const idResult = await run("id -u && id -g", 3e4);
109
+ const [uid, gid] = idResult.stdout.trim().split("\n");
110
+ const validUidGid = uid && gid && /^\d+$/.test(uid) && /^\d+$/.test(gid);
111
+ if (!validUidGid) logger.warn(`${LOG_PREFIX} Unexpected uid/gid format: "${idResult.stdout.trim()}" — mounted files will be owned by root`);
112
+ const credentialsPath = `/tmp/.passwd-s3fs-${(0, crypto.createHash)("md5").update(mountPath).digest("hex").slice(0, 8)}`;
113
+ await run("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");
114
+ if (hasCredentials) {
115
+ await run(`sudo rm -f ${shellQuote(credentialsPath)}`, 3e4);
116
+ await writeFile(credentialsPath, `${config.accessKeyId}:${config.secretAccessKey}`);
117
+ await run(`chmod 600 ${shellQuote(credentialsPath)}`, 3e4);
118
+ }
119
+ const mountOptions = [];
120
+ if (hasCredentials) mountOptions.push(`passwd_file=${credentialsPath}`);
121
+ else {
122
+ mountOptions.push("public_bucket=1");
123
+ logger.debug(`${LOG_PREFIX} No credentials provided, mounting as public bucket (read-only)`);
124
+ }
125
+ mountOptions.push("allow_other");
126
+ if (validUidGid) mountOptions.push(`uid=${uid}`, `gid=${gid}`);
127
+ if (config.endpoint) {
128
+ const endpoint = config.endpoint.replace(/\/$/, "");
129
+ mountOptions.push(`url=${shellQuote(endpoint)}`, "use_path_request_style", "sigv4", "nomultipart");
130
+ }
131
+ if (config.readOnly) {
132
+ mountOptions.push("ro");
133
+ logger.debug(`${LOG_PREFIX} Mounting as read-only`);
134
+ }
135
+ let bucketArg = config.bucket;
136
+ if (config.prefix) {
137
+ const normalizedPrefix = validatePrefix(config.prefix);
138
+ bucketArg = `${config.bucket}:/${normalizedPrefix}`;
139
+ }
140
+ const mountCmd = `s3fs ${shellQuote(bucketArg)} ${quotedMountPath} -o ${mountOptions.join(" -o ")}`;
141
+ logger.debug(`${LOG_PREFIX} Mounting S3:`, hasCredentials ? mountCmd.replace(credentialsPath, "***") : mountCmd);
142
+ const result = await run(mountCmd, 6e4);
143
+ logger.debug(`${LOG_PREFIX} s3fs result:`, {
144
+ exitCode: result.exitCode,
145
+ stdout: result.stdout,
146
+ stderr: result.stderr
147
+ });
148
+ if (result.exitCode !== 0) throw new Error(`Failed to mount S3 bucket: ${result.stderr || result.stdout}`);
165
149
  }
150
+ //#endregion
151
+ //#region src/sandbox/mounts/gcs.ts
152
+ /**
153
+ * Mount a GCS bucket using gcsfuse.
154
+ */
166
155
  async function mountGCS(mountPath, config, ctx) {
167
- const { run, writeFile, logger } = ctx;
168
- validateBucketName(config.bucket);
169
- const quotedMountPath = shellQuote(mountPath);
170
- const connectivityCheck = await run("curl -sS --max-time 5 http://storage.googleapis.com 2>&1", 1e4);
171
- const checkOutput = connectivityCheck.stdout.trim();
172
- if (connectivityCheck.exitCode !== 0 || checkOutput.toLowerCase().includes("restricted") || checkOutput.toLowerCase().includes("blocked")) {
173
- throw new Error(
174
- `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 ? `
175
-
176
- Sandbox network response: ${checkOutput}` : "")
177
- );
178
- }
179
- const checkResult = await run('which gcsfuse 2>/dev/null || echo "not found"', 3e4);
180
- if (checkResult.stdout.includes("not found")) {
181
- logger.warn(`${LOG_PREFIX} gcsfuse not found, attempting runtime installation...`);
182
- logger.info(`${LOG_PREFIX} Tip: For faster startup, pre-install gcsfuse in your sandbox image`);
183
- await run("sudo apt-get update -qq 2>&1", 6e4);
184
- const prepResult = await run("sudo apt-get install -y curl gnupg 2>&1", 12e4);
185
- if (prepResult.exitCode !== 0) {
186
- throw new Error(
187
- `Failed to install gcsfuse prerequisites (curl, gnupg): ${prepResult.stderr || prepResult.stdout}`
188
- );
189
- }
190
- const distroIdResult = await run(
191
- 'cat /etc/os-release 2>/dev/null | grep "^ID=" | cut -d= -f2 || echo debian',
192
- 3e4
193
- );
194
- const distroId = distroIdResult.stdout.trim().replace(/"/g, "") || "debian";
195
- const fallbackCodename = distroId === "ubuntu" ? "jammy" : "bookworm";
196
- const codenameResult = await run(
197
- `cat /etc/os-release 2>/dev/null | grep "^VERSION_CODENAME=" | cut -d= -f2 || echo ${fallbackCodename}`,
198
- 3e4
199
- );
200
- const detectedCodename = codenameResult.stdout.trim() || fallbackCodename;
201
- if (!/^[a-z0-9][a-z0-9-]*$/.test(detectedCodename)) {
202
- throw new Error(`Invalid distro codename for gcsfuse repo: "${detectedCodename}"`);
203
- }
204
- logger.debug(`${LOG_PREFIX} Detected distro: ${distroId}/${detectedCodename}, fallback: ${fallbackCodename}`);
205
- const repoSetup = await run(
206
- `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`,
207
- 3e4
208
- );
209
- if (repoSetup.exitCode !== 0) {
210
- throw new Error(`Failed to set up gcsfuse apt repository: ${repoSetup.stderr || repoSetup.stdout}`);
211
- }
212
- await run("sudo apt-get update -qq 2>&1 || true", 6e4);
213
- let installResult = await run("sudo apt-get install -y gcsfuse 2>&1", 12e4);
214
- if (installResult.exitCode !== 0 && detectedCodename !== fallbackCodename) {
215
- logger.warn(
216
- `${LOG_PREFIX} gcsfuse install failed for "${detectedCodename}", retrying with "${fallbackCodename}" fallback`
217
- );
218
- await run(
219
- `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`,
220
- 1e4
221
- );
222
- await run("sudo apt-get update -qq 2>&1 || true", 6e4);
223
- installResult = await run("sudo apt-get install -y gcsfuse 2>&1", 12e4);
224
- }
225
- const verifyResult = await run('which gcsfuse 2>/dev/null || echo "not found"', 3e4);
226
- if (verifyResult.stdout.includes("not found")) {
227
- throw new Error(`Failed to install gcsfuse: ${installResult.stderr || installResult.stdout}`);
228
- }
229
- if (installResult.exitCode !== 0) {
230
- logger.warn(
231
- `${LOG_PREFIX} gcsfuse install reported dpkg errors (likely fuse post-install in container) but binary is present \u2014 proceeding`
232
- );
233
- }
234
- }
235
- const idResult = await run("id -u && id -g", 3e4);
236
- const [uid, gid] = idResult.stdout.trim().split("\n");
237
- const validUidGid = uid && gid && /^\d+$/.test(uid) && /^\d+$/.test(gid);
238
- if (!validUidGid) {
239
- logger.warn(
240
- `${LOG_PREFIX} Unexpected uid/gid format: "${idResult.stdout.trim()}" \u2014 mounted files will be owned by root`
241
- );
242
- }
243
- const uidGidFlags = validUidGid ? `--uid=${uid} --gid=${gid}` : "";
244
- await run(
245
- `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`
246
- );
247
- let onlyDirFlag = "";
248
- if (config.prefix) {
249
- const normalizedPrefix = validatePrefix(config.prefix);
250
- onlyDirFlag = `--only-dir=${shellQuote(normalizedPrefix)} `;
251
- }
252
- const hasCredentials = !!config.serviceAccountKey;
253
- const implicitDirsFlag = "--implicit-dirs";
254
- let mountCmd;
255
- if (hasCredentials) {
256
- const mountHash = crypto.createHash("md5").update(mountPath).digest("hex").slice(0, 8);
257
- const keyPath = `/tmp/gcs-key-${mountHash}.json`;
258
- await run(`sudo rm -f ${shellQuote(keyPath)}`, 3e4);
259
- await writeFile(keyPath, config.serviceAccountKey);
260
- await run(`chmod 600 ${shellQuote(keyPath)}`, 3e4);
261
- mountCmd = `gcsfuse --key-file=${shellQuote(keyPath)} ${implicitDirsFlag} ${onlyDirFlag}-o allow_other ${uidGidFlags} ${shellQuote(config.bucket)} ${quotedMountPath}`;
262
- } else {
263
- logger.debug(`${LOG_PREFIX} No credentials provided, mounting GCS as public bucket (read-only)`);
264
- mountCmd = `gcsfuse --anonymous-access ${implicitDirsFlag} ${onlyDirFlag}-o allow_other ${uidGidFlags} ${shellQuote(config.bucket)} ${quotedMountPath}`;
265
- }
266
- logger.debug(`${LOG_PREFIX} Mounting GCS:`, mountCmd);
267
- const result = await run(mountCmd, 6e4);
268
- logger.debug(`${LOG_PREFIX} gcsfuse result:`, {
269
- exitCode: result.exitCode,
270
- stdout: result.stdout,
271
- stderr: result.stderr
272
- });
273
- if (result.exitCode !== 0) {
274
- throw new Error(`Failed to mount GCS bucket: ${result.stderr || result.stdout}`);
275
- }
156
+ const { run, writeFile, logger } = ctx;
157
+ validateBucketName(config.bucket);
158
+ const quotedMountPath = shellQuote(mountPath);
159
+ const connectivityCheck = await run("curl -sS --max-time 5 http://storage.googleapis.com 2>&1", 1e4);
160
+ const checkOutput = connectivityCheck.stdout.trim();
161
+ if (connectivityCheck.exitCode !== 0 || checkOutput.toLowerCase().includes("restricted") || checkOutput.toLowerCase().includes("blocked")) throw new Error("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 ? `\n\nSandbox network response: ${checkOutput}` : ""));
162
+ if ((await run("which gcsfuse 2>/dev/null || echo \"not found\"", 3e4)).stdout.includes("not found")) {
163
+ logger.warn(`${LOG_PREFIX} gcsfuse not found, attempting runtime installation...`);
164
+ logger.info(`${LOG_PREFIX} Tip: For faster startup, pre-install gcsfuse in your sandbox image`);
165
+ await run("sudo apt-get update -qq 2>&1", 6e4);
166
+ const prepResult = await run("sudo apt-get install -y curl gnupg 2>&1", 12e4);
167
+ if (prepResult.exitCode !== 0) throw new Error(`Failed to install gcsfuse prerequisites (curl, gnupg): ${prepResult.stderr || prepResult.stdout}`);
168
+ const distroId = (await run("cat /etc/os-release 2>/dev/null | grep \"^ID=\" | cut -d= -f2 || echo debian", 3e4)).stdout.trim().replace(/"/g, "") || "debian";
169
+ const fallbackCodename = distroId === "ubuntu" ? "jammy" : "bookworm";
170
+ const detectedCodename = (await run(`cat /etc/os-release 2>/dev/null | grep "^VERSION_CODENAME=" | cut -d= -f2 || echo ${fallbackCodename}`, 3e4)).stdout.trim() || fallbackCodename;
171
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(detectedCodename)) throw new Error(`Invalid distro codename for gcsfuse repo: "${detectedCodename}"`);
172
+ logger.debug(`${LOG_PREFIX} Detected distro: ${distroId}/${detectedCodename}, fallback: ${fallbackCodename}`);
173
+ const repoSetup = await run(`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`, 3e4);
174
+ if (repoSetup.exitCode !== 0) throw new Error(`Failed to set up gcsfuse apt repository: ${repoSetup.stderr || repoSetup.stdout}`);
175
+ await run("sudo apt-get update -qq 2>&1 || true", 6e4);
176
+ let installResult = await run("sudo apt-get install -y gcsfuse 2>&1", 12e4);
177
+ if (installResult.exitCode !== 0 && detectedCodename !== fallbackCodename) {
178
+ logger.warn(`${LOG_PREFIX} gcsfuse install failed for "${detectedCodename}", retrying with "${fallbackCodename}" fallback`);
179
+ await run(`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`, 1e4);
180
+ await run("sudo apt-get update -qq 2>&1 || true", 6e4);
181
+ installResult = await run("sudo apt-get install -y gcsfuse 2>&1", 12e4);
182
+ }
183
+ if ((await run("which gcsfuse 2>/dev/null || echo \"not found\"", 3e4)).stdout.includes("not found")) throw new Error(`Failed to install gcsfuse: ${installResult.stderr || installResult.stdout}`);
184
+ if (installResult.exitCode !== 0) logger.warn(`${LOG_PREFIX} gcsfuse install reported dpkg errors (likely fuse post-install in container) but binary is present — proceeding`);
185
+ }
186
+ const idResult = await run("id -u && id -g", 3e4);
187
+ const [uid, gid] = idResult.stdout.trim().split("\n");
188
+ const validUidGid = uid && gid && /^\d+$/.test(uid) && /^\d+$/.test(gid);
189
+ if (!validUidGid) logger.warn(`${LOG_PREFIX} Unexpected uid/gid format: "${idResult.stdout.trim()}" mounted files will be owned by root`);
190
+ const uidGidFlags = validUidGid ? `--uid=${uid} --gid=${gid}` : "";
191
+ await run("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");
192
+ let onlyDirFlag = "";
193
+ if (config.prefix) onlyDirFlag = `--only-dir=${shellQuote(validatePrefix(config.prefix))} `;
194
+ const hasCredentials = !!config.serviceAccountKey;
195
+ const implicitDirsFlag = "--implicit-dirs";
196
+ let mountCmd;
197
+ if (hasCredentials) {
198
+ const keyPath = `/tmp/gcs-key-${(0, crypto.createHash)("md5").update(mountPath).digest("hex").slice(0, 8)}.json`;
199
+ await run(`sudo rm -f ${shellQuote(keyPath)}`, 3e4);
200
+ await writeFile(keyPath, config.serviceAccountKey);
201
+ await run(`chmod 600 ${shellQuote(keyPath)}`, 3e4);
202
+ mountCmd = `gcsfuse --key-file=${shellQuote(keyPath)} ${implicitDirsFlag} ${onlyDirFlag}-o allow_other ${uidGidFlags} ${shellQuote(config.bucket)} ${quotedMountPath}`;
203
+ } else {
204
+ logger.debug(`${LOG_PREFIX} No credentials provided, mounting GCS as public bucket (read-only)`);
205
+ mountCmd = `gcsfuse --anonymous-access ${implicitDirsFlag} ${onlyDirFlag}-o allow_other ${uidGidFlags} ${shellQuote(config.bucket)} ${quotedMountPath}`;
206
+ }
207
+ logger.debug(`${LOG_PREFIX} Mounting GCS:`, mountCmd);
208
+ const result = await run(mountCmd, 6e4);
209
+ logger.debug(`${LOG_PREFIX} gcsfuse result:`, {
210
+ exitCode: result.exitCode,
211
+ stdout: result.stdout,
212
+ stderr: result.stderr
213
+ });
214
+ if (result.exitCode !== 0) throw new Error(`Failed to mount GCS bucket: ${result.stderr || result.stdout}`);
276
215
  }
277
- var SAFE_CONTAINER_NAME = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/;
278
- var BLOBFUSE2_GITHUB_DEB = "https://github.com/Azure/azure-storage-fuse/releases/download/blobfuse2-2.5.1/blobfuse2-2.5.1-Ubuntu-22.04.x86_64.deb";
216
+ //#endregion
217
+ //#region src/sandbox/mounts/azure.ts
218
+ const SAFE_CONTAINER_NAME = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/;
219
+ const BLOBFUSE2_GITHUB_DEB = "https://github.com/Azure/azure-storage-fuse/releases/download/blobfuse2-2.5.1/blobfuse2-2.5.1-Ubuntu-22.04.x86_64.deb";
279
220
  function validateContainerName(name) {
280
- if (!SAFE_CONTAINER_NAME.test(name) || name.includes("--")) {
281
- throw new Error(
282
- `Invalid Azure container name: "${name}". Container names must be 3-63 lowercase alphanumeric characters or hyphens, with no consecutive hyphens.`
283
- );
284
- }
221
+ if (!SAFE_CONTAINER_NAME.test(name) || name.includes("--")) throw new Error(`Invalid Azure container name: "${name}". Container names must be 3-63 lowercase alphanumeric characters or hyphens, with no consecutive hyphens.`);
285
222
  }
286
223
  function parseConnectionString(cs) {
287
- const out = {};
288
- for (const part of cs.split(";")) {
289
- const eq = part.indexOf("=");
290
- if (eq === -1) continue;
291
- const key = part.slice(0, eq).trim();
292
- const value = part.slice(eq + 1).trim();
293
- if (!value) continue;
294
- if (key === "AccountName") out.accountName = value;
295
- else if (key === "AccountKey") out.accountKey = value;
296
- else if (key === "SharedAccessSignature") out.sasToken = value;
297
- else if (key === "BlobEndpoint") out.endpoint = value;
298
- else if (key === "EndpointSuffix") out.endpointSuffix = value;
299
- else if (key === "DefaultEndpointsProtocol") out.protocol = value;
300
- }
301
- if (!out.endpoint && out.accountName) {
302
- out.endpoint = `${out.protocol || "https"}://${out.accountName}.blob.${out.endpointSuffix || "core.windows.net"}`;
303
- }
304
- return out;
224
+ const out = {};
225
+ for (const part of cs.split(";")) {
226
+ const eq = part.indexOf("=");
227
+ if (eq === -1) continue;
228
+ const key = part.slice(0, eq).trim();
229
+ const value = part.slice(eq + 1).trim();
230
+ if (!value) continue;
231
+ if (key === "AccountName") out.accountName = value;
232
+ else if (key === "AccountKey") out.accountKey = value;
233
+ else if (key === "SharedAccessSignature") out.sasToken = value;
234
+ else if (key === "BlobEndpoint") out.endpoint = value;
235
+ else if (key === "EndpointSuffix") out.endpointSuffix = value;
236
+ else if (key === "DefaultEndpointsProtocol") out.protocol = value;
237
+ }
238
+ if (!out.endpoint && out.accountName) out.endpoint = `${out.protocol || "https"}://${out.accountName}.blob.${out.endpointSuffix || "core.windows.net"}`;
239
+ return out;
305
240
  }
306
241
  function yamlString(value) {
307
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
242
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
308
243
  }
309
244
  function parseOsRelease(output) {
310
- const values = {};
311
- for (const line of output.split("\n")) {
312
- const eq = line.indexOf("=");
313
- if (eq === -1) continue;
314
- const key = line.slice(0, eq);
315
- const value = line.slice(eq + 1).trim().replace(/^"|"$/g, "");
316
- values[key] = value;
317
- }
318
- return values;
245
+ const values = {};
246
+ for (const line of output.split("\n")) {
247
+ const eq = line.indexOf("=");
248
+ if (eq === -1) continue;
249
+ const key = line.slice(0, eq);
250
+ values[key] = line.slice(eq + 1).trim().replace(/^"|"$/g, "");
251
+ }
252
+ return values;
319
253
  }
320
254
  function resolveMicrosoftAptRepos(osReleaseOutput) {
321
- const osRelease = parseOsRelease(osReleaseOutput);
322
- const distroId = osRelease.ID || "ubuntu";
323
- const codename = osRelease.VERSION_CODENAME || (distroId === "debian" ? "bookworm" : "jammy");
324
- const versionId = osRelease.VERSION_ID || (distroId === "debian" ? "12" : "22.04");
325
- if (!/^[a-z0-9][a-z0-9-]*$/.test(codename)) {
326
- throw new Error(`Invalid distro codename for blobfuse2 repo: "${codename}"`);
327
- }
328
- if (!/^\d+(?:\.\d+)?$/.test(versionId)) {
329
- throw new Error(`Invalid distro version for blobfuse2 repo: "${versionId}"`);
330
- }
331
- if (distroId === "debian") {
332
- const repos = [
333
- { repoUrl: `https://packages.microsoft.com/debian/${versionId.split(".")[0]}/prod`, suite: codename }
334
- ];
335
- if (versionId.split(".")[0] !== "12" || codename !== "bookworm") {
336
- repos.push({ repoUrl: "https://packages.microsoft.com/debian/12/prod", suite: "bookworm" });
337
- }
338
- return repos;
339
- }
340
- if (distroId === "ubuntu") {
341
- const repos = [{ repoUrl: `https://packages.microsoft.com/ubuntu/${versionId}/prod`, suite: codename }];
342
- if (versionId !== "24.04" || codename !== "noble") {
343
- repos.push({ repoUrl: "https://packages.microsoft.com/ubuntu/24.04/prod", suite: "noble" });
344
- }
345
- if (versionId !== "22.04" || codename !== "jammy") {
346
- repos.push({ repoUrl: "https://packages.microsoft.com/ubuntu/22.04/prod", suite: "jammy" });
347
- }
348
- return repos;
349
- }
350
- throw new Error(`Unsupported distro for blobfuse2 runtime installation: "${distroId}"`);
255
+ const osRelease = parseOsRelease(osReleaseOutput);
256
+ const distroId = osRelease.ID || "ubuntu";
257
+ const codename = osRelease.VERSION_CODENAME || (distroId === "debian" ? "bookworm" : "jammy");
258
+ const versionId = osRelease.VERSION_ID || (distroId === "debian" ? "12" : "22.04");
259
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(codename)) throw new Error(`Invalid distro codename for blobfuse2 repo: "${codename}"`);
260
+ if (!/^\d+(?:\.\d+)?$/.test(versionId)) throw new Error(`Invalid distro version for blobfuse2 repo: "${versionId}"`);
261
+ if (distroId === "debian") {
262
+ const repos = [{
263
+ repoUrl: `https://packages.microsoft.com/debian/${versionId.split(".")[0]}/prod`,
264
+ suite: codename
265
+ }];
266
+ if (versionId.split(".")[0] !== "12" || codename !== "bookworm") repos.push({
267
+ repoUrl: "https://packages.microsoft.com/debian/12/prod",
268
+ suite: "bookworm"
269
+ });
270
+ return repos;
271
+ }
272
+ if (distroId === "ubuntu") {
273
+ const repos = [{
274
+ repoUrl: `https://packages.microsoft.com/ubuntu/${versionId}/prod`,
275
+ suite: codename
276
+ }];
277
+ if (versionId !== "24.04" || codename !== "noble") repos.push({
278
+ repoUrl: "https://packages.microsoft.com/ubuntu/24.04/prod",
279
+ suite: "noble"
280
+ });
281
+ if (versionId !== "22.04" || codename !== "jammy") repos.push({
282
+ repoUrl: "https://packages.microsoft.com/ubuntu/22.04/prod",
283
+ suite: "jammy"
284
+ });
285
+ return repos;
286
+ }
287
+ throw new Error(`Unsupported distro for blobfuse2 runtime installation: "${distroId}"`);
351
288
  }
352
289
  function resolveAuth(config) {
353
- let accountName = config.accountName;
354
- let accountKey = config.accountKey;
355
- let sasToken = config.sasToken;
356
- let endpoint = config.endpoint;
357
- if (config.connectionString) {
358
- const parsed = parseConnectionString(config.connectionString);
359
- accountName = accountName ?? parsed.accountName;
360
- accountKey = accountKey ?? parsed.accountKey;
361
- sasToken = sasToken ?? parsed.sasToken;
362
- endpoint = endpoint ?? parsed.endpoint;
363
- }
364
- let mode;
365
- if (config.useDefaultCredential) {
366
- mode = "msi";
367
- } else if (sasToken) {
368
- mode = "sas";
369
- } else if (accountKey) {
370
- mode = "key";
371
- } else {
372
- throw new Error(
373
- "Azure Blob mount requires credentials: provide connectionString, accountKey + accountName, sasToken + accountName, or useDefaultCredential."
374
- );
375
- }
376
- if (!accountName) {
377
- throw new Error("Azure Blob mount requires an accountName (either explicitly or via connectionString).");
378
- }
379
- if (endpoint) {
380
- validateEndpoint(endpoint);
381
- }
382
- return { mode, accountName, accountKey, sasToken, endpoint };
290
+ let accountName = config.accountName;
291
+ let accountKey = config.accountKey;
292
+ let sasToken = config.sasToken;
293
+ let endpoint = config.endpoint;
294
+ if (config.connectionString) {
295
+ const parsed = parseConnectionString(config.connectionString);
296
+ accountName = accountName ?? parsed.accountName;
297
+ accountKey = accountKey ?? parsed.accountKey;
298
+ sasToken = sasToken ?? parsed.sasToken;
299
+ endpoint = endpoint ?? parsed.endpoint;
300
+ }
301
+ let mode;
302
+ if (config.useDefaultCredential) mode = "msi";
303
+ else if (sasToken) mode = "sas";
304
+ else if (accountKey) mode = "key";
305
+ else throw new Error("Azure Blob mount requires credentials: provide connectionString, accountKey + accountName, sasToken + accountName, or useDefaultCredential.");
306
+ if (!accountName) throw new Error("Azure Blob mount requires an accountName (either explicitly or via connectionString).");
307
+ if (endpoint) validateEndpoint(endpoint);
308
+ return {
309
+ mode,
310
+ accountName,
311
+ accountKey,
312
+ sasToken,
313
+ endpoint
314
+ };
383
315
  }
384
316
  function buildBlobfuseConfig(container, auth, cachePath, readOnly) {
385
- const lines = [
386
- "allow-other: true",
387
- "foreground: false",
388
- `read-only: ${readOnly ? "true" : "false"}`,
389
- "logging:",
390
- " type: silent",
391
- "components:",
392
- " - libfuse",
393
- " - file_cache",
394
- " - attr_cache",
395
- " - azstorage",
396
- "libfuse:",
397
- " attribute-expiration-sec: 240",
398
- " entry-expiration-sec: 240",
399
- " negative-entry-expiration-sec: 120",
400
- "file_cache:",
401
- ` path: ${yamlString(cachePath)}`,
402
- " timeout-sec: 120",
403
- "attr_cache:",
404
- " timeout-sec: 7200",
405
- "azstorage:",
406
- ` mode: ${auth.mode}`,
407
- ` account-name: ${yamlString(auth.accountName)}`,
408
- ` container: ${yamlString(container)}`
409
- ];
410
- if (auth.mode === "key" && auth.accountKey) {
411
- lines.push(` account-key: ${yamlString(auth.accountKey)}`);
412
- } else if (auth.mode === "sas" && auth.sasToken) {
413
- lines.push(` sas: ${yamlString(auth.sasToken)}`);
414
- }
415
- if (auth.endpoint) {
416
- lines.push(` endpoint: ${yamlString(auth.endpoint.replace(/\/$/, ""))}`);
417
- }
418
- return lines.join("\n") + "\n";
317
+ const lines = [
318
+ "allow-other: true",
319
+ "foreground: false",
320
+ `read-only: ${readOnly ? "true" : "false"}`,
321
+ "logging:",
322
+ " type: silent",
323
+ "components:",
324
+ " - libfuse",
325
+ " - file_cache",
326
+ " - attr_cache",
327
+ " - azstorage",
328
+ "libfuse:",
329
+ " attribute-expiration-sec: 240",
330
+ " entry-expiration-sec: 240",
331
+ " negative-entry-expiration-sec: 120",
332
+ "file_cache:",
333
+ ` path: ${yamlString(cachePath)}`,
334
+ " timeout-sec: 120",
335
+ "attr_cache:",
336
+ " timeout-sec: 7200",
337
+ "azstorage:",
338
+ ` mode: ${auth.mode}`,
339
+ ` account-name: ${yamlString(auth.accountName)}`,
340
+ ` container: ${yamlString(container)}`
341
+ ];
342
+ if (auth.mode === "key" && auth.accountKey) lines.push(` account-key: ${yamlString(auth.accountKey)}`);
343
+ else if (auth.mode === "sas" && auth.sasToken) lines.push(` sas: ${yamlString(auth.sasToken)}`);
344
+ if (auth.endpoint) lines.push(` endpoint: ${yamlString(auth.endpoint.replace(/\/$/, ""))}`);
345
+ return lines.join("\n") + "\n";
419
346
  }
347
+ /**
348
+ * Mount an Azure Blob container using blobfuse2.
349
+ */
420
350
  async function mountAzure(mountPath, config, ctx) {
421
- const { run, writeFile, logger } = ctx;
422
- validateContainerName(config.container);
423
- const auth = resolveAuth(config);
424
- const prefix = config.prefix ? validatePrefix(config.prefix) : void 0;
425
- const quotedMountPath = shellQuote(mountPath);
426
- const curlCheck = await run('which curl 2>/dev/null || echo "not found"', 3e4);
427
- if (curlCheck.stdout.includes("not found")) {
428
- const curlInstall = await run("sudo apt-get update -qq 2>&1 && sudo apt-get install -y curl 2>&1", 12e4);
429
- if (curlInstall.exitCode !== 0) {
430
- throw new Error(
431
- `Failed to install curl for Azure Blob reachability check: ${curlInstall.stderr || curlInstall.stdout}`
432
- );
433
- }
434
- }
435
- const probeUrl = auth.endpoint ? auth.endpoint.replace(/\/$/, "") : `https://${auth.accountName}.blob.core.windows.net`;
436
- const connectivityCheck = await run(`curl -sS --max-time 5 ${shellQuote(probeUrl)} 2>&1`, 1e4);
437
- const checkOutput = connectivityCheck.stdout.trim();
438
- if (connectivityCheck.exitCode !== 0 || checkOutput.toLowerCase().includes("restricted") || checkOutput.toLowerCase().includes("blocked")) {
439
- throw new Error(
440
- `Cannot reach ${probeUrl} from this sandbox. Azure Blob mounting requires network access to the storage 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 ? `
441
-
442
- Sandbox network response: ${checkOutput}` : "")
443
- );
444
- }
445
- const checkResult = await run('which blobfuse2 2>/dev/null || echo "not found"', 3e4);
446
- if (checkResult.stdout.includes("not found")) {
447
- logger.warn(`${LOG_PREFIX} blobfuse2 not found, attempting runtime installation...`);
448
- logger.info(`${LOG_PREFIX} Tip: For faster startup, pre-install blobfuse2 in your sandbox image`);
449
- await run("sudo apt-get update -qq 2>&1", 6e4);
450
- const prepResult = await run("sudo apt-get install -y curl gnupg 2>&1", 12e4);
451
- if (prepResult.exitCode !== 0) {
452
- throw new Error(
453
- `Failed to install blobfuse2 prerequisites (curl, gnupg): ${prepResult.stderr || prepResult.stdout}`
454
- );
455
- }
456
- const osReleaseResult = await run("cat /etc/os-release 2>/dev/null || true", 3e4);
457
- const repos = resolveMicrosoftAptRepos(osReleaseResult.stdout);
458
- const repoSetup = await run(
459
- "sudo mkdir -p /etc/apt/keyrings && curl --retry 3 --retry-all-errors --retry-delay 2 -fsSL https://packages.microsoft.com/keys/microsoft.asc -o /tmp/ms-key.asc && sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/microsoft.gpg /tmp/ms-key.asc",
460
- 3e4
461
- );
462
- let installResult;
463
- if (repoSetup.exitCode === 0) {
464
- for (const { repoUrl, suite } of repos) {
465
- await run(
466
- `echo "deb [signed-by=/etc/apt/keyrings/microsoft.gpg] ${repoUrl} ${suite} main" | sudo tee /etc/apt/sources.list.d/microsoft-prod.list`,
467
- 3e4
468
- );
469
- await run("sudo apt-get update -qq 2>&1 || true", 6e4);
470
- installResult = await run("sudo apt-get install -y blobfuse2 fuse3 2>&1", 12e4);
471
- if (installResult.exitCode === 0) break;
472
- logger.warn(`${LOG_PREFIX} blobfuse2 install failed for ${repoUrl} ${suite}, trying fallback if available`);
473
- }
474
- } else {
475
- logger.warn(`${LOG_PREFIX} Failed to set up Microsoft apt repository, trying GitHub release fallback`);
476
- }
477
- let verifyResult = await run("which blobfuse2 && blobfuse2 --version", 3e4);
478
- if (verifyResult.exitCode !== 0) {
479
- installResult = await run(
480
- `sudo apt-get update -qq 2>&1 || true && sudo apt-get install -y fuse3 ca-certificates curl 2>&1 && curl -L --retry 3 --retry-all-errors --retry-delay 2 -fSLo /tmp/blobfuse2.deb ${BLOBFUSE2_GITHUB_DEB} && sudo dpkg -i /tmp/blobfuse2.deb 2>&1 && sudo bash -c 'lib=$(find /usr/lib -name "libfuse3.so.3.*" | head -1); [ -z "$lib" ] || ln -sf "$lib" /usr/lib/x86_64-linux-gnu/libfuse3.so.3'`,
481
- 18e4
482
- );
483
- verifyResult = await run("which blobfuse2 && blobfuse2 --version", 3e4);
484
- }
485
- if (!installResult || verifyResult.exitCode !== 0) {
486
- throw new Error(
487
- `Failed to install blobfuse2: ${verifyResult.stderr || verifyResult.stdout || installResult?.stderr || installResult?.stdout || "unknown error"}`
488
- );
489
- }
490
- }
491
- const idResult = await run("id -u && id -g", 3e4);
492
- const [uid, gid] = idResult.stdout.trim().split("\n");
493
- const validUidGid = uid && gid && /^\d+$/.test(uid) && /^\d+$/.test(gid);
494
- await run(
495
- `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`
496
- );
497
- const mountHash = crypto.createHash("md5").update(mountPath).digest("hex").slice(0, 8);
498
- const configPath = `/tmp/.blobfuse2-config-${mountHash}.yaml`;
499
- const cachePath = `/tmp/blobfuse2-cache-${mountHash}`;
500
- const yaml = buildBlobfuseConfig(config.container, auth, cachePath, !!config.readOnly);
501
- await run(`sudo rm -f ${shellQuote(configPath)}`, 3e4);
502
- await writeFile(configPath, yaml);
503
- await run(`chmod 600 ${shellQuote(configPath)}`, 3e4);
504
- await run(`sudo rm -rf ${shellQuote(cachePath)} && mkdir -p ${shellQuote(cachePath)}`, 3e4);
505
- if (validUidGid) {
506
- await run(`sudo chown ${uid}:${gid} ${shellQuote(cachePath)} 2>/dev/null || true`, 3e4);
507
- }
508
- const prefixFlags = prefix ? ` --virtual-directory=true --subdirectory=${shellQuote(prefix)}` : "";
509
- const mountCmd = `blobfuse2 mount ${quotedMountPath} --config-file=${shellQuote(configPath)}${prefixFlags}`;
510
- logger.debug(`${LOG_PREFIX} Mounting Azure Blob:`, mountCmd);
511
- const result = await run(mountCmd, 6e4);
512
- logger.debug(`${LOG_PREFIX} blobfuse2 result:`, {
513
- exitCode: result.exitCode,
514
- stdout: result.stdout,
515
- stderr: result.stderr
516
- });
517
- if (result.exitCode !== 0) {
518
- throw new Error(`Failed to mount Azure Blob container: ${result.stderr || result.stdout}`);
519
- }
351
+ const { run, writeFile, logger } = ctx;
352
+ validateContainerName(config.container);
353
+ const auth = resolveAuth(config);
354
+ const prefix = config.prefix ? validatePrefix(config.prefix) : void 0;
355
+ const quotedMountPath = shellQuote(mountPath);
356
+ if ((await run("which curl 2>/dev/null || echo \"not found\"", 3e4)).stdout.includes("not found")) {
357
+ const curlInstall = await run("sudo apt-get update -qq 2>&1 && sudo apt-get install -y curl 2>&1", 12e4);
358
+ if (curlInstall.exitCode !== 0) throw new Error(`Failed to install curl for Azure Blob reachability check: ${curlInstall.stderr || curlInstall.stdout}`);
359
+ }
360
+ const probeUrl = auth.endpoint ? auth.endpoint.replace(/\/$/, "") : `https://${auth.accountName}.blob.core.windows.net`;
361
+ const connectivityCheck = await run(`curl -sS --max-time 5 ${shellQuote(probeUrl)} 2>&1`, 1e4);
362
+ const checkOutput = connectivityCheck.stdout.trim();
363
+ if (connectivityCheck.exitCode !== 0 || checkOutput.toLowerCase().includes("restricted") || checkOutput.toLowerCase().includes("blocked")) throw new Error(`Cannot reach ${probeUrl} from this sandbox. Azure Blob mounting requires network access to the storage 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 ? `\n\nSandbox network response: ${checkOutput}` : ""));
364
+ if ((await run("which blobfuse2 2>/dev/null || echo \"not found\"", 3e4)).stdout.includes("not found")) {
365
+ logger.warn(`${LOG_PREFIX} blobfuse2 not found, attempting runtime installation...`);
366
+ logger.info(`${LOG_PREFIX} Tip: For faster startup, pre-install blobfuse2 in your sandbox image`);
367
+ await run("sudo apt-get update -qq 2>&1", 6e4);
368
+ const prepResult = await run("sudo apt-get install -y curl gnupg 2>&1", 12e4);
369
+ if (prepResult.exitCode !== 0) throw new Error(`Failed to install blobfuse2 prerequisites (curl, gnupg): ${prepResult.stderr || prepResult.stdout}`);
370
+ const repos = resolveMicrosoftAptRepos((await run("cat /etc/os-release 2>/dev/null || true", 3e4)).stdout);
371
+ const repoSetup = await run("sudo mkdir -p /etc/apt/keyrings && curl --retry 3 --retry-all-errors --retry-delay 2 -fsSL https://packages.microsoft.com/keys/microsoft.asc -o /tmp/ms-key.asc && sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/microsoft.gpg /tmp/ms-key.asc", 3e4);
372
+ let installResult;
373
+ if (repoSetup.exitCode === 0) for (const { repoUrl, suite } of repos) {
374
+ await run(`echo "deb [signed-by=/etc/apt/keyrings/microsoft.gpg] ${repoUrl} ${suite} main" | sudo tee /etc/apt/sources.list.d/microsoft-prod.list`, 3e4);
375
+ await run("sudo apt-get update -qq 2>&1 || true", 6e4);
376
+ installResult = await run("sudo apt-get install -y blobfuse2 fuse3 2>&1", 12e4);
377
+ if (installResult.exitCode === 0) break;
378
+ logger.warn(`${LOG_PREFIX} blobfuse2 install failed for ${repoUrl} ${suite}, trying fallback if available`);
379
+ }
380
+ else logger.warn(`${LOG_PREFIX} Failed to set up Microsoft apt repository, trying GitHub release fallback`);
381
+ let verifyResult = await run("which blobfuse2 && blobfuse2 --version", 3e4);
382
+ if (verifyResult.exitCode !== 0) {
383
+ installResult = await run(`sudo apt-get update -qq 2>&1 || true && sudo apt-get install -y fuse3 ca-certificates curl 2>&1 && curl -L --retry 3 --retry-all-errors --retry-delay 2 -fSLo /tmp/blobfuse2.deb ${BLOBFUSE2_GITHUB_DEB} && sudo dpkg -i /tmp/blobfuse2.deb 2>&1 && sudo bash -c 'lib=\$(find /usr/lib -name "libfuse3.so.3.*" | head -1); [ -z "\$lib" ] || ln -sf "\$lib" /usr/lib/x86_64-linux-gnu/libfuse3.so.3'`, 18e4);
384
+ verifyResult = await run("which blobfuse2 && blobfuse2 --version", 3e4);
385
+ }
386
+ if (!installResult || verifyResult.exitCode !== 0) throw new Error(`Failed to install blobfuse2: ${verifyResult.stderr || verifyResult.stdout || installResult?.stderr || installResult?.stdout || "unknown error"}`);
387
+ }
388
+ const [uid, gid] = (await run("id -u && id -g", 3e4)).stdout.trim().split("\n");
389
+ const validUidGid = uid && gid && /^\d+$/.test(uid) && /^\d+$/.test(gid);
390
+ await run("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");
391
+ const mountHash = (0, crypto.createHash)("md5").update(mountPath).digest("hex").slice(0, 8);
392
+ const configPath = `/tmp/.blobfuse2-config-${mountHash}.yaml`;
393
+ const cachePath = `/tmp/blobfuse2-cache-${mountHash}`;
394
+ const yaml = buildBlobfuseConfig(config.container, auth, cachePath, !!config.readOnly);
395
+ await run(`sudo rm -f ${shellQuote(configPath)}`, 3e4);
396
+ await writeFile(configPath, yaml);
397
+ await run(`chmod 600 ${shellQuote(configPath)}`, 3e4);
398
+ await run(`sudo rm -rf ${shellQuote(cachePath)} && mkdir -p ${shellQuote(cachePath)}`, 3e4);
399
+ if (validUidGid) await run(`sudo chown ${uid}:${gid} ${shellQuote(cachePath)} 2>/dev/null || true`, 3e4);
400
+ const prefixFlags = prefix ? ` --virtual-directory=true --subdirectory=${shellQuote(prefix)}` : "";
401
+ const mountCmd = `blobfuse2 mount ${quotedMountPath} --config-file=${shellQuote(configPath)}${prefixFlags}`;
402
+ logger.debug(`${LOG_PREFIX} Mounting Azure Blob:`, mountCmd);
403
+ const result = await run(mountCmd, 6e4);
404
+ logger.debug(`${LOG_PREFIX} blobfuse2 result:`, {
405
+ exitCode: result.exitCode,
406
+ stdout: result.stdout,
407
+ stderr: result.stderr
408
+ });
409
+ if (result.exitCode !== 0) throw new Error(`Failed to mount Azure Blob container: ${result.stderr || result.stdout}`);
520
410
  }
521
- var DaytonaProcessHandle = class extends workspace.ProcessHandle {
522
- pid;
523
- _cmdId;
524
- _sandbox;
525
- _startTime;
526
- _timeout;
527
- _exitCode;
528
- _waitPromise = null;
529
- _streamingPromise = null;
530
- _killed = false;
531
- constructor(sessionId, cmdId, sandbox, startTime, options) {
532
- super(options);
533
- this.pid = sessionId;
534
- this._cmdId = cmdId;
535
- this._sandbox = sandbox;
536
- this._startTime = startTime;
537
- this._timeout = options?.timeout;
538
- }
539
- get exitCode() {
540
- return this._exitCode;
541
- }
542
- /** @internal Set by the process manager after streaming starts. */
543
- set streamingPromise(p) {
544
- this._streamingPromise = p;
545
- p.then(() => this._resolveExitCode()).catch(() => this._resolveExitCode());
546
- }
547
- /** Fetch the exit code from Daytona and set _exitCode. No-op if already set. */
548
- async _resolveExitCode() {
549
- if (this._exitCode !== void 0) return;
550
- try {
551
- const cmd = await this._sandbox.process.getSessionCommand(this.pid, this._cmdId);
552
- this._exitCode = cmd.exitCode ?? 0;
553
- } catch {
554
- if (this._exitCode === void 0) {
555
- this._exitCode = 1;
556
- }
557
- }
558
- }
559
- async wait() {
560
- if (!this._waitPromise) {
561
- this._waitPromise = this._doWait();
562
- }
563
- return this._waitPromise;
564
- }
565
- async _doWait() {
566
- const streamDone = this._streamingPromise ?? Promise.resolve();
567
- if (this._timeout) {
568
- let timeoutId;
569
- const timeoutPromise = new Promise((_, reject) => {
570
- timeoutId = setTimeout(() => reject(new Error(`Command timed out after ${this._timeout}ms`)), this._timeout);
571
- });
572
- try {
573
- await Promise.race([streamDone, timeoutPromise]);
574
- } catch (error) {
575
- if (error instanceof Error && error.message.includes("timed out")) {
576
- await this.kill();
577
- this._exitCode = 124;
578
- return {
579
- success: false,
580
- exitCode: 124,
581
- stdout: this.stdout,
582
- stderr: this.stderr || error.message,
583
- executionTimeMs: Date.now() - this._startTime,
584
- killed: true,
585
- timedOut: true
586
- };
587
- }
588
- throw error;
589
- } finally {
590
- clearTimeout(timeoutId);
591
- }
592
- } else {
593
- await streamDone.catch(() => {
594
- });
595
- }
596
- if (this._killed) {
597
- return {
598
- success: false,
599
- exitCode: this._exitCode ?? 137,
600
- stdout: this.stdout,
601
- stderr: this.stderr,
602
- executionTimeMs: Date.now() - this._startTime,
603
- killed: true,
604
- timedOut: false
605
- };
606
- }
607
- await this._resolveExitCode();
608
- return {
609
- success: this._exitCode === 0,
610
- exitCode: this._exitCode ?? 1,
611
- stdout: this.stdout,
612
- stderr: this.stderr,
613
- executionTimeMs: Date.now() - this._startTime
614
- };
615
- }
616
- async kill() {
617
- if (this._exitCode !== void 0) return false;
618
- this._killed = true;
619
- this._exitCode = 137;
620
- try {
621
- await this._sandbox.process.deleteSession(this.pid);
622
- } catch {
623
- }
624
- return true;
625
- }
626
- async sendStdin(data) {
627
- if (this._exitCode !== void 0) {
628
- throw new Error(`Process ${this.pid} has already exited with code ${this._exitCode}`);
629
- }
630
- await this._sandbox.process.sendSessionCommandInput(this.pid, this._cmdId, data);
631
- }
411
+ //#endregion
412
+ //#region src/sandbox/process-manager.ts
413
+ /**
414
+ * Wraps a Daytona session + command pair to conform to Mastra's ProcessHandle.
415
+ * Not exported — internal to this module.
416
+ */
417
+ var DaytonaProcessHandle = class extends _mastra_core_workspace.ProcessHandle {
418
+ pid;
419
+ _cmdId;
420
+ _sandbox;
421
+ _startTime;
422
+ _timeout;
423
+ _exitCode;
424
+ _waitPromise = null;
425
+ _streamingPromise = null;
426
+ _killed = false;
427
+ constructor(sessionId, cmdId, sandbox, startTime, options) {
428
+ super(options);
429
+ this.pid = sessionId;
430
+ this._cmdId = cmdId;
431
+ this._sandbox = sandbox;
432
+ this._startTime = startTime;
433
+ this._timeout = options?.timeout;
434
+ }
435
+ get exitCode() {
436
+ return this._exitCode;
437
+ }
438
+ /** @internal Set by the process manager after streaming starts. */
439
+ set streamingPromise(p) {
440
+ this._streamingPromise = p;
441
+ p.then(() => this._resolveExitCode()).catch(() => this._resolveExitCode());
442
+ }
443
+ /** Fetch the exit code from Daytona and set _exitCode. No-op if already set. */
444
+ async _resolveExitCode() {
445
+ if (this._exitCode !== void 0) return;
446
+ try {
447
+ const cmd = await this._sandbox.process.getSessionCommand(this.pid, this._cmdId);
448
+ this._exitCode = cmd.exitCode ?? 0;
449
+ } catch {
450
+ if (this._exitCode === void 0) this._exitCode = 1;
451
+ }
452
+ }
453
+ async wait() {
454
+ if (!this._waitPromise) this._waitPromise = this._doWait();
455
+ return this._waitPromise;
456
+ }
457
+ async _doWait() {
458
+ const streamDone = this._streamingPromise ?? Promise.resolve();
459
+ if (this._timeout) {
460
+ let timeoutId;
461
+ const timeoutPromise = new Promise((_, reject) => {
462
+ timeoutId = setTimeout(() => reject(/* @__PURE__ */ new Error(`Command timed out after ${this._timeout}ms`)), this._timeout);
463
+ });
464
+ try {
465
+ await Promise.race([streamDone, timeoutPromise]);
466
+ } catch (error) {
467
+ if (error instanceof Error && error.message.includes("timed out")) {
468
+ await this.kill();
469
+ this._exitCode = 124;
470
+ return {
471
+ success: false,
472
+ exitCode: 124,
473
+ stdout: this.stdout,
474
+ stderr: this.stderr || error.message,
475
+ executionTimeMs: Date.now() - this._startTime,
476
+ killed: true,
477
+ timedOut: true
478
+ };
479
+ }
480
+ throw error;
481
+ } finally {
482
+ clearTimeout(timeoutId);
483
+ }
484
+ } else await streamDone.catch(() => {});
485
+ if (this._killed) return {
486
+ success: false,
487
+ exitCode: this._exitCode ?? 137,
488
+ stdout: this.stdout,
489
+ stderr: this.stderr,
490
+ executionTimeMs: Date.now() - this._startTime,
491
+ killed: true,
492
+ timedOut: false
493
+ };
494
+ await this._resolveExitCode();
495
+ return {
496
+ success: this._exitCode === 0,
497
+ exitCode: this._exitCode ?? 1,
498
+ stdout: this.stdout,
499
+ stderr: this.stderr,
500
+ executionTimeMs: Date.now() - this._startTime
501
+ };
502
+ }
503
+ async kill() {
504
+ if (this._exitCode !== void 0) return false;
505
+ this._killed = true;
506
+ this._exitCode = 137;
507
+ try {
508
+ await this._sandbox.process.deleteSession(this.pid);
509
+ } catch {}
510
+ return true;
511
+ }
512
+ async sendStdin(data) {
513
+ if (this._exitCode !== void 0) throw new Error(`Process ${this.pid} has already exited with code ${this._exitCode}`);
514
+ await this._sandbox.process.sendSessionCommandInput(this.pid, this._cmdId, data);
515
+ }
632
516
  };
633
- var DaytonaProcessManager = class extends workspace.SandboxProcessManager {
634
- _spawnCounter = 0;
635
- _defaultTimeout;
636
- constructor(opts = {}) {
637
- super({ env: opts.env });
638
- this._defaultTimeout = opts.defaultTimeout;
639
- }
640
- async spawn(command, options = {}) {
641
- const effectiveOptions = {
642
- ...options,
643
- timeout: options.timeout ?? this._defaultTimeout,
644
- cwd: options.cwd ?? this.sandbox.mounts?.entries?.keys().next().value
645
- };
646
- const mergedEnv = { ...this.env, ...effectiveOptions.env };
647
- const envs = Object.fromEntries(
648
- Object.entries(mergedEnv).filter((entry) => entry[1] !== void 0)
649
- );
650
- const sessionCommand = buildSpawnCommand(command, effectiveOptions.cwd, envs);
651
- return this.sandbox.retryOnDead(async () => {
652
- const sandbox = this.sandbox.daytona;
653
- const sessionId = `mastra-proc-${Date.now().toString(36)}-${++this._spawnCounter}`;
654
- await sandbox.process.createSession(sessionId);
655
- const { cmdId } = await sandbox.process.executeSessionCommand(sessionId, {
656
- command: sessionCommand,
657
- runAsync: true
658
- });
659
- const handle = new DaytonaProcessHandle(sessionId, cmdId, sandbox, Date.now(), effectiveOptions);
660
- const streamingPromise = sandbox.process.getSessionCommandLogs(
661
- sessionId,
662
- cmdId,
663
- (chunk) => handle.emitStdout(chunk),
664
- (chunk) => handle.emitStderr(chunk)
665
- ).catch(() => {
666
- });
667
- handle.streamingPromise = streamingPromise;
668
- this._tracked.set(handle.pid, handle);
669
- return handle;
670
- });
671
- }
672
- async list() {
673
- const result = [];
674
- for (const [pid, handle] of this._tracked) {
675
- result.push({
676
- pid,
677
- command: handle.command,
678
- running: handle.exitCode === void 0,
679
- exitCode: handle.exitCode
680
- });
681
- }
682
- return result;
683
- }
517
+ /**
518
+ * Daytona implementation of SandboxProcessManager.
519
+ * Uses the Daytona SDK's session API with one session per spawned process.
520
+ */
521
+ var DaytonaProcessManager = class extends _mastra_core_workspace.SandboxProcessManager {
522
+ _spawnCounter = 0;
523
+ _defaultTimeout;
524
+ constructor(opts = {}) {
525
+ super({ env: opts.env });
526
+ this._defaultTimeout = opts.defaultTimeout;
527
+ }
528
+ async spawn(command, options = {}) {
529
+ const effectiveOptions = {
530
+ ...options,
531
+ timeout: options.timeout ?? this._defaultTimeout,
532
+ cwd: options.cwd ?? this.sandbox.mounts?.entries?.keys().next().value
533
+ };
534
+ const mergedEnv = {
535
+ ...this.env,
536
+ ...effectiveOptions.env
537
+ };
538
+ const envs = Object.fromEntries(Object.entries(mergedEnv).filter((entry) => entry[1] !== void 0));
539
+ const sessionCommand = buildSpawnCommand(command, effectiveOptions.cwd, envs);
540
+ return this.sandbox.retryOnDead(async () => {
541
+ const sandbox = this.sandbox.daytona;
542
+ const sessionId = `mastra-proc-${Date.now().toString(36)}-${++this._spawnCounter}`;
543
+ await sandbox.process.createSession(sessionId);
544
+ const { cmdId } = await sandbox.process.executeSessionCommand(sessionId, {
545
+ command: sessionCommand,
546
+ runAsync: true
547
+ });
548
+ const handle = new DaytonaProcessHandle(sessionId, cmdId, sandbox, Date.now(), effectiveOptions);
549
+ handle.streamingPromise = sandbox.process.getSessionCommandLogs(sessionId, cmdId, (chunk) => handle.emitStdout(chunk), (chunk) => handle.emitStderr(chunk)).catch(() => {});
550
+ this._tracked.set(handle.pid, handle);
551
+ return handle;
552
+ });
553
+ }
554
+ async list() {
555
+ const result = [];
556
+ for (const [pid, handle] of this._tracked) result.push({
557
+ pid,
558
+ command: handle.command,
559
+ running: handle.exitCode === void 0,
560
+ exitCode: handle.exitCode
561
+ });
562
+ return result;
563
+ }
684
564
  };
685
- var SHELL_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
565
+ const SHELL_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
566
+ /**
567
+ * Build a shell command string that bakes in cwd and env vars.
568
+ * Wraps the user command in a subshell `(command)` so that:
569
+ * - `exit N` exits the subshell, not the session shell
570
+ * - Heredocs work correctly within the subshell
571
+ *
572
+ * @example
573
+ * buildSpawnCommand('npm test', '/app', { NODE_ENV: 'test' })
574
+ * // → "export NODE_ENV='test' && cd '/app' && (npm test)"
575
+ */
686
576
  function buildSpawnCommand(command, cwd, envs) {
687
- const parts = [];
688
- for (const [k, v] of Object.entries(envs)) {
689
- if (!SHELL_IDENTIFIER_PATTERN.test(k)) {
690
- throw new Error(`Invalid environment variable name: ${JSON.stringify(k)}`);
691
- }
692
- parts.push(`export ${k}=${shellQuote(v)}`);
693
- }
694
- if (cwd) {
695
- parts.push(`cd ${shellQuote(cwd)}`);
696
- }
697
- parts.push(`(${command})`);
698
- return parts.join(" && ");
577
+ const parts = [];
578
+ for (const [k, v] of Object.entries(envs)) {
579
+ if (!SHELL_IDENTIFIER_PATTERN.test(k)) throw new Error(`Invalid environment variable name: ${JSON.stringify(k)}`);
580
+ parts.push(`export ${k}=${shellQuote(v)}`);
581
+ }
582
+ if (cwd) parts.push(`cd ${shellQuote(cwd)}`);
583
+ parts.push(`(${command})`);
584
+ return parts.join(" && ");
699
585
  }
700
-
701
- // src/sandbox/index.ts
702
- var SAFE_MOUNT_PATH = /^\/[a-zA-Z0-9_.\-/]+$/;
703
- var MOUNT_COMMAND_TIMEOUT_MS = 3e4;
586
+ //#endregion
587
+ //#region src/sandbox/index.ts
588
+ /**
589
+ * Daytona Sandbox Provider
590
+ *
591
+ * A Daytona sandbox implementation for Mastra workspaces.
592
+ * Supports command execution, environment variables, resource configuration,
593
+ * snapshots, Daytona volumes, and FUSE-based cloud filesystem mounting (S3, GCS).
594
+ *
595
+ * @see https://www.daytona.io/docs
596
+ */
597
+ /** Allowlist pattern for mount paths — absolute path with safe characters only. */
598
+ const SAFE_MOUNT_PATH = /^\/[a-zA-Z0-9_.\-/]+$/;
599
+ /** Default timeout for mount lifecycle shell commands (mkdir, unmount, proc reads, etc.) */
600
+ const MOUNT_COMMAND_TIMEOUT_MS = 3e4;
601
+ /** Convert an unknown error to a readable string. */
704
602
  function errorToString(error) {
705
- if (error instanceof Error) return error.message;
706
- if (typeof error === "string") return error;
707
- if (error && typeof error === "object" && "message" in error) {
708
- const maybeError = error;
709
- if (typeof maybeError.message === "string") {
710
- return maybeError.message;
711
- }
712
- }
713
- try {
714
- return JSON.stringify(error);
715
- } catch {
716
- return String(error);
717
- }
603
+ if (error instanceof Error) return error.message;
604
+ if (typeof error === "string") return error;
605
+ if (error && typeof error === "object" && "message" in error) {
606
+ const maybeError = error;
607
+ if (typeof maybeError.message === "string") return maybeError.message;
608
+ }
609
+ try {
610
+ return JSON.stringify(error);
611
+ } catch {
612
+ return String(error);
613
+ }
718
614
  }
719
615
  function validateMountPath(mountPath) {
720
- if (!SAFE_MOUNT_PATH.test(mountPath)) {
721
- throw new Error(
722
- `Invalid mount path: ${mountPath}. Must be an absolute path with alphanumeric, dash, dot, underscore, or slash characters only.`
723
- );
724
- }
725
- const segments = mountPath.split("/");
726
- if (mountPath.includes("//") || segments.some((segment) => segment === "." || segment === "..")) {
727
- throw new Error(`Invalid mount path: ${mountPath}. Path traversal segments are not allowed.`);
728
- }
616
+ if (!SAFE_MOUNT_PATH.test(mountPath)) throw new Error(`Invalid mount path: ${mountPath}. Must be an absolute path with alphanumeric, dash, dot, underscore, or slash characters only.`);
617
+ const segments = mountPath.split("/");
618
+ if (mountPath.includes("//") || segments.some((segment) => segment === "." || segment === "..")) throw new Error(`Invalid mount path: ${mountPath}. Path traversal segments are not allowed.`);
729
619
  }
730
- var SAFE_MARKER_NAME = /^mount-[a-z0-9]+$/;
731
- var SANDBOX_DEAD_PATTERNS = [
732
- /sandbox is not running/i,
733
- /sandbox already destroyed/i,
734
- /sandbox.*not found/i,
735
- /failed to resolve container IP/i,
736
- /is the sandbox started/i
620
+ /** Allowlist for marker filenames from ls output — e.g. "mount-abc123" */
621
+ const SAFE_MARKER_NAME = /^mount-[a-z0-9]+$/;
622
+ /** Patterns indicating the sandbox is dead/gone (@daytonaio/sdk@0.143.0). */
623
+ const SANDBOX_DEAD_PATTERNS = [
624
+ /sandbox is not running/i,
625
+ /sandbox already destroyed/i,
626
+ /sandbox.*not found/i,
627
+ /failed to resolve container IP/i,
628
+ /is the sandbox started/i
737
629
  ];
738
- var DaytonaSandbox = class _DaytonaSandbox extends workspace.MastraSandbox {
739
- id;
740
- name = "DaytonaSandbox";
741
- provider = "daytona";
742
- // Non-optional (initialized by base class when mount() exists)
743
- /**
744
- * Networking capability: public HTTPS URLs for sandbox ports.
745
- * Daytona exposes ports through preview links (`getPreviewLink(port)`) —
746
- * if the port is closed it is opened automatically. Private sandboxes
747
- * require the preview token; pass `public: true` for tokenless URLs
748
- * (required for sandbox deploys).
749
- *
750
- * When not attached in this process, the sandbox is looked up by identity
751
- * (`daytona.get()` does not start it), so other processes can resolve
752
- * deployments without waking a stopped sandbox.
753
- */
754
- networking = {
755
- getPortUrl: async (port) => {
756
- try {
757
- const sandbox = this._sandbox ?? await this.lookupDetachedSandbox();
758
- if (!sandbox) return null;
759
- const preview = await sandbox.getPreviewLink(port);
760
- return preview?.url ?? null;
761
- } catch {
762
- return null;
763
- }
764
- }
765
- };
766
- status = "pending";
767
- _daytona = null;
768
- _sandbox = null;
769
- _createdAt = null;
770
- _workingDir = null;
771
- _isRetrying = false;
772
- timeout;
773
- language;
774
- resources;
775
- env;
776
- labels;
777
- snapshotId;
778
- image;
779
- ephemeral;
780
- autoStopInterval;
781
- autoArchiveInterval;
782
- autoDeleteInterval;
783
- volumeConfigs;
784
- sandboxName;
785
- _daytonaSandboxId;
786
- sandboxUser;
787
- sandboxPublic;
788
- networkBlockAll;
789
- networkAllowList;
790
- connectionOpts;
791
- _constructorOptions;
792
- constructor(options = {}) {
793
- super({
794
- ...options,
795
- name: "DaytonaSandbox",
796
- processes: new DaytonaProcessManager({
797
- env: options.env,
798
- defaultTimeout: options.timeout ?? 3e5
799
- })
800
- });
801
- this.id = options.id ?? this.generateId();
802
- this.timeout = options.timeout ?? 3e5;
803
- this.language = options.language ?? "typescript";
804
- this.resources = options.resources;
805
- this.env = options.env ?? {};
806
- this.labels = options.labels ?? {};
807
- this.snapshotId = options.snapshot;
808
- this.image = options.image;
809
- this.ephemeral = options.ephemeral ?? false;
810
- this.autoStopInterval = options.autoStopInterval ?? 15;
811
- this.autoArchiveInterval = options.autoArchiveInterval;
812
- this.autoDeleteInterval = options.autoDeleteInterval;
813
- this.volumeConfigs = options.volumes ?? [];
814
- this.sandboxName = options.name ?? this.id;
815
- this.sandboxUser = options.user;
816
- this.sandboxPublic = options.public;
817
- this.networkBlockAll = options.networkBlockAll;
818
- this.networkAllowList = options.networkAllowList;
819
- this.connectionOpts = {
820
- ...options.apiKey !== void 0 && { apiKey: options.apiKey },
821
- ...options.apiUrl !== void 0 && { apiUrl: options.apiUrl },
822
- ...options.target !== void 0 && { target: options.target }
823
- };
824
- this._constructorOptions = { ...options };
825
- }
826
- generateId() {
827
- return `daytona-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
828
- }
829
- /**
830
- * Construct a sibling `DaytonaSandbox` that inherits this sandbox's
831
- * configuration (credentials, snapshot/image, resources, network policy)
832
- * with per-instance overrides.
833
- *
834
- * Performs no I/O — the sandbox clone provisions (or reconnects to an
835
- * existing Daytona sandbox with the same logical `id`) on its own `start()`.
836
- * Use it when one configured sandbox acts as the template for a fleet of
837
- * independent sandboxes (e.g. one per project).
838
- *
839
- * `options.idleTimeoutMinutes` maps to Daytona's `autoStopInterval`
840
- * (minutes); `options.sandboxId` is ignored because Daytona reconnects by
841
- * logical `id`.
842
- */
843
- clone(options = {}) {
844
- const { id: _id, name: _name, ...base } = this._constructorOptions;
845
- return new _DaytonaSandbox({
846
- ...base,
847
- ...options.id !== void 0 && { id: options.id },
848
- ...options.env !== void 0 && { env: options.env },
849
- ...options.idleTimeoutMinutes !== void 0 && { autoStopInterval: options.idleTimeoutMinutes }
850
- });
851
- }
852
- /**
853
- * Get the underlying Daytona Sandbox instance for direct access to Daytona APIs.
854
- *
855
- * Use this when you need to access Daytona features not exposed through the
856
- * WorkspaceSandbox interface (e.g., filesystem API, git operations, LSP).
857
- *
858
- * @throws {SandboxNotReadyError} If the sandbox has not been started
859
- *
860
- * @example Direct file operations
861
- * ```typescript
862
- * await sandbox.start();
863
- * const daytonaSandbox = sandbox.daytona;
864
- * await daytonaSandbox.fs.uploadFile(Buffer.from('Hello'), '/tmp/test.txt');
865
- * ```
866
- */
867
- get daytona() {
868
- if (!this._sandbox) {
869
- throw new workspace.SandboxNotReadyError(this.id);
870
- }
871
- return this._sandbox;
872
- }
873
- /** @deprecated Use `daytona` instead. */
874
- get instance() {
875
- return this.daytona;
876
- }
877
- // ---------------------------------------------------------------------------
878
- // Lifecycle
879
- // ---------------------------------------------------------------------------
880
- /**
881
- * Start the Daytona sandbox.
882
- * Reconnects to an existing sandbox with the same logical ID if one exists,
883
- * otherwise creates a new sandbox instance.
884
- */
885
- async start() {
886
- if (this._sandbox) {
887
- return;
888
- }
889
- if (!this._daytona) {
890
- this._daytona = new sdk.Daytona(this.connectionOpts);
891
- }
892
- const existing = await this.findExistingSandbox();
893
- if (existing) {
894
- this._sandbox = existing;
895
- this._daytonaSandboxId = existing.id;
896
- this._createdAt = existing.createdAt ? new Date(existing.createdAt) : /* @__PURE__ */ new Date();
897
- this.logger.debug(`${LOG_PREFIX} Reconnected to existing sandbox ${existing.id} for: ${this.id}`);
898
- const expectedPaths = Array.from(this.mounts.entries.keys());
899
- this.logger.debug(`${LOG_PREFIX} Running mount reconciliation...`);
900
- await this.reconcileMounts(expectedPaths);
901
- this.logger.debug(`${LOG_PREFIX} Mount reconciliation complete`);
902
- await this.detectWorkingDir();
903
- return;
904
- }
905
- this.logger.debug(`${LOG_PREFIX} Creating sandbox for: ${this.id}`);
906
- const baseParams = compact({
907
- language: this.language,
908
- labels: { ...this.labels, "mastra-sandbox-id": this.id },
909
- ephemeral: this.ephemeral,
910
- autoStopInterval: this.autoStopInterval,
911
- autoArchiveInterval: this.autoArchiveInterval,
912
- autoDeleteInterval: this.autoDeleteInterval,
913
- volumes: this.volumeConfigs.length > 0 ? this.volumeConfigs : void 0,
914
- name: this.sandboxName,
915
- user: this.sandboxUser,
916
- public: this.sandboxPublic,
917
- networkBlockAll: this.networkBlockAll,
918
- networkAllowList: this.networkAllowList
919
- });
920
- if (this.resources && !this.image) {
921
- this.logger.warn(
922
- `${LOG_PREFIX} 'resources' option requires 'image' to take effect \u2014 falling back to snapshot-based creation without custom resources`
923
- );
924
- }
925
- const createParams = this.image && !this.snapshotId ? compact({
926
- ...baseParams,
927
- image: this.image,
928
- resources: this.resources
929
- }) : compact({ ...baseParams, snapshot: this.snapshotId });
930
- this._sandbox = await this._daytona.create(createParams);
931
- this._daytonaSandboxId = this._sandbox.id;
932
- this.logger.debug(`${LOG_PREFIX} Created sandbox ${this._sandbox.id} for logical ID: ${this.id}`);
933
- this._createdAt = /* @__PURE__ */ new Date();
934
- await this.detectWorkingDir();
935
- }
936
- /**
937
- * Stop the Daytona sandbox.
938
- * Unmounts all filesystems, then stops the sandbox.
939
- */
940
- async stop() {
941
- if (!this._sandbox) {
942
- try {
943
- const existing = await this.lookupDetachedSandbox();
944
- if (existing && existing.state === sdk.SandboxState.STARTED) {
945
- await this._daytona.stop(existing);
946
- }
947
- } catch {
948
- }
949
- return;
950
- }
951
- for (const mountPath of [...this.mounts.entries.keys()]) {
952
- try {
953
- await this.unmount(mountPath);
954
- } catch {
955
- }
956
- }
957
- if (this._daytona) {
958
- try {
959
- await this._daytona.stop(this._sandbox);
960
- } catch {
961
- }
962
- }
963
- this._sandbox = null;
964
- }
965
- /**
966
- * Destroy the Daytona sandbox and clean up all resources.
967
- * Deletes the sandbox and clears all state.
968
- */
969
- async destroy() {
970
- if (this._sandbox && this._daytona) {
971
- try {
972
- await this._daytona.delete(this._sandbox);
973
- } catch {
974
- }
975
- } else if (!this._sandbox) {
976
- try {
977
- const orphan = await this.lookupDetachedSandbox();
978
- if (orphan) {
979
- await this._daytona.delete(orphan);
980
- }
981
- } catch {
982
- }
983
- }
984
- this._sandbox = null;
985
- this._daytonaSandboxId = void 0;
986
- this._daytona = null;
987
- this.mounts?.clear();
988
- }
989
- /**
990
- * Check if the sandbox is ready for operations.
991
- */
992
- async isReady() {
993
- return this.status === "running" && this._sandbox !== null;
994
- }
995
- /**
996
- * Get information about the current state of the sandbox.
997
- */
998
- async getInfo() {
999
- return {
1000
- id: this.id,
1001
- name: this.name,
1002
- provider: this.provider,
1003
- status: this.status,
1004
- createdAt: this._createdAt ?? /* @__PURE__ */ new Date(),
1005
- mounts: this.mounts ? Array.from(this.mounts.entries).map(([path, entry]) => ({
1006
- path,
1007
- filesystem: entry.filesystem?.provider ?? entry.config?.type ?? "unknown"
1008
- })) : [],
1009
- ...this._sandbox && {
1010
- resources: {
1011
- cpuCores: this._sandbox.cpu,
1012
- memoryMB: this._sandbox.memory * 1024,
1013
- diskMB: this._sandbox.disk * 1024
1014
- }
1015
- },
1016
- metadata: {
1017
- language: this.language,
1018
- ephemeral: this.ephemeral,
1019
- ...this.snapshotId && { snapshot: this.snapshotId },
1020
- ...this.image && { image: this.image },
1021
- ...this._sandbox && { target: this._sandbox.target }
1022
- }
1023
- };
1024
- }
1025
- /**
1026
- * Get instructions describing this Daytona sandbox.
1027
- * Used by agents to understand the execution environment.
1028
- */
1029
- getInstructions() {
1030
- const parts = [];
1031
- const mountCount = this.mounts.entries.size;
1032
- const mountInfo = mountCount > 0 ? ` ${mountCount} filesystem(s) mounted via FUSE.` : "";
1033
- parts.push(`Cloud sandbox with isolated execution (${this.language} runtime).${mountInfo}`);
1034
- if (this._workingDir) {
1035
- parts.push(`Default working directory: ${this._workingDir}.`);
1036
- }
1037
- parts.push(`Command timeout: ${Math.ceil(this.timeout / 1e3)}s.`);
1038
- parts.push(`Running as user: ${this.sandboxUser ?? "daytona"}.`);
1039
- if (this.volumeConfigs.length > 0) {
1040
- parts.push(`${this.volumeConfigs.length} volume(s) attached.`);
1041
- }
1042
- if (this.networkBlockAll) {
1043
- parts.push(`Network access is blocked.`);
1044
- }
1045
- return parts.join(" ");
1046
- }
1047
- // ---------------------------------------------------------------------------
1048
- // Command Execution
1049
- // ---------------------------------------------------------------------------
1050
- /**
1051
- * Execute a command in the sandbox and return the result.
1052
- */
1053
- async executeCommand(command, args = [], options = {}) {
1054
- await this.ensureRunning();
1055
- const fullCommand = args.length > 0 ? `${command} ${args.map(shellQuote).join(" ")}` : command;
1056
- const handle = await this.processes.spawn(fullCommand, options);
1057
- const result = await handle.wait();
1058
- return { ...result, command, args };
1059
- }
1060
- /**
1061
- * Bulk-write files into the sandbox filesystem via the SDK's native upload.
1062
- */
1063
- async writeFiles(files) {
1064
- await this.ensureRunning();
1065
- await this.daytona.fs.uploadFiles(
1066
- files.map((file) => ({
1067
- source: Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content),
1068
- destination: file.path
1069
- }))
1070
- );
1071
- }
1072
- // ---------------------------------------------------------------------------
1073
- // Mount Support
1074
- // ---------------------------------------------------------------------------
1075
- /**
1076
- * Mount a filesystem at a path in the sandbox.
1077
- * Uses FUSE tools (s3fs, gcsfuse) to mount cloud storage.
1078
- */
1079
- async mount(filesystem, mountPath) {
1080
- validateMountPath(mountPath);
1081
- if (!this._sandbox) {
1082
- throw new workspace.SandboxNotReadyError(this.id);
1083
- }
1084
- const sandbox = this._sandbox;
1085
- this.logger.debug(`${LOG_PREFIX} Mounting "${mountPath}"...`);
1086
- const config = filesystem.getMountConfig?.();
1087
- if (!config) {
1088
- const error = `Filesystem "${filesystem.id}" does not provide a mount config`;
1089
- this.logger.error(`${LOG_PREFIX} ${error}`);
1090
- this.mounts.set(mountPath, { filesystem, state: "error", error });
1091
- return { success: false, mountPath, error };
1092
- }
1093
- const existingMount = await this.checkExistingMount(mountPath, config);
1094
- if (existingMount === "matching") {
1095
- this.logger.debug(
1096
- `${LOG_PREFIX} Detected existing mount for ${filesystem.provider} ("${filesystem.id}") at "${mountPath}" with correct config, skipping`
1097
- );
1098
- this.mounts.set(mountPath, { state: "mounted", config });
1099
- return { success: true, mountPath };
1100
- } else if (existingMount === "mismatched") {
1101
- this.logger.debug(`${LOG_PREFIX} Config mismatch at "${mountPath}", unmounting to re-mount with new config...`);
1102
- await this.unmount(mountPath);
1103
- } else if (existingMount === "unmanaged") {
1104
- const error = `Mount path "${mountPath}" is already mounted by an unmanaged source`;
1105
- this.logger.error(`${LOG_PREFIX} ${error}`);
1106
- this.mounts.set(mountPath, { filesystem, state: "error", config, error });
1107
- return { success: false, mountPath, error };
1108
- }
1109
- this.mounts.set(mountPath, { filesystem, state: "mounting", config });
1110
- this.logger.debug(`${LOG_PREFIX} Config type: ${config.type}`);
1111
- try {
1112
- const quotedPath = shellQuote(mountPath);
1113
- const checkResult = await runCommand(
1114
- sandbox,
1115
- `[ -d ${quotedPath} ] && ! mountpoint -q ${quotedPath} 2>/dev/null && [ "$(ls -A ${quotedPath} 2>/dev/null)" ] && echo "non-empty" || echo "ok"`,
1116
- { timeout: MOUNT_COMMAND_TIMEOUT_MS }
1117
- );
1118
- if (checkResult.output.trim() === "non-empty") {
1119
- 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.`;
1120
- this.logger.error(`${LOG_PREFIX} ${error}`);
1121
- this.mounts.set(mountPath, { filesystem, state: "error", config, error });
1122
- return { success: false, mountPath, error };
1123
- }
1124
- } catch {
1125
- }
1126
- this.logger.debug(`${LOG_PREFIX} Creating mount directory for "${mountPath}"...`);
1127
- try {
1128
- const quotedPath = shellQuote(mountPath);
1129
- const mkdirResult = await runCommand(
1130
- sandbox,
1131
- `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}`,
1132
- { timeout: MOUNT_COMMAND_TIMEOUT_MS }
1133
- );
1134
- if (mkdirResult.exitCode !== 0) {
1135
- const error = mkdirResult.output || "Failed to create mount directory";
1136
- this.logger.debug(`${LOG_PREFIX} mkdir error for "${mountPath}":`, error);
1137
- this.mounts.set(mountPath, { filesystem, state: "error", config, error });
1138
- return { success: false, mountPath, error };
1139
- }
1140
- } catch (err) {
1141
- const error = `Failed to create mount directory: ${err}`;
1142
- this.mounts.set(mountPath, { filesystem, state: "error", config, error });
1143
- return { success: false, mountPath, error };
1144
- }
1145
- const mountCtx = {
1146
- run: async (cmd, timeoutMs) => {
1147
- const result = await runCommand(sandbox, cmd, timeoutMs !== void 0 ? { timeout: timeoutMs } : void 0);
1148
- return {
1149
- exitCode: result.exitCode,
1150
- stdout: result.output,
1151
- stderr: result.exitCode !== 0 ? result.output : ""
1152
- };
1153
- },
1154
- writeFile: async (path, content) => {
1155
- await sandbox.fs.uploadFile(Buffer.from(content), path);
1156
- },
1157
- logger: this.logger
1158
- };
1159
- try {
1160
- switch (config.type) {
1161
- case "s3":
1162
- this.logger.debug(`${LOG_PREFIX} Mounting S3 at "${mountPath}"...`);
1163
- await mountS3(mountPath, config, mountCtx);
1164
- this.logger.debug(`${LOG_PREFIX} Mounted S3 bucket at ${mountPath}`);
1165
- break;
1166
- case "gcs":
1167
- this.logger.debug(`${LOG_PREFIX} Mounting GCS at "${mountPath}"...`);
1168
- await mountGCS(mountPath, config, mountCtx);
1169
- this.logger.debug(`${LOG_PREFIX} Mounted GCS bucket at ${mountPath}`);
1170
- break;
1171
- case "azure-blob":
1172
- this.logger.debug(`${LOG_PREFIX} Mounting Azure Blob at "${mountPath}"...`);
1173
- await mountAzure(mountPath, config, mountCtx);
1174
- this.logger.debug(`${LOG_PREFIX} Mounted Azure Blob container at ${mountPath}`);
1175
- break;
1176
- default: {
1177
- const error = `Unsupported mount type: ${config.type}`;
1178
- this.mounts.set(mountPath, { filesystem, state: "unsupported", config, error });
1179
- return { success: false, mountPath, error };
1180
- }
1181
- }
1182
- } catch (error) {
1183
- this.logger.error(
1184
- `${LOG_PREFIX} Error mounting "${filesystem.provider}" (${filesystem.id}) at "${mountPath}":`,
1185
- error
1186
- );
1187
- this.mounts.set(mountPath, { filesystem, state: "error", config, error: errorToString(error) });
1188
- await runCommand(sandbox, `sudo rmdir ${shellQuote(mountPath)} 2>/dev/null || true`, {
1189
- timeout: MOUNT_COMMAND_TIMEOUT_MS
1190
- });
1191
- this.logger.debug(`${LOG_PREFIX} Cleaned up directory after failed mount: ${mountPath}`);
1192
- return { success: false, mountPath, error: errorToString(error) };
1193
- }
1194
- this.mounts.set(mountPath, { state: "mounted", config });
1195
- await this.writeMarkerFile(mountPath);
1196
- this.logger.debug(`${LOG_PREFIX} Mounted "${mountPath}"`);
1197
- return { success: true, mountPath };
1198
- }
1199
- /**
1200
- * Unmount a filesystem from a path in the sandbox.
1201
- */
1202
- async unmount(mountPath) {
1203
- validateMountPath(mountPath);
1204
- if (!this._sandbox) {
1205
- throw new workspace.SandboxNotReadyError(this.id);
1206
- }
1207
- const sandbox = this._sandbox;
1208
- this.logger.debug(`${LOG_PREFIX} Unmounting "${mountPath}"...`);
1209
- const quotedPath = shellQuote(mountPath);
1210
- await runCommand(
1211
- sandbox,
1212
- `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; }`,
1213
- { timeout: MOUNT_COMMAND_TIMEOUT_MS }
1214
- );
1215
- this.mounts.delete(mountPath);
1216
- const markerPath = `/tmp/.mastra-mounts/${this.mounts.markerFilename(mountPath)}`;
1217
- const rmdirResult = await runCommand(
1218
- sandbox,
1219
- `rm -f ${shellQuote(markerPath)} 2>/dev/null; sudo rmdir ${quotedPath} 2>&1`,
1220
- {
1221
- timeout: MOUNT_COMMAND_TIMEOUT_MS
1222
- }
1223
- );
1224
- if (rmdirResult.exitCode === 0) {
1225
- this.logger.debug(`${LOG_PREFIX} Unmounted and removed ${mountPath}`);
1226
- } else {
1227
- this.logger.debug(
1228
- `${LOG_PREFIX} Unmounted ${mountPath} (directory not removed: ${rmdirResult.output.trim() || "not empty"})`
1229
- );
1230
- }
1231
- }
1232
- /**
1233
- * Unmount all stale mounts that are not in the expected mounts list.
1234
- * Also cleans up orphaned directories and marker files from failed mount attempts.
1235
- * Call this after reconnecting to an existing sandbox to clean up old mounts.
1236
- */
1237
- async reconcileMounts(expectedMountPaths) {
1238
- if (!this._sandbox) return;
1239
- const sandbox = this._sandbox;
1240
- this.logger.debug(`${LOG_PREFIX} Reconciling mounts. Expected paths:`, expectedMountPaths);
1241
- let currentMounts = [];
1242
- try {
1243
- const mountsResult = await runCommand(
1244
- sandbox,
1245
- `grep -E 'fuse\\.(s3fs|gcsfuse|blobfuse2)' /proc/mounts | awk '{print $2}'`,
1246
- { timeout: MOUNT_COMMAND_TIMEOUT_MS }
1247
- );
1248
- currentMounts = mountsResult.output.trim().split("\n").filter((p) => p.length > 0);
1249
- } catch (err) {
1250
- this.logger.debug(`${LOG_PREFIX} Could not read /proc/mounts: ${err}`);
1251
- return;
1252
- }
1253
- this.logger.debug(`${LOG_PREFIX} Current FUSE mounts in sandbox:`, currentMounts);
1254
- let markerFiles = [];
1255
- try {
1256
- const markersResult = await runCommand(sandbox, 'ls /tmp/.mastra-mounts/ 2>/dev/null || echo ""', {
1257
- timeout: MOUNT_COMMAND_TIMEOUT_MS
1258
- });
1259
- markerFiles = markersResult.output.trim().split("\n").filter((f) => f.length > 0 && SAFE_MARKER_NAME.test(f));
1260
- } catch (err) {
1261
- this.logger.debug(`${LOG_PREFIX} Could not read marker files: ${err}`);
1262
- }
1263
- const managedMountPaths = /* @__PURE__ */ new Map();
1264
- for (const markerFile of markerFiles) {
1265
- const markerResult = await runCommand(sandbox, `cat "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || echo ""`, {
1266
- timeout: MOUNT_COMMAND_TIMEOUT_MS
1267
- });
1268
- const parsed = this.mounts.parseMarkerContent(markerResult.output.trim());
1269
- if (parsed && SAFE_MOUNT_PATH.test(parsed.path)) {
1270
- managedMountPaths.set(parsed.path, markerFile);
1271
- }
1272
- }
1273
- const staleMounts = currentMounts.filter((path) => !expectedMountPaths.includes(path));
1274
- for (const stalePath of staleMounts) {
1275
- if (managedMountPaths.has(stalePath)) {
1276
- this.logger.debug(`${LOG_PREFIX} Found stale managed FUSE mount at "${stalePath}", unmounting...`);
1277
- try {
1278
- await this.unmount(stalePath);
1279
- } catch (err) {
1280
- this.logger.debug(`${LOG_PREFIX} Failed to unmount stale mount at "${stalePath}": ${err}`);
1281
- }
1282
- } else {
1283
- this.logger.debug(`${LOG_PREFIX} Found external FUSE mount at "${stalePath}", leaving untouched`);
1284
- }
1285
- }
1286
- try {
1287
- const expectedMarkerFiles = new Set(expectedMountPaths.map((p) => this.mounts.markerFilename(p)));
1288
- const markerToPath = /* @__PURE__ */ new Map();
1289
- for (const [path, file] of managedMountPaths) {
1290
- markerToPath.set(file, path);
1291
- }
1292
- for (const markerFile of markerFiles) {
1293
- if (!expectedMarkerFiles.has(markerFile)) {
1294
- const mountPath = markerToPath.get(markerFile);
1295
- if (mountPath) {
1296
- if (!currentMounts.includes(mountPath)) {
1297
- this.logger.debug(`${LOG_PREFIX} Cleaning up orphaned marker and directory for ${mountPath}`);
1298
- await runCommand(
1299
- sandbox,
1300
- `rm -f "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null; sudo rmdir ${shellQuote(mountPath)} 2>/dev/null`,
1301
- { timeout: MOUNT_COMMAND_TIMEOUT_MS }
1302
- );
1303
- }
1304
- } else {
1305
- this.logger.debug(`${LOG_PREFIX} Removing malformed marker file: ${markerFile}`);
1306
- await runCommand(sandbox, `rm -f "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || true`, {
1307
- timeout: MOUNT_COMMAND_TIMEOUT_MS
1308
- });
1309
- }
1310
- }
1311
- }
1312
- } catch {
1313
- this.logger.debug(`${LOG_PREFIX} Error during orphan cleanup (non-fatal)`);
1314
- }
1315
- }
1316
- /**
1317
- * Write marker file for detecting config changes on reconnect.
1318
- * Stores both the mount path and config hash in the file.
1319
- */
1320
- async writeMarkerFile(mountPath) {
1321
- if (!this._sandbox) return;
1322
- const markerContent = this.mounts.getMarkerContent(mountPath);
1323
- if (!markerContent) return;
1324
- const filename = this.mounts.markerFilename(mountPath);
1325
- const markerPath = `/tmp/.mastra-mounts/${filename}`;
1326
- try {
1327
- await runCommand(this._sandbox, "mkdir -p /tmp/.mastra-mounts", { timeout: MOUNT_COMMAND_TIMEOUT_MS });
1328
- await this._sandbox.fs.uploadFile(Buffer.from(markerContent, "utf-8"), markerPath);
1329
- } catch {
1330
- this.logger.debug(`${LOG_PREFIX} Warning: Could not write marker file at ${markerPath}`);
1331
- }
1332
- }
1333
- /**
1334
- * Check if a path is already mounted and whether the config matches.
1335
- *
1336
- * @param mountPath - The mount path to check
1337
- * @param newConfig - The new config to compare against the stored config
1338
- * @returns 'not_mounted' | 'matching' | 'mismatched' | 'unmanaged'
1339
- */
1340
- async checkExistingMount(mountPath, newConfig) {
1341
- if (!this._sandbox) throw new workspace.SandboxNotReadyError(this.id);
1342
- const sandbox = this._sandbox;
1343
- try {
1344
- const mountCheck = await runCommand(
1345
- sandbox,
1346
- `mountpoint -q ${shellQuote(mountPath)} && echo "mounted" || echo "not mounted"`,
1347
- { timeout: MOUNT_COMMAND_TIMEOUT_MS }
1348
- );
1349
- if (mountCheck.output.trim() !== "mounted") {
1350
- return "not_mounted";
1351
- }
1352
- } catch {
1353
- return "not_mounted";
1354
- }
1355
- const filename = this.mounts.markerFilename(mountPath);
1356
- const markerPath = `/tmp/.mastra-mounts/${filename}`;
1357
- let parsed;
1358
- try {
1359
- const markerResult = await runCommand(sandbox, `cat ${shellQuote(markerPath)} 2>/dev/null || echo ""`, {
1360
- timeout: MOUNT_COMMAND_TIMEOUT_MS
1361
- });
1362
- parsed = this.mounts.parseMarkerContent(markerResult.output.trim());
1363
- } catch {
1364
- return "unmanaged";
1365
- }
1366
- if (!parsed) return "unmanaged";
1367
- const newConfigHash = this.mounts.computeConfigHash(newConfig);
1368
- this.logger.debug(
1369
- `${LOG_PREFIX} Marker check - stored hash: "${parsed.configHash}", new config hash: "${newConfigHash}"`
1370
- );
1371
- if (parsed.path === mountPath && parsed.configHash === newConfigHash) {
1372
- return "matching";
1373
- }
1374
- return "mismatched";
1375
- }
1376
- // ---------------------------------------------------------------------------
1377
- // Internal Helpers
1378
- // ---------------------------------------------------------------------------
1379
- /**
1380
- * Try to find and reconnect to an existing Daytona sandbox by ID.
1381
- * Returns the sandbox if found and usable, or null if a fresh one should
1382
- * be created.
1383
- */
1384
- async detectWorkingDir() {
1385
- if (!this._sandbox) return;
1386
- try {
1387
- const result = await runCommand(this._sandbox, "pwd", { timeout: MOUNT_COMMAND_TIMEOUT_MS });
1388
- const dir = result.output?.trim();
1389
- if (dir) {
1390
- this._workingDir = dir;
1391
- this.logger.debug(`${LOG_PREFIX} Detected working directory: ${dir}`);
1392
- }
1393
- } catch {
1394
- this.logger.debug(`${LOG_PREFIX} Could not detect working directory, will omit from instructions`);
1395
- }
1396
- }
1397
- /**
1398
- * Look up the existing Daytona sandbox by identity WITHOUT starting it.
1399
- * Used for detached (cross-process) networking/stop/destroy so those
1400
- * operations never wake a stopped sandbox. Returns null when not found.
1401
- */
1402
- async lookupDetachedSandbox() {
1403
- const lookupKey = this._daytonaSandboxId ?? this.sandboxName;
1404
- if (!lookupKey) {
1405
- return null;
1406
- }
1407
- if (!this._daytona) {
1408
- this._daytona = new sdk.Daytona(this.connectionOpts);
1409
- }
1410
- try {
1411
- return await this._daytona.get(lookupKey);
1412
- } catch (error) {
1413
- if (error instanceof sdk.DaytonaNotFoundError) {
1414
- return null;
1415
- }
1416
- throw error;
1417
- }
1418
- }
1419
- async findExistingSandbox() {
1420
- const DEAD_STATES = [
1421
- sdk.SandboxState.DESTROYED,
1422
- sdk.SandboxState.DESTROYING,
1423
- sdk.SandboxState.ERROR,
1424
- sdk.SandboxState.BUILD_FAILED
1425
- ];
1426
- const lookupKey = this._daytonaSandboxId ?? this.sandboxName;
1427
- if (!lookupKey) {
1428
- return null;
1429
- }
1430
- let sandbox;
1431
- try {
1432
- sandbox = await this._daytona.get(lookupKey);
1433
- } catch (error) {
1434
- if (error instanceof sdk.DaytonaNotFoundError) {
1435
- this._daytonaSandboxId = void 0;
1436
- return null;
1437
- }
1438
- throw error;
1439
- }
1440
- const state = sandbox.state;
1441
- if (state && DEAD_STATES.includes(state)) {
1442
- this.logger.debug(`${LOG_PREFIX} Existing sandbox ${sandbox.id} is dead (${state}), deleting and creating fresh`);
1443
- try {
1444
- await this._daytona.delete(sandbox);
1445
- } catch {
1446
- }
1447
- return null;
1448
- }
1449
- if (state !== sdk.SandboxState.STARTED) {
1450
- this.logger.debug(`${LOG_PREFIX} Restarting sandbox ${sandbox.id} (state: ${state})`);
1451
- await this.waitForStableStateAndStart(sandbox);
1452
- }
1453
- return sandbox;
1454
- }
1455
- /**
1456
- * Transitional states where the Daytona API will reject start() with
1457
- * "State change in progress". We poll until the sandbox reaches a stable
1458
- * state before attempting start().
1459
- */
1460
- static TRANSITIONAL_STATES = [
1461
- sdk.SandboxState.STARTING,
1462
- sdk.SandboxState.STOPPING,
1463
- sdk.SandboxState.CREATING,
1464
- sdk.SandboxState.RESTORING,
1465
- sdk.SandboxState.ARCHIVING,
1466
- sdk.SandboxState.RESIZING,
1467
- sdk.SandboxState.PULLING_SNAPSHOT,
1468
- sdk.SandboxState.BUILDING_SNAPSHOT
1469
- ];
1470
- /**
1471
- * Wait for the sandbox to leave a transitional state, then start it if needed.
1472
- * Polls every 2s for up to 120s. If the sandbox reaches STARTED on its own
1473
- * (e.g. it was STARTING), we skip the start() call. If start() still fails
1474
- * with "State change in progress", we retry with backoff.
1475
- */
1476
- async waitForStableStateAndStart(sandbox) {
1477
- const MAX_WAIT_MS = 12e4;
1478
- const POLL_INTERVAL_MS = 2e3;
1479
- const deadline = Date.now() + MAX_WAIT_MS;
1480
- let current = sandbox;
1481
- while (current.state && _DaytonaSandbox.TRANSITIONAL_STATES.includes(current.state) && Date.now() < deadline) {
1482
- this.logger.debug(`${LOG_PREFIX} Sandbox ${current.id} is in transitional state (${current.state}), waiting...`);
1483
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1484
- current = await this._daytona.get(current.id);
1485
- }
1486
- if (current.state === sdk.SandboxState.STARTED) {
1487
- Object.assign(sandbox, current);
1488
- return;
1489
- }
1490
- while (Date.now() < deadline) {
1491
- try {
1492
- await this._daytona.start(current);
1493
- return;
1494
- } catch (error) {
1495
- const msg = error instanceof Error ? error.message : String(error);
1496
- if (msg.includes("State change in progress") && Date.now() < deadline) {
1497
- this.logger.debug(`${LOG_PREFIX} start() returned "State change in progress", retrying...`);
1498
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1499
- current = await this._daytona.get(current.id);
1500
- if (current.state === sdk.SandboxState.STARTED) {
1501
- Object.assign(sandbox, current);
1502
- return;
1503
- }
1504
- continue;
1505
- }
1506
- throw error;
1507
- }
1508
- }
1509
- await this._daytona.start(current);
1510
- }
1511
- /**
1512
- * Check if an error indicates the sandbox is dead/gone.
1513
- * Uses DaytonaNotFoundError from the SDK when available,
1514
- * with string fallback for edge cases.
1515
- *
1516
- * String patterns observed in @daytonaio/sdk@0.143.0 error messages.
1517
- * Update if SDK error messages change in future versions.
1518
- */
1519
- isSandboxDeadError(error) {
1520
- if (!error) return false;
1521
- if (error instanceof sdk.DaytonaNotFoundError) return true;
1522
- const errorStr = String(error);
1523
- return SANDBOX_DEAD_PATTERNS.some((pattern) => pattern.test(errorStr));
1524
- }
1525
- /**
1526
- * Handle sandbox timeout by clearing the instance and resetting state.
1527
- */
1528
- handleSandboxTimeout() {
1529
- this._sandbox = null;
1530
- if (this.mounts) {
1531
- for (const [path, entry] of this.mounts.entries) {
1532
- if (entry.state === "mounted" || entry.state === "mounting") {
1533
- this.mounts.set(path, { state: "pending" });
1534
- }
1535
- }
1536
- }
1537
- this.status = "stopped";
1538
- }
1539
- // ---------------------------------------------------------------------------
1540
- // Retry on Dead
1541
- // ---------------------------------------------------------------------------
1542
- /**
1543
- * Execute a function, retrying once if the sandbox is found to be dead.
1544
- * Used by DaytonaProcessManager to handle stale sandboxes transparently.
1545
- */
1546
- async retryOnDead(fn) {
1547
- try {
1548
- return await fn();
1549
- } catch (error) {
1550
- if (this.isSandboxDeadError(error) && !this._isRetrying) {
1551
- this.handleSandboxTimeout();
1552
- this._isRetrying = true;
1553
- try {
1554
- await this.ensureRunning();
1555
- return await fn();
1556
- } finally {
1557
- this._isRetrying = false;
1558
- }
1559
- }
1560
- throw error;
1561
- }
1562
- }
630
+ /**
631
+ * Daytona sandbox provider for Mastra workspaces.
632
+ *
633
+ * Features:
634
+ * - Isolated cloud sandbox via Daytona SDK
635
+ * - Multi-runtime support (TypeScript, JavaScript, Python)
636
+ * - Resource configuration (CPU, memory, disk)
637
+ * - Volume attachment at creation time
638
+ * - FUSE-based cloud filesystem mounting (S3, GCS)
639
+ * - Automatic sandbox timeout handling with retry
640
+ *
641
+ * @example Basic usage
642
+ * ```typescript
643
+ * import { Workspace } from '@mastra/core/workspace';
644
+ * import { DaytonaSandbox } from '@mastra/daytona';
645
+ *
646
+ * const sandbox = new DaytonaSandbox({
647
+ * timeout: 60000,
648
+ * language: 'typescript',
649
+ * });
650
+ *
651
+ * const workspace = new Workspace({ sandbox });
652
+ * const result = await workspace.executeCode('console.log("Hello!")');
653
+ * ```
654
+ *
655
+ * @example With resources and volumes
656
+ * ```typescript
657
+ * const sandbox = new DaytonaSandbox({
658
+ * resources: { cpu: 2, memory: 4, disk: 6 },
659
+ * volumes: [{ volumeId: 'vol-123', mountPath: '/data' }],
660
+ * env: { NODE_ENV: 'production' },
661
+ * });
662
+ * ```
663
+ */
664
+ var DaytonaSandbox = class DaytonaSandbox extends _mastra_core_workspace.MastraSandbox {
665
+ id;
666
+ name = "DaytonaSandbox";
667
+ provider = "daytona";
668
+ /**
669
+ * Networking capability: public HTTPS URLs for sandbox ports.
670
+ * Daytona exposes ports through preview links (`getPreviewLink(port)`) —
671
+ * if the port is closed it is opened automatically. Private sandboxes
672
+ * require the preview token; pass `public: true` for tokenless URLs
673
+ * (required for sandbox deploys).
674
+ *
675
+ * When not attached in this process, the sandbox is looked up by identity
676
+ * (`daytona.get()` does not start it), so other processes can resolve
677
+ * deployments without waking a stopped sandbox.
678
+ */
679
+ networking = { getPortUrl: async (port) => {
680
+ try {
681
+ const sandbox = this._sandbox ?? await this.lookupDetachedSandbox();
682
+ if (!sandbox) return null;
683
+ return (await sandbox.getPreviewLink(port))?.url ?? null;
684
+ } catch {
685
+ return null;
686
+ }
687
+ } };
688
+ status = "pending";
689
+ _daytona = null;
690
+ _sandbox = null;
691
+ _createdAt = null;
692
+ _workingDir = null;
693
+ _isRetrying = false;
694
+ timeout;
695
+ language;
696
+ resources;
697
+ env;
698
+ labels;
699
+ snapshotId;
700
+ image;
701
+ ephemeral;
702
+ autoStopInterval;
703
+ autoArchiveInterval;
704
+ autoDeleteInterval;
705
+ volumeConfigs;
706
+ sandboxName;
707
+ _daytonaSandboxId;
708
+ sandboxUser;
709
+ sandboxPublic;
710
+ networkBlockAll;
711
+ networkAllowList;
712
+ domainAllowList;
713
+ connectionOpts;
714
+ _constructorOptions;
715
+ constructor(options = {}) {
716
+ super({
717
+ ...options,
718
+ name: "DaytonaSandbox",
719
+ processes: new DaytonaProcessManager({
720
+ env: options.env,
721
+ defaultTimeout: options.timeout ?? 3e5
722
+ })
723
+ });
724
+ this.id = options.id ?? this.generateId();
725
+ this.timeout = options.timeout ?? 3e5;
726
+ this.language = options.language ?? "typescript";
727
+ this.resources = options.resources;
728
+ this.env = options.env ?? {};
729
+ this.labels = options.labels ?? {};
730
+ this.snapshotId = options.snapshot;
731
+ this.image = options.image;
732
+ this.ephemeral = options.ephemeral ?? false;
733
+ this.autoStopInterval = options.autoStopInterval ?? 15;
734
+ this.autoArchiveInterval = options.autoArchiveInterval;
735
+ this.autoDeleteInterval = options.autoDeleteInterval;
736
+ this.volumeConfigs = options.volumes ?? [];
737
+ this.sandboxName = options.name ?? this.id;
738
+ this.sandboxUser = options.user;
739
+ this.sandboxPublic = options.public;
740
+ this.networkBlockAll = options.networkBlockAll;
741
+ this.networkAllowList = options.networkAllowList;
742
+ this.domainAllowList = options.domainAllowList;
743
+ this.connectionOpts = {
744
+ ...options.apiKey !== void 0 && { apiKey: options.apiKey },
745
+ ...options.apiUrl !== void 0 && { apiUrl: options.apiUrl },
746
+ ...options.target !== void 0 && { target: options.target }
747
+ };
748
+ this._constructorOptions = { ...options };
749
+ }
750
+ generateId() {
751
+ return `daytona-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
752
+ }
753
+ /**
754
+ * Construct a sibling `DaytonaSandbox` that inherits this sandbox's
755
+ * configuration (credentials, snapshot/image, resources, network policy)
756
+ * with per-instance overrides.
757
+ *
758
+ * Performs no I/O — the sandbox clone provisions (or reconnects to an
759
+ * existing Daytona sandbox with the same logical `id`) on its own `start()`.
760
+ * Use it when one configured sandbox acts as the template for a fleet of
761
+ * independent sandboxes (e.g. one per project).
762
+ *
763
+ * `options.idleTimeoutMinutes` maps to Daytona's `autoStopInterval`
764
+ * (minutes); `options.sandboxId` is ignored because Daytona reconnects by
765
+ * logical `id`.
766
+ */
767
+ clone(options = {}) {
768
+ const { id: _id, name: _name, ...base } = this._constructorOptions;
769
+ return new DaytonaSandbox({
770
+ ...base,
771
+ ...options.id !== void 0 && { id: options.id },
772
+ ...options.env !== void 0 && { env: options.env },
773
+ ...options.idleTimeoutMinutes !== void 0 && { autoStopInterval: options.idleTimeoutMinutes }
774
+ });
775
+ }
776
+ /**
777
+ * Get the underlying Daytona Sandbox instance for direct access to Daytona APIs.
778
+ *
779
+ * Use this when you need to access Daytona features not exposed through the
780
+ * WorkspaceSandbox interface (e.g., filesystem API, git operations, LSP).
781
+ *
782
+ * @throws {SandboxNotReadyError} If the sandbox has not been started
783
+ *
784
+ * @example Direct file operations
785
+ * ```typescript
786
+ * await sandbox.start();
787
+ * const daytonaSandbox = sandbox.daytona;
788
+ * await daytonaSandbox.fs.uploadFile(Buffer.from('Hello'), '/tmp/test.txt');
789
+ * ```
790
+ */
791
+ get daytona() {
792
+ if (!this._sandbox) throw new _mastra_core_workspace.SandboxNotReadyError(this.id);
793
+ return this._sandbox;
794
+ }
795
+ /** @deprecated Use `daytona` instead. */
796
+ get instance() {
797
+ return this.daytona;
798
+ }
799
+ /**
800
+ * Start the Daytona sandbox.
801
+ * Reconnects to an existing sandbox with the same logical ID if one exists,
802
+ * otherwise creates a new sandbox instance.
803
+ */
804
+ async start() {
805
+ if (this._sandbox) return;
806
+ if (!this._daytona) this._daytona = new _daytonaio_sdk.Daytona(this.connectionOpts);
807
+ const existing = await this.findExistingSandbox();
808
+ if (existing) {
809
+ this._sandbox = existing;
810
+ this._daytonaSandboxId = existing.id;
811
+ this._createdAt = existing.createdAt ? new Date(existing.createdAt) : /* @__PURE__ */ new Date();
812
+ this.logger.debug(`${LOG_PREFIX} Reconnected to existing sandbox ${existing.id} for: ${this.id}`);
813
+ const expectedPaths = Array.from(this.mounts.entries.keys());
814
+ this.logger.debug(`${LOG_PREFIX} Running mount reconciliation...`);
815
+ await this.reconcileMounts(expectedPaths);
816
+ this.logger.debug(`${LOG_PREFIX} Mount reconciliation complete`);
817
+ await this.detectWorkingDir();
818
+ return;
819
+ }
820
+ this.logger.debug(`${LOG_PREFIX} Creating sandbox for: ${this.id}`);
821
+ const baseParams = compact({
822
+ language: this.language,
823
+ labels: {
824
+ ...this.labels,
825
+ "mastra-sandbox-id": this.id
826
+ },
827
+ ephemeral: this.ephemeral,
828
+ autoStopInterval: this.autoStopInterval,
829
+ autoArchiveInterval: this.autoArchiveInterval,
830
+ autoDeleteInterval: this.autoDeleteInterval,
831
+ volumes: this.volumeConfigs.length > 0 ? this.volumeConfigs : void 0,
832
+ name: this.sandboxName,
833
+ user: this.sandboxUser,
834
+ public: this.sandboxPublic,
835
+ networkBlockAll: this.networkBlockAll,
836
+ networkAllowList: this.networkAllowList,
837
+ domainAllowList: this.domainAllowList
838
+ });
839
+ if (this.resources && !this.image) this.logger.warn(`${LOG_PREFIX} 'resources' option requires 'image' to take effect — falling back to snapshot-based creation without custom resources`);
840
+ const createParams = this.image && !this.snapshotId ? compact({
841
+ ...baseParams,
842
+ image: this.image,
843
+ resources: this.resources
844
+ }) : compact({
845
+ ...baseParams,
846
+ snapshot: this.snapshotId
847
+ });
848
+ this._sandbox = await this._daytona.create(createParams);
849
+ this._daytonaSandboxId = this._sandbox.id;
850
+ this.logger.debug(`${LOG_PREFIX} Created sandbox ${this._sandbox.id} for logical ID: ${this.id}`);
851
+ this._createdAt = /* @__PURE__ */ new Date();
852
+ await this.detectWorkingDir();
853
+ }
854
+ /**
855
+ * Stop the Daytona sandbox.
856
+ * Unmounts all filesystems, then stops the sandbox.
857
+ */
858
+ async stop() {
859
+ if (!this._sandbox) {
860
+ try {
861
+ const existing = await this.lookupDetachedSandbox();
862
+ if (existing && existing.state === _daytonaio_sdk.SandboxState.STARTED) await this._daytona.stop(existing);
863
+ } catch {}
864
+ return;
865
+ }
866
+ for (const mountPath of [...this.mounts.entries.keys()]) try {
867
+ await this.unmount(mountPath);
868
+ } catch {}
869
+ if (this._daytona) try {
870
+ await this._daytona.stop(this._sandbox);
871
+ } catch {}
872
+ this._sandbox = null;
873
+ }
874
+ /**
875
+ * Destroy the Daytona sandbox and clean up all resources.
876
+ * Deletes the sandbox and clears all state.
877
+ */
878
+ async destroy() {
879
+ if (this._sandbox && this._daytona) try {
880
+ await this._daytona.delete(this._sandbox);
881
+ } catch {}
882
+ else if (!this._sandbox) try {
883
+ const orphan = await this.lookupDetachedSandbox();
884
+ if (orphan) await this._daytona.delete(orphan);
885
+ } catch {}
886
+ this._sandbox = null;
887
+ this._daytonaSandboxId = void 0;
888
+ this._daytona = null;
889
+ this.mounts?.clear();
890
+ }
891
+ /**
892
+ * Check if the sandbox is ready for operations.
893
+ */
894
+ async isReady() {
895
+ return this.status === "running" && this._sandbox !== null;
896
+ }
897
+ /**
898
+ * Get information about the current state of the sandbox.
899
+ */
900
+ async getInfo() {
901
+ return {
902
+ id: this.id,
903
+ name: this.name,
904
+ provider: this.provider,
905
+ status: this.status,
906
+ createdAt: this._createdAt ?? /* @__PURE__ */ new Date(),
907
+ mounts: this.mounts ? Array.from(this.mounts.entries).map(([path, entry]) => ({
908
+ path,
909
+ filesystem: entry.filesystem?.provider ?? entry.config?.type ?? "unknown"
910
+ })) : [],
911
+ ...this._sandbox && { resources: {
912
+ cpuCores: this._sandbox.cpu,
913
+ memoryMB: this._sandbox.memory * 1024,
914
+ diskMB: this._sandbox.disk * 1024
915
+ } },
916
+ metadata: {
917
+ language: this.language,
918
+ ephemeral: this.ephemeral,
919
+ ...this.snapshotId && { snapshot: this.snapshotId },
920
+ ...this.image && { image: this.image },
921
+ ...this._sandbox && { target: this._sandbox.target }
922
+ }
923
+ };
924
+ }
925
+ /**
926
+ * Get instructions describing this Daytona sandbox.
927
+ * Used by agents to understand the execution environment.
928
+ */
929
+ getInstructions() {
930
+ const parts = [];
931
+ const mountCount = this.mounts.entries.size;
932
+ const mountInfo = mountCount > 0 ? ` ${mountCount} filesystem(s) mounted via FUSE.` : "";
933
+ parts.push(`Cloud sandbox with isolated execution (${this.language} runtime).${mountInfo}`);
934
+ if (this._workingDir) parts.push(`Default working directory: ${this._workingDir}.`);
935
+ parts.push(`Command timeout: ${Math.ceil(this.timeout / 1e3)}s.`);
936
+ parts.push(`Running as user: ${this.sandboxUser ?? "daytona"}.`);
937
+ if (this.volumeConfigs.length > 0) parts.push(`${this.volumeConfigs.length} volume(s) attached.`);
938
+ if (this.networkBlockAll) parts.push(`Network access is blocked.`);
939
+ return parts.join(" ");
940
+ }
941
+ /**
942
+ * Execute a command in the sandbox and return the result.
943
+ */
944
+ async executeCommand(command, args = [], options = {}) {
945
+ await this.ensureRunning();
946
+ const fullCommand = args.length > 0 ? `${command} ${args.map(shellQuote).join(" ")}` : command;
947
+ return {
948
+ ...await (await this.processes.spawn(fullCommand, options)).wait(),
949
+ command,
950
+ args
951
+ };
952
+ }
953
+ /**
954
+ * Bulk-write files into the sandbox filesystem via the SDK's native upload.
955
+ */
956
+ async writeFiles(files) {
957
+ await this.ensureRunning();
958
+ await this.daytona.fs.uploadFiles(files.map((file) => ({
959
+ source: Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content),
960
+ destination: file.path
961
+ })));
962
+ }
963
+ /**
964
+ * Mount a filesystem at a path in the sandbox.
965
+ * Uses FUSE tools (s3fs, gcsfuse) to mount cloud storage.
966
+ */
967
+ async mount(filesystem, mountPath) {
968
+ validateMountPath(mountPath);
969
+ if (!this._sandbox) throw new _mastra_core_workspace.SandboxNotReadyError(this.id);
970
+ const sandbox = this._sandbox;
971
+ this.logger.debug(`${LOG_PREFIX} Mounting "${mountPath}"...`);
972
+ const config = filesystem.getMountConfig?.();
973
+ if (!config) {
974
+ const error = `Filesystem "${filesystem.id}" does not provide a mount config`;
975
+ this.logger.error(`${LOG_PREFIX} ${error}`);
976
+ this.mounts.set(mountPath, {
977
+ filesystem,
978
+ state: "error",
979
+ error
980
+ });
981
+ return {
982
+ success: false,
983
+ mountPath,
984
+ error
985
+ };
986
+ }
987
+ const existingMount = await this.checkExistingMount(mountPath, config);
988
+ if (existingMount === "matching") {
989
+ this.logger.debug(`${LOG_PREFIX} Detected existing mount for ${filesystem.provider} ("${filesystem.id}") at "${mountPath}" with correct config, skipping`);
990
+ this.mounts.set(mountPath, {
991
+ state: "mounted",
992
+ config
993
+ });
994
+ return {
995
+ success: true,
996
+ mountPath
997
+ };
998
+ } else if (existingMount === "mismatched") {
999
+ this.logger.debug(`${LOG_PREFIX} Config mismatch at "${mountPath}", unmounting to re-mount with new config...`);
1000
+ await this.unmount(mountPath);
1001
+ } else if (existingMount === "unmanaged") {
1002
+ const error = `Mount path "${mountPath}" is already mounted by an unmanaged source`;
1003
+ this.logger.error(`${LOG_PREFIX} ${error}`);
1004
+ this.mounts.set(mountPath, {
1005
+ filesystem,
1006
+ state: "error",
1007
+ config,
1008
+ error
1009
+ });
1010
+ return {
1011
+ success: false,
1012
+ mountPath,
1013
+ error
1014
+ };
1015
+ }
1016
+ this.mounts.set(mountPath, {
1017
+ filesystem,
1018
+ state: "mounting",
1019
+ config
1020
+ });
1021
+ this.logger.debug(`${LOG_PREFIX} Config type: ${config.type}`);
1022
+ try {
1023
+ const quotedPath = shellQuote(mountPath);
1024
+ if ((await runCommand(sandbox, `[ -d ${quotedPath} ] && ! mountpoint -q ${quotedPath} 2>/dev/null && [ "$(ls -A ${quotedPath} 2>/dev/null)" ] && echo "non-empty" || echo "ok"`, { timeout: MOUNT_COMMAND_TIMEOUT_MS })).output.trim() === "non-empty") {
1025
+ 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.`;
1026
+ this.logger.error(`${LOG_PREFIX} ${error}`);
1027
+ this.mounts.set(mountPath, {
1028
+ filesystem,
1029
+ state: "error",
1030
+ config,
1031
+ error
1032
+ });
1033
+ return {
1034
+ success: false,
1035
+ mountPath,
1036
+ error
1037
+ };
1038
+ }
1039
+ } catch {}
1040
+ this.logger.debug(`${LOG_PREFIX} Creating mount directory for "${mountPath}"...`);
1041
+ try {
1042
+ const quotedPath = shellQuote(mountPath);
1043
+ const mkdirResult = await runCommand(sandbox, `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}`, { timeout: MOUNT_COMMAND_TIMEOUT_MS });
1044
+ if (mkdirResult.exitCode !== 0) {
1045
+ const error = mkdirResult.output || "Failed to create mount directory";
1046
+ this.logger.debug(`${LOG_PREFIX} mkdir error for "${mountPath}":`, error);
1047
+ this.mounts.set(mountPath, {
1048
+ filesystem,
1049
+ state: "error",
1050
+ config,
1051
+ error
1052
+ });
1053
+ return {
1054
+ success: false,
1055
+ mountPath,
1056
+ error
1057
+ };
1058
+ }
1059
+ } catch (err) {
1060
+ const error = `Failed to create mount directory: ${err}`;
1061
+ this.mounts.set(mountPath, {
1062
+ filesystem,
1063
+ state: "error",
1064
+ config,
1065
+ error
1066
+ });
1067
+ return {
1068
+ success: false,
1069
+ mountPath,
1070
+ error
1071
+ };
1072
+ }
1073
+ const mountCtx = {
1074
+ run: async (cmd, timeoutMs) => {
1075
+ const result = await runCommand(sandbox, cmd, timeoutMs !== void 0 ? { timeout: timeoutMs } : void 0);
1076
+ return {
1077
+ exitCode: result.exitCode,
1078
+ stdout: result.output,
1079
+ stderr: result.exitCode !== 0 ? result.output : ""
1080
+ };
1081
+ },
1082
+ writeFile: async (path, content) => {
1083
+ await sandbox.fs.uploadFile(Buffer.from(content), path);
1084
+ },
1085
+ logger: this.logger
1086
+ };
1087
+ try {
1088
+ switch (config.type) {
1089
+ case "s3":
1090
+ this.logger.debug(`${LOG_PREFIX} Mounting S3 at "${mountPath}"...`);
1091
+ await mountS3(mountPath, config, mountCtx);
1092
+ this.logger.debug(`${LOG_PREFIX} Mounted S3 bucket at ${mountPath}`);
1093
+ break;
1094
+ case "gcs":
1095
+ this.logger.debug(`${LOG_PREFIX} Mounting GCS at "${mountPath}"...`);
1096
+ await mountGCS(mountPath, config, mountCtx);
1097
+ this.logger.debug(`${LOG_PREFIX} Mounted GCS bucket at ${mountPath}`);
1098
+ break;
1099
+ case "azure-blob":
1100
+ this.logger.debug(`${LOG_PREFIX} Mounting Azure Blob at "${mountPath}"...`);
1101
+ await mountAzure(mountPath, config, mountCtx);
1102
+ this.logger.debug(`${LOG_PREFIX} Mounted Azure Blob container at ${mountPath}`);
1103
+ break;
1104
+ default: {
1105
+ const error = `Unsupported mount type: ${config.type}`;
1106
+ this.mounts.set(mountPath, {
1107
+ filesystem,
1108
+ state: "unsupported",
1109
+ config,
1110
+ error
1111
+ });
1112
+ return {
1113
+ success: false,
1114
+ mountPath,
1115
+ error
1116
+ };
1117
+ }
1118
+ }
1119
+ } catch (error) {
1120
+ this.logger.error(`${LOG_PREFIX} Error mounting "${filesystem.provider}" (${filesystem.id}) at "${mountPath}":`, error);
1121
+ this.mounts.set(mountPath, {
1122
+ filesystem,
1123
+ state: "error",
1124
+ config,
1125
+ error: errorToString(error)
1126
+ });
1127
+ await runCommand(sandbox, `sudo rmdir ${shellQuote(mountPath)} 2>/dev/null || true`, { timeout: MOUNT_COMMAND_TIMEOUT_MS });
1128
+ this.logger.debug(`${LOG_PREFIX} Cleaned up directory after failed mount: ${mountPath}`);
1129
+ return {
1130
+ success: false,
1131
+ mountPath,
1132
+ error: errorToString(error)
1133
+ };
1134
+ }
1135
+ this.mounts.set(mountPath, {
1136
+ state: "mounted",
1137
+ config
1138
+ });
1139
+ await this.writeMarkerFile(mountPath);
1140
+ this.logger.debug(`${LOG_PREFIX} Mounted "${mountPath}"`);
1141
+ return {
1142
+ success: true,
1143
+ mountPath
1144
+ };
1145
+ }
1146
+ /**
1147
+ * Unmount a filesystem from a path in the sandbox.
1148
+ */
1149
+ async unmount(mountPath) {
1150
+ validateMountPath(mountPath);
1151
+ if (!this._sandbox) throw new _mastra_core_workspace.SandboxNotReadyError(this.id);
1152
+ const sandbox = this._sandbox;
1153
+ this.logger.debug(`${LOG_PREFIX} Unmounting "${mountPath}"...`);
1154
+ const quotedPath = shellQuote(mountPath);
1155
+ await runCommand(sandbox, `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; }`, { timeout: MOUNT_COMMAND_TIMEOUT_MS });
1156
+ this.mounts.delete(mountPath);
1157
+ const rmdirResult = await runCommand(sandbox, `rm -f ${shellQuote(`/tmp/.mastra-mounts/${this.mounts.markerFilename(mountPath)}`)} 2>/dev/null; sudo rmdir ${quotedPath} 2>&1`, { timeout: MOUNT_COMMAND_TIMEOUT_MS });
1158
+ if (rmdirResult.exitCode === 0) this.logger.debug(`${LOG_PREFIX} Unmounted and removed ${mountPath}`);
1159
+ else this.logger.debug(`${LOG_PREFIX} Unmounted ${mountPath} (directory not removed: ${rmdirResult.output.trim() || "not empty"})`);
1160
+ }
1161
+ /**
1162
+ * Unmount all stale mounts that are not in the expected mounts list.
1163
+ * Also cleans up orphaned directories and marker files from failed mount attempts.
1164
+ * Call this after reconnecting to an existing sandbox to clean up old mounts.
1165
+ */
1166
+ async reconcileMounts(expectedMountPaths) {
1167
+ if (!this._sandbox) return;
1168
+ const sandbox = this._sandbox;
1169
+ this.logger.debug(`${LOG_PREFIX} Reconciling mounts. Expected paths:`, expectedMountPaths);
1170
+ let currentMounts = [];
1171
+ try {
1172
+ currentMounts = (await runCommand(sandbox, `grep -E 'fuse\\.(s3fs|gcsfuse|blobfuse2)' /proc/mounts | awk '{print $2}'`, { timeout: MOUNT_COMMAND_TIMEOUT_MS })).output.trim().split("\n").filter((p) => p.length > 0);
1173
+ } catch (err) {
1174
+ this.logger.debug(`${LOG_PREFIX} Could not read /proc/mounts: ${err}`);
1175
+ return;
1176
+ }
1177
+ this.logger.debug(`${LOG_PREFIX} Current FUSE mounts in sandbox:`, currentMounts);
1178
+ let markerFiles = [];
1179
+ try {
1180
+ markerFiles = (await runCommand(sandbox, "ls /tmp/.mastra-mounts/ 2>/dev/null || echo \"\"", { timeout: MOUNT_COMMAND_TIMEOUT_MS })).output.trim().split("\n").filter((f) => f.length > 0 && SAFE_MARKER_NAME.test(f));
1181
+ } catch (err) {
1182
+ this.logger.debug(`${LOG_PREFIX} Could not read marker files: ${err}`);
1183
+ }
1184
+ const managedMountPaths = /* @__PURE__ */ new Map();
1185
+ for (const markerFile of markerFiles) {
1186
+ const markerResult = await runCommand(sandbox, `cat "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || echo ""`, { timeout: MOUNT_COMMAND_TIMEOUT_MS });
1187
+ const parsed = this.mounts.parseMarkerContent(markerResult.output.trim());
1188
+ if (parsed && SAFE_MOUNT_PATH.test(parsed.path)) managedMountPaths.set(parsed.path, markerFile);
1189
+ }
1190
+ const staleMounts = currentMounts.filter((path) => !expectedMountPaths.includes(path));
1191
+ for (const stalePath of staleMounts) if (managedMountPaths.has(stalePath)) {
1192
+ this.logger.debug(`${LOG_PREFIX} Found stale managed FUSE mount at "${stalePath}", unmounting...`);
1193
+ try {
1194
+ await this.unmount(stalePath);
1195
+ } catch (err) {
1196
+ this.logger.debug(`${LOG_PREFIX} Failed to unmount stale mount at "${stalePath}": ${err}`);
1197
+ }
1198
+ } else this.logger.debug(`${LOG_PREFIX} Found external FUSE mount at "${stalePath}", leaving untouched`);
1199
+ try {
1200
+ const expectedMarkerFiles = new Set(expectedMountPaths.map((p) => this.mounts.markerFilename(p)));
1201
+ const markerToPath = /* @__PURE__ */ new Map();
1202
+ for (const [path, file] of managedMountPaths) markerToPath.set(file, path);
1203
+ for (const markerFile of markerFiles) if (!expectedMarkerFiles.has(markerFile)) {
1204
+ const mountPath = markerToPath.get(markerFile);
1205
+ if (mountPath) {
1206
+ if (!currentMounts.includes(mountPath)) {
1207
+ this.logger.debug(`${LOG_PREFIX} Cleaning up orphaned marker and directory for ${mountPath}`);
1208
+ await runCommand(sandbox, `rm -f "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null; sudo rmdir ${shellQuote(mountPath)} 2>/dev/null`, { timeout: MOUNT_COMMAND_TIMEOUT_MS });
1209
+ }
1210
+ } else {
1211
+ this.logger.debug(`${LOG_PREFIX} Removing malformed marker file: ${markerFile}`);
1212
+ await runCommand(sandbox, `rm -f "/tmp/.mastra-mounts/${markerFile}" 2>/dev/null || true`, { timeout: MOUNT_COMMAND_TIMEOUT_MS });
1213
+ }
1214
+ }
1215
+ } catch {
1216
+ this.logger.debug(`${LOG_PREFIX} Error during orphan cleanup (non-fatal)`);
1217
+ }
1218
+ }
1219
+ /**
1220
+ * Write marker file for detecting config changes on reconnect.
1221
+ * Stores both the mount path and config hash in the file.
1222
+ */
1223
+ async writeMarkerFile(mountPath) {
1224
+ if (!this._sandbox) return;
1225
+ const markerContent = this.mounts.getMarkerContent(mountPath);
1226
+ if (!markerContent) return;
1227
+ const markerPath = `/tmp/.mastra-mounts/${this.mounts.markerFilename(mountPath)}`;
1228
+ try {
1229
+ await runCommand(this._sandbox, "mkdir -p /tmp/.mastra-mounts", { timeout: MOUNT_COMMAND_TIMEOUT_MS });
1230
+ await this._sandbox.fs.uploadFile(Buffer.from(markerContent, "utf-8"), markerPath);
1231
+ } catch {
1232
+ this.logger.debug(`${LOG_PREFIX} Warning: Could not write marker file at ${markerPath}`);
1233
+ }
1234
+ }
1235
+ /**
1236
+ * Check if a path is already mounted and whether the config matches.
1237
+ *
1238
+ * @param mountPath - The mount path to check
1239
+ * @param newConfig - The new config to compare against the stored config
1240
+ * @returns 'not_mounted' | 'matching' | 'mismatched' | 'unmanaged'
1241
+ */
1242
+ async checkExistingMount(mountPath, newConfig) {
1243
+ if (!this._sandbox) throw new _mastra_core_workspace.SandboxNotReadyError(this.id);
1244
+ const sandbox = this._sandbox;
1245
+ try {
1246
+ if ((await runCommand(sandbox, `mountpoint -q ${shellQuote(mountPath)} && echo "mounted" || echo "not mounted"`, { timeout: MOUNT_COMMAND_TIMEOUT_MS })).output.trim() !== "mounted") return "not_mounted";
1247
+ } catch {
1248
+ return "not_mounted";
1249
+ }
1250
+ const markerPath = `/tmp/.mastra-mounts/${this.mounts.markerFilename(mountPath)}`;
1251
+ let parsed;
1252
+ try {
1253
+ const markerResult = await runCommand(sandbox, `cat ${shellQuote(markerPath)} 2>/dev/null || echo ""`, { timeout: MOUNT_COMMAND_TIMEOUT_MS });
1254
+ parsed = this.mounts.parseMarkerContent(markerResult.output.trim());
1255
+ } catch {
1256
+ return "unmanaged";
1257
+ }
1258
+ if (!parsed) return "unmanaged";
1259
+ const newConfigHash = this.mounts.computeConfigHash(newConfig);
1260
+ this.logger.debug(`${LOG_PREFIX} Marker check - stored hash: "${parsed.configHash}", new config hash: "${newConfigHash}"`);
1261
+ if (parsed.path === mountPath && parsed.configHash === newConfigHash) return "matching";
1262
+ return "mismatched";
1263
+ }
1264
+ /**
1265
+ * Try to find and reconnect to an existing Daytona sandbox by ID.
1266
+ * Returns the sandbox if found and usable, or null if a fresh one should
1267
+ * be created.
1268
+ */
1269
+ async detectWorkingDir() {
1270
+ if (!this._sandbox) return;
1271
+ try {
1272
+ const dir = (await runCommand(this._sandbox, "pwd", { timeout: MOUNT_COMMAND_TIMEOUT_MS })).output?.trim();
1273
+ if (dir) {
1274
+ this._workingDir = dir;
1275
+ this.logger.debug(`${LOG_PREFIX} Detected working directory: ${dir}`);
1276
+ }
1277
+ } catch {
1278
+ this.logger.debug(`${LOG_PREFIX} Could not detect working directory, will omit from instructions`);
1279
+ }
1280
+ }
1281
+ /**
1282
+ * Look up the existing Daytona sandbox by identity WITHOUT starting it.
1283
+ * Used for detached (cross-process) networking/stop/destroy so those
1284
+ * operations never wake a stopped sandbox. Returns null when not found.
1285
+ */
1286
+ async lookupDetachedSandbox() {
1287
+ const lookupKey = this._daytonaSandboxId ?? this.sandboxName;
1288
+ if (!lookupKey) return null;
1289
+ if (!this._daytona) this._daytona = new _daytonaio_sdk.Daytona(this.connectionOpts);
1290
+ try {
1291
+ return await this._daytona.get(lookupKey);
1292
+ } catch (error) {
1293
+ if (error instanceof _daytonaio_sdk.DaytonaNotFoundError) return null;
1294
+ throw error;
1295
+ }
1296
+ }
1297
+ async findExistingSandbox() {
1298
+ const DEAD_STATES = [
1299
+ _daytonaio_sdk.SandboxState.DESTROYED,
1300
+ _daytonaio_sdk.SandboxState.DESTROYING,
1301
+ _daytonaio_sdk.SandboxState.ERROR,
1302
+ _daytonaio_sdk.SandboxState.BUILD_FAILED
1303
+ ];
1304
+ const lookupKey = this._daytonaSandboxId ?? this.sandboxName;
1305
+ if (!lookupKey) return null;
1306
+ let sandbox;
1307
+ try {
1308
+ sandbox = await this._daytona.get(lookupKey);
1309
+ } catch (error) {
1310
+ if (error instanceof _daytonaio_sdk.DaytonaNotFoundError) {
1311
+ this._daytonaSandboxId = void 0;
1312
+ return null;
1313
+ }
1314
+ throw error;
1315
+ }
1316
+ const state = sandbox.state;
1317
+ if (state && DEAD_STATES.includes(state)) {
1318
+ this.logger.debug(`${LOG_PREFIX} Existing sandbox ${sandbox.id} is dead (${state}), deleting and creating fresh`);
1319
+ try {
1320
+ await this._daytona.delete(sandbox);
1321
+ } catch {}
1322
+ return null;
1323
+ }
1324
+ if (state !== _daytonaio_sdk.SandboxState.STARTED) {
1325
+ this.logger.debug(`${LOG_PREFIX} Restarting sandbox ${sandbox.id} (state: ${state})`);
1326
+ await this.waitForStableStateAndStart(sandbox);
1327
+ }
1328
+ return sandbox;
1329
+ }
1330
+ /**
1331
+ * Transitional states where the Daytona API will reject start() with
1332
+ * "State change in progress". We poll until the sandbox reaches a stable
1333
+ * state before attempting start().
1334
+ */
1335
+ static TRANSITIONAL_STATES = [
1336
+ _daytonaio_sdk.SandboxState.STARTING,
1337
+ _daytonaio_sdk.SandboxState.STOPPING,
1338
+ _daytonaio_sdk.SandboxState.CREATING,
1339
+ _daytonaio_sdk.SandboxState.RESTORING,
1340
+ _daytonaio_sdk.SandboxState.ARCHIVING,
1341
+ _daytonaio_sdk.SandboxState.RESIZING,
1342
+ _daytonaio_sdk.SandboxState.PULLING_SNAPSHOT,
1343
+ _daytonaio_sdk.SandboxState.BUILDING_SNAPSHOT
1344
+ ];
1345
+ /**
1346
+ * Wait for the sandbox to leave a transitional state, then start it if needed.
1347
+ * Polls every 2s for up to 120s. If the sandbox reaches STARTED on its own
1348
+ * (e.g. it was STARTING), we skip the start() call. If start() still fails
1349
+ * with "State change in progress", we retry with backoff.
1350
+ */
1351
+ async waitForStableStateAndStart(sandbox) {
1352
+ const MAX_WAIT_MS = 12e4;
1353
+ const POLL_INTERVAL_MS = 2e3;
1354
+ const deadline = Date.now() + MAX_WAIT_MS;
1355
+ let current = sandbox;
1356
+ while (current.state && DaytonaSandbox.TRANSITIONAL_STATES.includes(current.state) && Date.now() < deadline) {
1357
+ this.logger.debug(`${LOG_PREFIX} Sandbox ${current.id} is in transitional state (${current.state}), waiting...`);
1358
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1359
+ current = await this._daytona.get(current.id);
1360
+ }
1361
+ if (current.state === _daytonaio_sdk.SandboxState.STARTED) {
1362
+ Object.assign(sandbox, current);
1363
+ return;
1364
+ }
1365
+ while (Date.now() < deadline) try {
1366
+ await this._daytona.start(current);
1367
+ return;
1368
+ } catch (error) {
1369
+ if ((error instanceof Error ? error.message : String(error)).includes("State change in progress") && Date.now() < deadline) {
1370
+ this.logger.debug(`${LOG_PREFIX} start() returned "State change in progress", retrying...`);
1371
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1372
+ current = await this._daytona.get(current.id);
1373
+ if (current.state === _daytonaio_sdk.SandboxState.STARTED) {
1374
+ Object.assign(sandbox, current);
1375
+ return;
1376
+ }
1377
+ continue;
1378
+ }
1379
+ throw error;
1380
+ }
1381
+ await this._daytona.start(current);
1382
+ }
1383
+ /**
1384
+ * Check if an error indicates the sandbox is dead/gone.
1385
+ * Uses DaytonaNotFoundError from the SDK when available,
1386
+ * with string fallback for edge cases.
1387
+ *
1388
+ * String patterns observed in @daytonaio/sdk@0.143.0 error messages.
1389
+ * Update if SDK error messages change in future versions.
1390
+ */
1391
+ isSandboxDeadError(error) {
1392
+ if (!error) return false;
1393
+ if (error instanceof _daytonaio_sdk.DaytonaNotFoundError) return true;
1394
+ const errorStr = String(error);
1395
+ return SANDBOX_DEAD_PATTERNS.some((pattern) => pattern.test(errorStr));
1396
+ }
1397
+ /**
1398
+ * Handle sandbox timeout by clearing the instance and resetting state.
1399
+ */
1400
+ handleSandboxTimeout() {
1401
+ this._sandbox = null;
1402
+ if (this.mounts) {
1403
+ for (const [path, entry] of this.mounts.entries) if (entry.state === "mounted" || entry.state === "mounting") this.mounts.set(path, { state: "pending" });
1404
+ }
1405
+ this.status = "stopped";
1406
+ }
1407
+ /**
1408
+ * Execute a function, retrying once if the sandbox is found to be dead.
1409
+ * Used by DaytonaProcessManager to handle stale sandboxes transparently.
1410
+ */
1411
+ async retryOnDead(fn) {
1412
+ try {
1413
+ return await fn();
1414
+ } catch (error) {
1415
+ if (this.isSandboxDeadError(error) && !this._isRetrying) {
1416
+ this.handleSandboxTimeout();
1417
+ this._isRetrying = true;
1418
+ try {
1419
+ await this.ensureRunning();
1420
+ return await fn();
1421
+ } finally {
1422
+ this._isRetrying = false;
1423
+ }
1424
+ }
1425
+ throw error;
1426
+ }
1427
+ }
1563
1428
  };
1564
-
1429
+ //#endregion
1565
1430
  exports.DaytonaProcessManager = DaytonaProcessManager;
1566
1431
  exports.DaytonaSandbox = DaytonaSandbox;
1567
- //# sourceMappingURL=index.cjs.map
1432
+
1568
1433
  //# sourceMappingURL=index.cjs.map