@mastra/daytona 0.10.0 → 0.11.0-alpha.1
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 +17 -0
- package/dist/index.cjs +122 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +124 -40
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.d.ts +3 -0
- package/dist/sandbox/index.d.ts.map +1 -1
- package/dist/sandbox/mounts/s3-credentials.d.ts +5 -0
- package/dist/sandbox/mounts/s3-credentials.d.ts.map +1 -0
- package/dist/sandbox/mounts/s3.d.ts +2 -0
- package/dist/sandbox/mounts/s3.d.ts.map +1 -1
- package/package.json +8 -8
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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
@@ -965,8 +1030,12 @@ var DaytonaSandbox = class DaytonaSandbox extends _mastra_core_workspace.MastraS
|
|
|
965
1030
|
}
|
|
966
1031
|
/**
|
|
967
1032
|
* Bulk-write files into the sandbox filesystem via the SDK's native upload.
|
|
1033
|
+
*
|
|
1034
|
+
* Per-file permission modes are not supported; an explicit `mode` is
|
|
1035
|
+
* rejected rather than silently discarded.
|
|
968
1036
|
*/
|
|
969
1037
|
async writeFiles(files) {
|
|
1038
|
+
(0, _mastra_core_workspace.assertModesUnsupported)(files, "Daytona");
|
|
970
1039
|
await this.ensureRunning();
|
|
971
1040
|
await this.daytona.fs.uploadFiles(files.map((file) => ({
|
|
972
1041
|
source: Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content),
|
|
@@ -1221,14 +1290,18 @@ var DaytonaSandbox = class DaytonaSandbox extends _mastra_core_workspace.MastraS
|
|
|
1221
1290
|
}
|
|
1222
1291
|
} catch (error) {
|
|
1223
1292
|
this.logger.error(`${LOG_PREFIX} Error mounting "${filesystem.provider}" (${filesystem.id}) at "${mountPath}":`, error);
|
|
1293
|
+
if (config.type === "s3") try {
|
|
1294
|
+
await this.unmount(mountPath);
|
|
1295
|
+
} catch (cleanupError) {
|
|
1296
|
+
this.logger.warn(`${LOG_PREFIX} Could not unmount failed S3 mount at ${mountPath}:`, cleanupError);
|
|
1297
|
+
}
|
|
1224
1298
|
this.mounts.set(mountPath, {
|
|
1225
1299
|
filesystem,
|
|
1226
1300
|
state: "error",
|
|
1227
1301
|
config,
|
|
1228
1302
|
error: errorToString(error)
|
|
1229
1303
|
});
|
|
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}`);
|
|
1304
|
+
if (config.type !== "s3") await runCommand(sandbox, `sudo rmdir ${shellQuote(mountPath)} 2>/dev/null || true`, { timeout: MOUNT_COMMAND_TIMEOUT_MS });
|
|
1232
1305
|
return {
|
|
1233
1306
|
success: false,
|
|
1234
1307
|
mountPath,
|
|
@@ -1255,7 +1328,18 @@ var DaytonaSandbox = class DaytonaSandbox extends _mastra_core_workspace.MastraS
|
|
|
1255
1328
|
const sandbox = this._sandbox;
|
|
1256
1329
|
this.logger.debug(`${LOG_PREFIX} Unmounting "${mountPath}"...`);
|
|
1257
1330
|
const quotedPath = shellQuote(mountPath);
|
|
1258
|
-
await runCommand(sandbox, `sudo fusermount -u ${quotedPath} 2>/dev/null; sudo umount -l ${quotedPath} 2>/dev/null;
|
|
1331
|
+
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 });
|
|
1332
|
+
await cleanupS3Credentials(mountPath, {
|
|
1333
|
+
run: async (cmd, timeout) => {
|
|
1334
|
+
const result = await runCommand(sandbox, cmd, { timeout: timeout ?? MOUNT_COMMAND_TIMEOUT_MS });
|
|
1335
|
+
return {
|
|
1336
|
+
exitCode: result.exitCode,
|
|
1337
|
+
stdout: result.output,
|
|
1338
|
+
stderr: ""
|
|
1339
|
+
};
|
|
1340
|
+
},
|
|
1341
|
+
logger: this.logger
|
|
1342
|
+
});
|
|
1259
1343
|
this.mounts.delete(mountPath);
|
|
1260
1344
|
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
1345
|
if (rmdirResult.exitCode === 0) this.logger.debug(`${LOG_PREFIX} Unmounted and removed ${mountPath}`);
|