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