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