@mastra/daytona 0.10.0 → 0.10.1-alpha.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/README.md CHANGED
@@ -37,6 +37,23 @@ await workspace.destroy();
37
37
  - [Daytona integration guide](https://mastra.ai/integrations/sandboxes/daytona)
38
38
  - [Workspace documentation](https://mastra.ai/docs/mastra-platform/workspaces)
39
39
 
40
+ ### R2 integration test
41
+
42
+ The opt-in S3 mount test creates billable Daytona sandboxes and disposable R2 objects. Use a dedicated test bucket with test-only credentials that can list, read, write, and delete its objects. The lifecycle checks also mount with those long-lived credentials; don't supply production credentials.
43
+
44
+ Prepare a Daytona snapshot containing Python and `boto3` before running the test. Pin Python dependencies when building the snapshot, and set `DAYTONA_R2_TEST_SNAPSHOT` to its name. You can omit this variable if your default snapshot already contains these dependencies. The test fails with setup instructions if they're missing; it doesn't install Python packages at runtime. Preinstalling `s3fs` also avoids the mount provider's existing runtime system-package installation.
45
+
46
+ Set `DAYTONA_API_KEY`, `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, and `S3_SECRET_ACCESS_KEY` in the repository-root `.env`, then run from the repository root:
47
+
48
+ ```bash
49
+ DOTENV_CONFIG_PATH="$PWD/.env" \
50
+ RUN_R2_ISOLATION_TEST=1 \
51
+ DAYTONA_R2_TEST_SNAPSHOT=your-prepared-snapshot \
52
+ pnpm --filter @mastra/daytona exec vitest run src/sandbox/mounts/s3-r2.integration.test.ts
53
+ ```
54
+
55
+ The test checks cross-prefix access denial, mount recovery, credential cleanup, sandbox deletion, and removal of its test objects.
56
+
40
57
  ## Changelog
41
58
 
42
59
  See the [package changelog](https://github.com/mastra-ai/mastra/blob/main/workspaces/daytona/CHANGELOG.md) for version history and release notes.
package/dist/index.cjs CHANGED
@@ -77,6 +77,51 @@ async function runCommand(sandbox, command, options) {
77
77
  };
78
78
  }
79
79
  //#endregion
80
+ //#region src/sandbox/mounts/s3-credentials.ts
81
+ function s3CredentialsPrefix(mountPath) {
82
+ return `/tmp/.mastra-s3-${(0, crypto.createHash)("sha256").update(mountPath.replace(/\/$/, "")).digest("hex")}-`;
83
+ }
84
+ /** Remove credentials only after their s3fs process has exited, including after reconnects. */
85
+ async function cleanupS3Credentials(mountPath, ctx) {
86
+ const script = `test -r /proc/self/comm || exit 1
87
+ credentials_in_use() {
88
+ for process in /proc/[0-9]*; do
89
+ test -d "$process" || continue
90
+ name=$(cat "$process/comm" 2>/dev/null) || {
91
+ test -d "$process" && return 0
92
+ continue
93
+ }
94
+ test "$name" = s3fs || continue
95
+ args=$(tr '\\000' '\\n' < "$process/cmdline") || {
96
+ test -d "$process" && return 0
97
+ continue
98
+ }
99
+ printf '%s\\n' "$args" | grep -Fxq -- "passwd_file=$directory/credentials" && return 0
100
+ done
101
+ return 1
102
+ }
103
+ result=0
104
+ for directory in ${shellQuote(s3CredentialsPrefix(mountPath))}*; do
105
+ test -d "$directory" && test ! -L "$directory" || continue
106
+ attempt=0
107
+ while credentials_in_use && test "$attempt" -lt 20; do
108
+ sleep 0.25
109
+ attempt=$((attempt + 1))
110
+ done
111
+ if credentials_in_use; then
112
+ result=1
113
+ continue
114
+ fi
115
+ rm -f -- "$directory/credentials" && rmdir -- "$directory" || result=1
116
+ done
117
+ exit "$result"`;
118
+ try {
119
+ if ((await ctx.run(`sh -c ${shellQuote(script)}`, 3e4)).exitCode !== 0) ctx.logger.warn(`${LOG_PREFIX} S3 credentials retained: daemon still active or cleanup could not be verified`);
120
+ } catch {
121
+ ctx.logger.warn(`${LOG_PREFIX} Could not clean up S3 credentials`);
122
+ }
123
+ }
124
+ //#endregion
80
125
  //#region src/sandbox/mounts/s3.ts
81
126
  /**
82
127
  * Mount an S3 bucket using s3fs-fuse.
@@ -90,6 +135,7 @@ async function mountS3(mountPath, config, ctx) {
90
135
  const hasSecretKey = !!config.secretAccessKey;
91
136
  if (hasAccessKey !== hasSecretKey) throw new Error("Both accessKeyId and secretAccessKey must be provided together.");
92
137
  const hasCredentials = hasAccessKey && hasSecretKey;
138
+ if (config.sessionToken && !hasCredentials) throw new Error("sessionToken requires accessKeyId and secretAccessKey.");
93
139
  if (!hasCredentials && config.endpoint) throw new Error(`S3-compatible storage requires credentials. Detected endpoint: ${config.endpoint}. The public_bucket option only works for AWS S3 public buckets, not R2, MinIO, etc.`);
94
140
  if (config.endpoint) {
95
141
  const endpoint = config.endpoint.replace(/\/$/, "");
@@ -109,43 +155,62 @@ async function mountS3(mountPath, config, ctx) {
109
155
  const [uid, gid] = idResult.stdout.trim().split("\n");
110
156
  const validUidGid = uid && gid && /^\d+$/.test(uid) && /^\d+$/.test(gid);
111
157
  if (!validUidGid) logger.warn(`${LOG_PREFIX} Unexpected uid/gid format: "${idResult.stdout.trim()}" — mounted files will be owned by root`);
112
- const credentialsPath = `/tmp/.passwd-s3fs-${(0, crypto.createHash)("md5").update(mountPath).digest("hex").slice(0, 8)}`;
158
+ const credentialsDirectory = `${s3CredentialsPrefix(mountPath)}${(0, crypto.randomUUID)()}`;
159
+ const credentialsPath = `${credentialsDirectory}/credentials`;
113
160
  await run("sudo chmod a+rw /dev/fuse 2>/dev/null || true; sudo bash -c 'grep -q \"^user_allow_other\" /etc/fuse.conf 2>/dev/null || echo \"user_allow_other\" >> /etc/fuse.conf' 2>/dev/null || true");
114
- if (hasCredentials) {
115
- await run(`sudo rm -f ${shellQuote(credentialsPath)}`, 3e4);
116
- await writeFile(credentialsPath, `${config.accessKeyId}:${config.secretAccessKey}`);
117
- await run(`chmod 600 ${shellQuote(credentialsPath)}`, 3e4);
118
- }
119
- const mountOptions = [];
120
- if (hasCredentials) mountOptions.push(`passwd_file=${credentialsPath}`);
121
- else {
122
- mountOptions.push("public_bucket=1");
123
- logger.debug(`${LOG_PREFIX} No credentials provided, mounting as public bucket (read-only)`);
124
- }
125
- mountOptions.push("allow_other");
126
- if (validUidGid) mountOptions.push(`uid=${uid}`, `gid=${gid}`);
127
- if (config.endpoint) {
128
- const endpoint = config.endpoint.replace(/\/$/, "");
129
- mountOptions.push(`url=${shellQuote(endpoint)}`, "use_path_request_style", "sigv4", "nomultipart");
130
- }
131
- if (config.readOnly) {
132
- mountOptions.push("ro");
133
- logger.debug(`${LOG_PREFIX} Mounting as read-only`);
134
- }
135
- let bucketArg = config.bucket;
136
- if (config.prefix) {
137
- const normalizedPrefix = validatePrefix(config.prefix);
138
- bucketArg = `${config.bucket}:/${normalizedPrefix}`;
161
+ let credentialsCreated = false;
162
+ let mountAttempted = false;
163
+ try {
164
+ if (config.accessKeyId && config.secretAccessKey) {
165
+ if ((await run(`mkdir -m 700 ${shellQuote(credentialsDirectory)}`, 3e4)).exitCode !== 0) throw new Error("Failed to create private S3 credentials directory");
166
+ credentialsCreated = true;
167
+ await writeFile(credentialsPath, config.sessionToken ? [
168
+ `export AWS_ACCESS_KEY_ID=${shellQuote(config.accessKeyId)}`,
169
+ `export AWS_SECRET_ACCESS_KEY=${shellQuote(config.secretAccessKey)}`,
170
+ `export AWS_SESSION_TOKEN=${shellQuote(config.sessionToken)}`
171
+ ].join("\n") : `${config.accessKeyId}:${config.secretAccessKey}`);
172
+ if ((await run(`chmod 600 ${shellQuote(credentialsPath)}`, 3e4)).exitCode !== 0) throw new Error("Failed to restrict S3 credentials file permissions");
173
+ }
174
+ const mountOptions = [];
175
+ if (hasCredentials) mountOptions.push(config.sessionToken ? "use_session_token" : `passwd_file=${credentialsPath}`);
176
+ else {
177
+ mountOptions.push("public_bucket=1");
178
+ logger.debug(`${LOG_PREFIX} No credentials provided, mounting as public bucket (read-only)`);
179
+ }
180
+ mountOptions.push("allow_other");
181
+ if (validUidGid) mountOptions.push(`uid=${uid}`, `gid=${gid}`);
182
+ if (config.endpoint) {
183
+ const endpoint = config.endpoint.replace(/\/$/, "");
184
+ mountOptions.push(`url=${shellQuote(endpoint)}`, "use_path_request_style", "sigv4", "nomultipart");
185
+ }
186
+ if (config.readOnly) {
187
+ mountOptions.push("ro");
188
+ logger.debug(`${LOG_PREFIX} Mounting as read-only`);
189
+ }
190
+ let bucketArg = config.bucket;
191
+ if (config.prefix) {
192
+ const normalizedPrefix = validatePrefix(config.prefix);
193
+ bucketArg = `${config.bucket}:/${normalizedPrefix}`;
194
+ }
195
+ const mountCmd = `${config.sessionToken ? `. ${shellQuote(credentialsPath)} && ` : ""}s3fs ${shellQuote(bucketArg)} ${quotedMountPath} -o ${mountOptions.join(" -o ")}`;
196
+ logger.debug(`${LOG_PREFIX} Mounting S3:`, hasCredentials ? mountCmd.replace(credentialsPath, "***") : mountCmd);
197
+ mountAttempted = true;
198
+ const result = await run(mountCmd, 6e4);
199
+ logger.debug(`${LOG_PREFIX} s3fs result:`, {
200
+ exitCode: result.exitCode,
201
+ stdout: result.stdout,
202
+ stderr: result.stderr
203
+ });
204
+ if (result.exitCode !== 0) throw new Error(`Failed to mount S3 bucket: ${result.stderr || result.stdout}`);
205
+ const probe = await run(`timeout -k 5s 15s stat -L -- ${quotedMountPath} > /dev/null`, 3e4);
206
+ if (probe.exitCode !== 0) throw new Error(`S3 mount is not readable (exit ${probe.exitCode}): ${probe.stderr || probe.stdout}`);
207
+ } finally {
208
+ if (credentialsCreated && (config.sessionToken || !mountAttempted)) try {
209
+ if ((await run(`rm -f ${shellQuote(credentialsPath)} && rmdir ${shellQuote(credentialsDirectory)}`, 3e4)).exitCode !== 0) logger.warn(`${LOG_PREFIX} Failed to remove S3 credentials`);
210
+ } catch {
211
+ logger.warn(`${LOG_PREFIX} Failed to remove S3 credentials`);
212
+ }
139
213
  }
140
- const mountCmd = `s3fs ${shellQuote(bucketArg)} ${quotedMountPath} -o ${mountOptions.join(" -o ")}`;
141
- logger.debug(`${LOG_PREFIX} Mounting S3:`, hasCredentials ? mountCmd.replace(credentialsPath, "***") : mountCmd);
142
- const result = await run(mountCmd, 6e4);
143
- logger.debug(`${LOG_PREFIX} s3fs result:`, {
144
- exitCode: result.exitCode,
145
- stdout: result.stdout,
146
- stderr: result.stderr
147
- });
148
- if (result.exitCode !== 0) throw new Error(`Failed to mount S3 bucket: ${result.stderr || result.stdout}`);
149
214
  }
150
215
  //#endregion
151
216
  //#region src/sandbox/mounts/gcs.ts
@@ -1221,14 +1286,18 @@ var DaytonaSandbox = class DaytonaSandbox extends _mastra_core_workspace.MastraS
1221
1286
  }
1222
1287
  } catch (error) {
1223
1288
  this.logger.error(`${LOG_PREFIX} Error mounting "${filesystem.provider}" (${filesystem.id}) at "${mountPath}":`, error);
1289
+ if (config.type === "s3") try {
1290
+ await this.unmount(mountPath);
1291
+ } catch (cleanupError) {
1292
+ this.logger.warn(`${LOG_PREFIX} Could not unmount failed S3 mount at ${mountPath}:`, cleanupError);
1293
+ }
1224
1294
  this.mounts.set(mountPath, {
1225
1295
  filesystem,
1226
1296
  state: "error",
1227
1297
  config,
1228
1298
  error: errorToString(error)
1229
1299
  });
1230
- await runCommand(sandbox, `sudo rmdir ${shellQuote(mountPath)} 2>/dev/null || true`, { timeout: MOUNT_COMMAND_TIMEOUT_MS });
1231
- this.logger.debug(`${LOG_PREFIX} Cleaned up directory after failed mount: ${mountPath}`);
1300
+ if (config.type !== "s3") await runCommand(sandbox, `sudo rmdir ${shellQuote(mountPath)} 2>/dev/null || true`, { timeout: MOUNT_COMMAND_TIMEOUT_MS });
1232
1301
  return {
1233
1302
  success: false,
1234
1303
  mountPath,
@@ -1255,7 +1324,18 @@ var DaytonaSandbox = class DaytonaSandbox extends _mastra_core_workspace.MastraS
1255
1324
  const sandbox = this._sandbox;
1256
1325
  this.logger.debug(`${LOG_PREFIX} Unmounting "${mountPath}"...`);
1257
1326
  const quotedPath = shellQuote(mountPath);
1258
- 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 });
1327
+ await runCommand(sandbox, `sudo fusermount -u ${quotedPath} 2>/dev/null; sudo umount -l ${quotedPath} 2>/dev/null; grep -Fq -- ${shellQuote(` ${(mountPath.endsWith("/") ? mountPath.slice(0, -1) : mountPath) || "/"} `)} /proc/mounts && { _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 });
1328
+ await cleanupS3Credentials(mountPath, {
1329
+ run: async (cmd, timeout) => {
1330
+ const result = await runCommand(sandbox, cmd, { timeout: timeout ?? MOUNT_COMMAND_TIMEOUT_MS });
1331
+ return {
1332
+ exitCode: result.exitCode,
1333
+ stdout: result.output,
1334
+ stderr: ""
1335
+ };
1336
+ },
1337
+ logger: this.logger
1338
+ });
1259
1339
  this.mounts.delete(mountPath);
1260
1340
  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 });
1261
1341
  if (rmdirResult.exitCode === 0) this.logger.debug(`${LOG_PREFIX} Unmounted and removed ${mountPath}`);