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