@mastra/platform-workspace 0.0.0-esbuild-bundle-worker-20260807173433

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1459 @@
1
+ import { Buffer } from "buffer";
2
+ import nodePath from "path";
3
+ import { FileExistsError, FileNotFoundError, MastraFilesystem, MastraSandbox, ProcessHandle, SandboxNotReadyError, SandboxProcessManager, WorkspaceReadOnlyError } from "@mastra/core/workspace";
4
+ //#region src/client.ts
5
+ const DEFAULT_PROXY_URL = "https://workspaces.mastra.ai";
6
+ /**
7
+ * Default per-request timeout for calls to the workspace proxy. Applied only
8
+ * when the caller doesn't already pass an `AbortSignal`. Long-running routes
9
+ * (e.g. `POST /sandbox/:id/exec`) pass their own longer signal.
10
+ */
11
+ const DEFAULT_REQUEST_TIMEOUT_MS = 6e4;
12
+ function requireOption(value, name) {
13
+ if (!value) throw new Error(`${name} is required`);
14
+ return value;
15
+ }
16
+ function resolvePlatformOptions(options) {
17
+ return {
18
+ accessToken: requireOption(options.accessToken ?? process.env.MASTRA_PLATFORM_ACCESS_TOKEN, "accessToken"),
19
+ projectId: requireOption(options.projectId ?? process.env.MASTRA_PROJECT_ID, "projectId"),
20
+ proxyUrl: (process.env.MASTRA_WORKSPACE_PROXY_URL ?? DEFAULT_PROXY_URL).replace(/\/$/, ""),
21
+ fetch: options.fetch ?? fetch
22
+ };
23
+ }
24
+ function parseProxyError(body) {
25
+ if (!body) return void 0;
26
+ let parsed;
27
+ try {
28
+ parsed = JSON.parse(body);
29
+ } catch {
30
+ return;
31
+ }
32
+ if (typeof parsed !== "object" || parsed === null) return void 0;
33
+ const err = parsed.error;
34
+ if (typeof err !== "object" || err === null) return void 0;
35
+ const { message, type } = err;
36
+ if (typeof message !== "string" || typeof type !== "string") return void 0;
37
+ return {
38
+ message,
39
+ type
40
+ };
41
+ }
42
+ var PlatformApiError = class extends Error {
43
+ status;
44
+ body;
45
+ /** Machine-readable proxy error kind (e.g. `not_found`), when the response body matches `{ error: { message, type } }`. */
46
+ code;
47
+ /** Human-readable proxy error message, when the response body matches `{ error: { message, type } }`. */
48
+ proxyMessage;
49
+ constructor(status, body) {
50
+ const parsed = parseProxyError(body);
51
+ const summary = parsed ? `${parsed.type}: ${parsed.message}` : body;
52
+ super(`Platform proxy request failed with ${status}${summary ? `: ${summary}` : ""}`);
53
+ this.name = "PlatformApiError";
54
+ this.status = status;
55
+ this.body = body;
56
+ this.code = parsed?.type;
57
+ this.proxyMessage = parsed?.message;
58
+ }
59
+ };
60
+ var PlatformClient = class {
61
+ accessToken;
62
+ projectId;
63
+ proxyUrl;
64
+ fetch;
65
+ constructor(options) {
66
+ const resolved = resolvePlatformOptions(options);
67
+ this.accessToken = resolved.accessToken;
68
+ this.projectId = resolved.projectId;
69
+ this.proxyUrl = resolved.proxyUrl;
70
+ this.fetch = resolved.fetch;
71
+ }
72
+ async request(path, options = {}) {
73
+ const url = new URL(`${this.proxyUrl}/v1/projects/${encodeURIComponent(this.projectId)}${path}`);
74
+ for (const [key, value] of Object.entries(options.query ?? {})) if (value !== void 0) url.searchParams.set(key, String(value));
75
+ const headers = new Headers(options.headers);
76
+ headers.set("authorization", `Bearer ${this.accessToken}`);
77
+ const { query: _query, ...fetchOptions } = options;
78
+ const signal = fetchOptions.signal ?? AbortSignal.timeout(DEFAULT_REQUEST_TIMEOUT_MS);
79
+ const response = await this.fetch(url, {
80
+ ...fetchOptions,
81
+ headers,
82
+ signal
83
+ });
84
+ if (!response.ok) throw new PlatformApiError(response.status, await response.text());
85
+ return response;
86
+ }
87
+ };
88
+ //#endregion
89
+ //#region src/filesystem.ts
90
+ function normalizePath(input) {
91
+ if (!input || input === ".") return "/";
92
+ let normalized = input.startsWith("/") ? input : `/${input}`;
93
+ normalized = nodePath.posix.normalize(normalized);
94
+ return normalized === "." ? "/" : normalized;
95
+ }
96
+ function keyFromPath(path) {
97
+ const normalized = normalizePath(path);
98
+ return normalized === "/" ? "" : normalized.slice(1);
99
+ }
100
+ /**
101
+ * Encode each `/`-delimited segment of an object key with `encodeURIComponent`
102
+ * so reserved URL characters (`?`, `#`, `%`, `&`, `+`, spaces, etc.) are
103
+ * treated as part of the key instead of URL syntax. Kept segment-aware so
104
+ * `/` continues to act as a path separator on the wire.
105
+ */
106
+ function encodeKeyPath(key) {
107
+ return key.split("/").map(encodeURIComponent).join("/");
108
+ }
109
+ function nameFromPath(path) {
110
+ const normalized = normalizePath(path);
111
+ if (normalized === "/") return "";
112
+ return normalized.slice(normalized.lastIndexOf("/") + 1);
113
+ }
114
+ function contentToBody(content) {
115
+ if (typeof content === "string") return content;
116
+ return Buffer.from(content);
117
+ }
118
+ function headerDate(headers, name) {
119
+ const value = headers.get(name);
120
+ return value ? new Date(value) : /* @__PURE__ */ new Date(0);
121
+ }
122
+ function headerSize(headers) {
123
+ const value = headers.get("content-length");
124
+ return value ? Number(value) : 0;
125
+ }
126
+ function isNotFound(error) {
127
+ return typeof error === "object" && error !== null && "status" in error && error.status === 404;
128
+ }
129
+ var PlatformFilesystem = class extends MastraFilesystem {
130
+ id;
131
+ name = "PlatformFilesystem";
132
+ provider = "platform";
133
+ readOnly;
134
+ displayName;
135
+ icon;
136
+ description;
137
+ status = "pending";
138
+ _client;
139
+ _bucketName;
140
+ _instructionsOverride;
141
+ constructor(options = {}) {
142
+ super({
143
+ ...options,
144
+ name: "PlatformFilesystem"
145
+ });
146
+ this.id = options.id ?? this.generateId();
147
+ this._bucketName = options.bucketName ?? process.env.MASTRA_PLATFORM_BUCKET_NAME ?? "";
148
+ if (!this._bucketName) throw new Error("bucketName is required");
149
+ this.readOnly = options.readOnly;
150
+ this.displayName = options.displayName;
151
+ this.icon = options.icon ?? "cloud";
152
+ this.description = options.description;
153
+ this._instructionsOverride = options.instructions;
154
+ this._client = new PlatformClient(options);
155
+ }
156
+ generateId() {
157
+ return `platform-fs-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
158
+ }
159
+ async readFile(path, options) {
160
+ await this.ensureReady();
161
+ let response;
162
+ try {
163
+ response = await this._client.request(`/fs/${encodeURIComponent(this._bucketName)}/${encodeKeyPath(keyFromPath(path))}`);
164
+ } catch (error) {
165
+ if (isNotFound(error)) throw new FileNotFoundError(path);
166
+ throw error;
167
+ }
168
+ const buffer = Buffer.from(await response.arrayBuffer());
169
+ return options?.encoding ? buffer.toString(options.encoding) : buffer;
170
+ }
171
+ async writeFile(path, content, options) {
172
+ await this.ensureReady();
173
+ if (this.readOnly) throw new WorkspaceReadOnlyError("writeFile");
174
+ const headers = {};
175
+ if (options?.mimeType) headers["content-type"] = options.mimeType;
176
+ if (options?.overwrite === false) headers["if-none-match"] = "*";
177
+ try {
178
+ await this._client.request(`/fs/${encodeURIComponent(this._bucketName)}/${encodeKeyPath(keyFromPath(path))}`, {
179
+ method: "PUT",
180
+ headers,
181
+ body: contentToBody(content)
182
+ });
183
+ } catch (error) {
184
+ if (typeof error === "object" && error !== null && "status" in error && error.status === 412) throw new FileExistsError(path);
185
+ throw error;
186
+ }
187
+ }
188
+ /**
189
+ * Append bytes to a file.
190
+ *
191
+ * **Not atomic.** Object storage behind the workspace proxy has no native
192
+ * append or compare-and-swap primitive, so this implementation is a
193
+ * read-modify-write: it reads the current contents, concatenates the new
194
+ * bytes, and PUTs the whole object back. Concurrent `appendFile` calls to
195
+ * the same path can overwrite each other's writes ("last write wins").
196
+ * Use `writeFile` with distinct keys for concurrent writers.
197
+ */
198
+ async appendFile(path, content) {
199
+ const existing = await this.exists(path) ? await this.readFile(path) : Buffer.alloc(0);
200
+ await this.writeFile(path, Buffer.concat([Buffer.isBuffer(existing) ? existing : Buffer.from(existing), Buffer.from(content)]));
201
+ }
202
+ async deleteFile(path, options) {
203
+ await this.ensureReady();
204
+ if (this.readOnly) throw new WorkspaceReadOnlyError("deleteFile");
205
+ try {
206
+ await this._client.request(`/fs/${encodeURIComponent(this._bucketName)}/${encodeKeyPath(keyFromPath(path))}`, {
207
+ method: "DELETE",
208
+ query: { recursive: options?.recursive }
209
+ });
210
+ } catch (error) {
211
+ if (isNotFound(error) && options?.force) return;
212
+ if (isNotFound(error)) throw new FileNotFoundError(path);
213
+ throw error;
214
+ }
215
+ }
216
+ async copyFile(src, dest, options) {
217
+ await this.ensureReady();
218
+ if (this.readOnly) throw new WorkspaceReadOnlyError("copyFile");
219
+ if (options?.overwrite === false) throw new Error("PlatformFilesystem.copyFile does not support overwrite: false — the proxy always overwrites.");
220
+ await this._client.request(`/fs/${encodeURIComponent(this._bucketName)}/${encodeKeyPath(keyFromPath(src))}`, {
221
+ method: "POST",
222
+ query: { op: "copy" },
223
+ headers: { "content-type": "application/json" },
224
+ body: JSON.stringify({ destination: keyFromPath(dest) })
225
+ });
226
+ }
227
+ async moveFile(src, dest, options) {
228
+ await this.ensureReady();
229
+ if (this.readOnly) throw new WorkspaceReadOnlyError("moveFile");
230
+ if (options?.overwrite === false) throw new Error("PlatformFilesystem.moveFile does not support overwrite: false — the proxy always overwrites.");
231
+ await this._client.request(`/fs/${encodeURIComponent(this._bucketName)}/${encodeKeyPath(keyFromPath(src))}`, {
232
+ method: "POST",
233
+ query: { op: "rename" },
234
+ headers: { "content-type": "application/json" },
235
+ body: JSON.stringify({ destination: keyFromPath(dest) })
236
+ });
237
+ }
238
+ async mkdir(path, _options) {
239
+ await this.ensureReady();
240
+ if (this.readOnly) throw new WorkspaceReadOnlyError("mkdir");
241
+ await this._client.request(`/fs/${encodeURIComponent(this._bucketName)}/${encodeKeyPath(keyFromPath(path))}`, {
242
+ method: "POST",
243
+ query: { op: "mkdir" }
244
+ });
245
+ }
246
+ async rmdir(path, options) {
247
+ await this.deleteFile(path.endsWith("/") ? path : `${path}/`, {
248
+ recursive: true,
249
+ force: options?.force
250
+ });
251
+ }
252
+ async readdir(path, options) {
253
+ await this.ensureReady();
254
+ const prefix = keyFromPath(path);
255
+ const json = await (await this._client.request(`/fs/${encodeURIComponent(this._bucketName)}/${encodeKeyPath(prefix)}`, { query: {
256
+ delimiter: options?.recursive ? void 0 : "/",
257
+ prefix: prefix ? `${prefix.replace(/\/$/, "")}/` : void 0
258
+ } })).json();
259
+ return [...(json.commonPrefixes ?? []).map((prefix) => ({
260
+ name: nameFromPath(prefix.replace(/\/$/, "")),
261
+ type: "directory"
262
+ })), ...(json.contents ?? []).filter((object) => object.key && !object.key.endsWith("/")).map((object) => ({
263
+ name: nameFromPath(object.key),
264
+ type: "file",
265
+ size: object.size
266
+ }))].filter((entry) => !options?.extension || entry.type === "directory" || matchesExtension(entry.name, options.extension));
267
+ }
268
+ async exists(path) {
269
+ try {
270
+ await this.stat(path);
271
+ return true;
272
+ } catch (error) {
273
+ if (isNotFound(error) || error instanceof FileNotFoundError) return false;
274
+ throw error;
275
+ }
276
+ }
277
+ async stat(path) {
278
+ await this.ensureReady();
279
+ const normalized = normalizePath(path);
280
+ if (normalized === "/") return {
281
+ name: "",
282
+ path: "/",
283
+ type: "directory",
284
+ size: 0,
285
+ createdAt: /* @__PURE__ */ new Date(0),
286
+ modifiedAt: /* @__PURE__ */ new Date(0)
287
+ };
288
+ let response;
289
+ try {
290
+ response = await this._client.request(`/fs/${encodeURIComponent(this._bucketName)}/${encodeKeyPath(keyFromPath(path))}`, { method: "HEAD" });
291
+ } catch (error) {
292
+ if (isNotFound(error)) throw new FileNotFoundError(path);
293
+ throw error;
294
+ }
295
+ return {
296
+ name: nameFromPath(path),
297
+ path: normalized,
298
+ type: normalized.endsWith("/") ? "directory" : "file",
299
+ size: headerSize(response.headers),
300
+ createdAt: headerDate(response.headers, "last-modified"),
301
+ modifiedAt: headerDate(response.headers, "last-modified"),
302
+ mimeType: response.headers.get("content-type") ?? void 0
303
+ };
304
+ }
305
+ realpath(path) {
306
+ return Promise.resolve(normalizePath(path));
307
+ }
308
+ getInstructions(opts) {
309
+ const defaultInstructions = `Platform filesystem backed by Mastra Platform bucket ${this._bucketName}. Use absolute workspace paths.`;
310
+ if (typeof this._instructionsOverride === "function") return this._instructionsOverride({
311
+ defaultInstructions,
312
+ requestContext: opts?.requestContext
313
+ });
314
+ if (typeof this._instructionsOverride === "string") return this._instructionsOverride;
315
+ return defaultInstructions;
316
+ }
317
+ getInfo() {
318
+ return {
319
+ id: this.id,
320
+ name: this.name,
321
+ provider: this.provider,
322
+ status: this.status,
323
+ readOnly: this.readOnly,
324
+ icon: this.icon,
325
+ metadata: {
326
+ bucketName: this._bucketName,
327
+ ...this.displayName && { displayName: this.displayName },
328
+ ...this.description && { description: this.description }
329
+ }
330
+ };
331
+ }
332
+ };
333
+ function matchesExtension(name, extension) {
334
+ return (Array.isArray(extension) ? extension : [extension]).some((ext) => name.endsWith(ext));
335
+ }
336
+ //#endregion
337
+ //#region src/direct-exec.ts
338
+ /**
339
+ * Direct exec client — opens Railway's tcp-proxy exec WebSocket directly using
340
+ * a short-lived JWT minted by the workspace proxy's exec-lease endpoint. This
341
+ * removes the platform data plane from the exec stdout/stderr path entirely
342
+ * (see `docs/factory/direct-sandbox-connection.md` in the Platform repo),
343
+ * cutting payload-scaled Cloud Run egress and RTT for commands like
344
+ * `pnpm install` that stream tens of MB of output.
345
+ *
346
+ * The frame protocol below mirrors `connectExecWs()` in `railway@3.5.5`
347
+ * (`workspaces/railway/node_modules/railway/dist/index.js`). The `railway`
348
+ * SDK's version is pinned on both sides (platform + here); a version bump
349
+ * signals the protocol may have drifted and this module must be revisited.
350
+ */
351
+ /** Byte-0 tag on binary WS frames for stdout output. */
352
+ const STDOUT_FRAME = 1;
353
+ /** Byte-0 tag on binary WS frames for stderr output. */
354
+ const STDERR_FRAME = 3;
355
+ /**
356
+ * Upper bound on how long we'll wait for the WebSocket to open when the
357
+ * caller didn't supply a `timeoutMs`. Guards against a stalled TLS/WS
358
+ * handshake leaving the promise unresolved forever. Not applied once the
359
+ * socket has opened — a caller with no timeout has opted in to unbounded
360
+ * command runtime, just not to unbounded connection setup.
361
+ */
362
+ const HANDSHAKE_DEADLINE_MS = 3e4;
363
+ const DEFAULT_WS_FACTORY = (endpoint, subprotocols) => {
364
+ const WS = globalThis.WebSocket;
365
+ if (!WS) throw new Error("Direct exec requires a WebSocket implementation. Node 22+ provides one globally; on older runtimes, pass webSocketFactory explicitly.");
366
+ return new WS(endpoint, subprotocols);
367
+ };
368
+ /**
369
+ * Open the provider exec WebSocket using `lease`, run `command`, and resolve
370
+ * with the accumulated stdout/stderr + exit code. See the module docstring
371
+ * for the wire protocol reference.
372
+ *
373
+ * The client sends `stdin_close` immediately after `init_exec`, matching the
374
+ * SDK's own one-shot exec behavior — we never stream stdin from the caller.
375
+ */
376
+ function execViaLease(lease, options) {
377
+ const factory = options.webSocketFactory ?? DEFAULT_WS_FACTORY;
378
+ const stdoutDecoder = new TextDecoder();
379
+ const stderrDecoder = new TextDecoder();
380
+ return new Promise((resolve) => {
381
+ let stdout = "";
382
+ let stderr = "";
383
+ let exitCode = null;
384
+ let timedOut = false;
385
+ let settled = false;
386
+ let opened = false;
387
+ let closeCode;
388
+ let closeReason;
389
+ let timer;
390
+ let handshakeTimer;
391
+ const settle = () => {
392
+ if (settled) return;
393
+ settled = true;
394
+ if (timer) clearTimeout(timer);
395
+ if (handshakeTimer) clearTimeout(handshakeTimer);
396
+ const stdoutTail = stdoutDecoder.decode();
397
+ if (stdoutTail) {
398
+ stdout += stdoutTail;
399
+ options.onStdout?.(stdoutTail);
400
+ }
401
+ const stderrTail = stderrDecoder.decode();
402
+ if (stderrTail) {
403
+ stderr += stderrTail;
404
+ options.onStderr?.(stderrTail);
405
+ }
406
+ try {
407
+ socket.close(1e3, "");
408
+ } catch {}
409
+ resolve({
410
+ exitCode,
411
+ stdout,
412
+ stderr,
413
+ truncated: false,
414
+ timedOut,
415
+ ...closeCode !== void 0 && { closeCode },
416
+ ...closeReason !== void 0 && { closeReason },
417
+ opened
418
+ });
419
+ };
420
+ if (options.timeoutMs !== void 0 && options.timeoutMs > 0) timer = setTimeout(() => {
421
+ timedOut = true;
422
+ if (exitCode === null) exitCode = 124;
423
+ settle();
424
+ }, options.timeoutMs);
425
+ else handshakeTimer = setTimeout(() => {
426
+ if (!opened) settle();
427
+ }, HANDSHAKE_DEADLINE_MS);
428
+ const socket = factory(lease.wsEndpoint, [lease.subprotocol, lease.jwt]);
429
+ socket.binaryType = "arraybuffer";
430
+ socket.onopen = () => {
431
+ opened = true;
432
+ if (handshakeTimer) {
433
+ clearTimeout(handshakeTimer);
434
+ handshakeTimer = void 0;
435
+ }
436
+ const data = { command: options.command };
437
+ if (options.cwd) data.cwd = options.cwd;
438
+ if (options.env && Object.keys(options.env).length > 0) data.env = options.env;
439
+ socket.send(JSON.stringify({
440
+ type: "init_exec",
441
+ data
442
+ }));
443
+ socket.send(JSON.stringify({ type: "stdin_close" }));
444
+ };
445
+ socket.onmessage = (event) => {
446
+ const { data } = event;
447
+ if (data instanceof ArrayBuffer) handleBinaryFrame(data);
448
+ else if (typeof data === "string") handleTextFrame(data);
449
+ };
450
+ socket.onclose = (event) => {
451
+ closeCode = event.code;
452
+ closeReason = event.reason;
453
+ if (!opened) {
454
+ settle();
455
+ return;
456
+ }
457
+ settle();
458
+ };
459
+ socket.onerror = () => {
460
+ if (settled) return;
461
+ if (!opened) settle();
462
+ };
463
+ function handleBinaryFrame(buffer) {
464
+ const view = new Uint8Array(buffer);
465
+ if (view.length <= 1) return;
466
+ if (view[0] === STDOUT_FRAME) {
467
+ const chunk = stdoutDecoder.decode(view.subarray(1), { stream: true });
468
+ stdout += chunk;
469
+ options.onStdout?.(chunk);
470
+ } else if (view[0] === STDERR_FRAME) {
471
+ const chunk = stderrDecoder.decode(view.subarray(1), { stream: true });
472
+ stderr += chunk;
473
+ options.onStderr?.(chunk);
474
+ }
475
+ }
476
+ function handleTextFrame(text) {
477
+ let frame;
478
+ try {
479
+ frame = JSON.parse(text);
480
+ } catch {
481
+ return;
482
+ }
483
+ if (frame.type === "exit") {
484
+ exitCode = frame.data?.exit_code ?? 0;
485
+ settle();
486
+ }
487
+ }
488
+ });
489
+ }
490
+ //#endregion
491
+ //#region src/private-net-exec.ts
492
+ /**
493
+ * Thrown by {@link execViaPrivateNetwork} when the sidecar returns a non-2xx
494
+ * HTTP response. This is an *application* error, not a transport error — the
495
+ * sidecar is reachable and answered, it just refused the exec. Callers should
496
+ * fall back to the lease path for this one call but MUST NOT invalidate the
497
+ * cached `instanceUrl` (the address is still good).
498
+ */
499
+ var PrivateNetExecHttpError = class extends Error {
500
+ status;
501
+ body;
502
+ constructor(status, body) {
503
+ super(`Sidecar /exec returned ${status}${body ? `: ${body.slice(0, 200)}` : ""}`);
504
+ this.name = "PrivateNetExecHttpError";
505
+ this.status = status;
506
+ this.body = body;
507
+ }
508
+ };
509
+ const DEFAULT_FETCH = (input, init) => {
510
+ const f = globalThis.fetch;
511
+ if (!f) throw new Error("Private-network exec requires a fetch implementation. Node 22+ provides one globally; on older runtimes, pass fetch explicitly.");
512
+ return f(input, init);
513
+ };
514
+ /**
515
+ * Dial `${instanceUrl}/exec` and stream the response, resolving with the
516
+ * accumulated stdout/stderr + exit code.
517
+ *
518
+ * Errors:
519
+ * - Connection failure (DNS, refused, reset) → resolves with
520
+ * `{opened:false, exitCode:null, transportErrorMessage}`. Never throws for
521
+ * transport failures — the shape matches the lease-path result so the
522
+ * caller can treat both transports uniformly.
523
+ * - Non-2xx HTTP response from the sidecar → throws {@link PrivateNetExecHttpError}.
524
+ * Application-level; caller decides whether to fall back.
525
+ * - Stream ends without an `exit` frame → resolves with
526
+ * `{opened:true, exitCode:null}`, matching the lease-path semantics for a
527
+ * mid-stream drop.
528
+ * - `timeoutMs` elapsed → aborts the request, resolves with
529
+ * `{timedOut:true, exitCode:124}`.
530
+ */
531
+ async function execViaPrivateNetwork(instanceUrl, options) {
532
+ const fetchImpl = options.fetch ?? DEFAULT_FETCH;
533
+ const url = `${instanceUrl.replace(/\/$/, "")}/exec`;
534
+ const controller = new AbortController();
535
+ let timedOut = false;
536
+ let timeoutTimer;
537
+ if (options.timeoutMs !== void 0 && options.timeoutMs > 0) timeoutTimer = setTimeout(() => {
538
+ timedOut = true;
539
+ controller.abort();
540
+ }, options.timeoutMs);
541
+ const body = { command: options.command };
542
+ if (options.cwd) body.cwd = options.cwd;
543
+ if (options.env && Object.keys(options.env).length > 0) body.env = options.env;
544
+ if (options.timeoutMs !== void 0 && options.timeoutMs > 0) body.timeoutMs = options.timeoutMs;
545
+ const headers = { "content-type": "application/json" };
546
+ if (options.bearerToken) headers.authorization = `Bearer ${options.bearerToken}`;
547
+ let response;
548
+ try {
549
+ response = await fetchImpl(url, {
550
+ method: "POST",
551
+ headers,
552
+ body: JSON.stringify(body),
553
+ signal: controller.signal
554
+ });
555
+ } catch (error) {
556
+ if (timeoutTimer) clearTimeout(timeoutTimer);
557
+ if (timedOut) return {
558
+ exitCode: 124,
559
+ stdout: "",
560
+ stderr: "",
561
+ timedOut: true,
562
+ opened: false
563
+ };
564
+ return {
565
+ exitCode: null,
566
+ stdout: "",
567
+ stderr: "",
568
+ timedOut: false,
569
+ opened: false,
570
+ transportErrorMessage: error instanceof Error ? error.message : String(error)
571
+ };
572
+ }
573
+ if (!response.ok) {
574
+ if (timeoutTimer) clearTimeout(timeoutTimer);
575
+ const text = await response.text().catch(() => "");
576
+ throw new PrivateNetExecHttpError(response.status, text);
577
+ }
578
+ if (!response.body) {
579
+ if (timeoutTimer) clearTimeout(timeoutTimer);
580
+ return {
581
+ exitCode: null,
582
+ stdout: "",
583
+ stderr: "",
584
+ timedOut: false,
585
+ opened: true,
586
+ status: response.status
587
+ };
588
+ }
589
+ let stdout = "";
590
+ let stderr = "";
591
+ let exitCode = null;
592
+ const decoder = new TextDecoder();
593
+ let buffer = "";
594
+ const handleLine = (line) => {
595
+ if (!line) return;
596
+ let frame;
597
+ try {
598
+ frame = JSON.parse(line);
599
+ } catch {
600
+ return;
601
+ }
602
+ if (!frame || typeof frame !== "object") return;
603
+ if (frame.type === "stdout" && typeof frame.data === "string") {
604
+ stdout += frame.data;
605
+ options.onStdout?.(frame.data);
606
+ } else if (frame.type === "stderr" && typeof frame.data === "string") {
607
+ stderr += frame.data;
608
+ options.onStderr?.(frame.data);
609
+ } else if (frame.type === "exit" && typeof frame.code === "number") exitCode = frame.code;
610
+ };
611
+ try {
612
+ const reader = response.body.getReader();
613
+ while (true) {
614
+ const { value, done } = await reader.read();
615
+ if (done) break;
616
+ buffer += decoder.decode(value, { stream: true });
617
+ let newlineIdx = buffer.indexOf("\n");
618
+ while (newlineIdx !== -1) {
619
+ const line = buffer.slice(0, newlineIdx).trim();
620
+ buffer = buffer.slice(newlineIdx + 1);
621
+ handleLine(line);
622
+ newlineIdx = buffer.indexOf("\n");
623
+ }
624
+ }
625
+ buffer += decoder.decode();
626
+ const trailing = buffer.trim();
627
+ if (trailing) handleLine(trailing);
628
+ } catch (error) {
629
+ if (timeoutTimer) clearTimeout(timeoutTimer);
630
+ if (timedOut) return {
631
+ exitCode: 124,
632
+ stdout,
633
+ stderr,
634
+ timedOut: true,
635
+ opened: true,
636
+ status: response.status
637
+ };
638
+ return {
639
+ exitCode,
640
+ stdout,
641
+ stderr,
642
+ timedOut: false,
643
+ opened: true,
644
+ status: response.status,
645
+ transportErrorMessage: error instanceof Error ? error.message : String(error)
646
+ };
647
+ } finally {
648
+ if (timeoutTimer) clearTimeout(timeoutTimer);
649
+ }
650
+ return {
651
+ exitCode,
652
+ stdout,
653
+ stderr,
654
+ timedOut: false,
655
+ opened: true,
656
+ status: response.status
657
+ };
658
+ }
659
+ //#endregion
660
+ //#region src/sandbox.ts
661
+ /**
662
+ * How long before a lease's stated `expiresAt` we should treat it as
663
+ * expired. Avoids a race where the JWT is valid at cache-hit time but the
664
+ * server rejects it by the time the WebSocket handshake completes.
665
+ */
666
+ const LEASE_REFRESH_MARGIN_MS = 6e4;
667
+ /** Max attempts for `POST /sandbox` when the proxy returns transient 5xx errors. */
668
+ const CREATE_MAX_ATTEMPTS = 3;
669
+ /** Base delay between create retries; multiplied by the attempt number. */
670
+ const CREATE_RETRY_BASE_DELAY_MS = 2e3;
671
+ /**
672
+ * Diagnostic error thrown when the direct-exec WebSocket transport fails
673
+ * twice in a row (opening handshake refused or socket closed mid-stream
674
+ * without an `exit` frame). Distinguishes "the sandbox transport is broken"
675
+ * from "your command failed" so callers can decide whether to retry at a
676
+ * higher level (e.g. reprovision the sandbox) or surface the error.
677
+ *
678
+ * `opened` is `true` when the WebSocket completed its handshake at least
679
+ * once before closing; `false` when Railway refused the upgrade outright.
680
+ */
681
+ var SandboxExecTransportError = class extends Error {
682
+ sandboxId;
683
+ command;
684
+ attempts;
685
+ opened;
686
+ closeCode;
687
+ closeReason;
688
+ wsEndpoint;
689
+ constructor(message, diagnostics) {
690
+ super(message);
691
+ this.name = "SandboxExecTransportError";
692
+ this.sandboxId = diagnostics.sandboxId;
693
+ this.command = diagnostics.command;
694
+ this.attempts = diagnostics.attempts;
695
+ this.opened = diagnostics.opened;
696
+ this.closeCode = diagnostics.closeCode;
697
+ this.closeReason = diagnostics.closeReason;
698
+ this.wsEndpoint = diagnostics.wsEndpoint;
699
+ }
700
+ };
701
+ /**
702
+ * Thrown when `/exec-lease` returns 410 Gone — the sandbox has been destroyed
703
+ * (Railway destroy, quota reclamation, etc.). The client cannot recover from
704
+ * this on its own because it does not own the binding store; only the fleet
705
+ * layer can clear the stale sandbox id and provision a fresh one. Callers
706
+ * (typically `SandboxFleet`) must catch this and reprovision-and-replay.
707
+ *
708
+ * When this is thrown the cached `_lease` and `_sandboxId` on the sandbox
709
+ * instance are cleared, so the next `ensureRunning()` on a reused instance
710
+ * will re-provision cleanly.
711
+ */
712
+ var SandboxDestroyedError = class extends Error {
713
+ sandboxId;
714
+ command;
715
+ attempts;
716
+ constructor(message, diagnostics) {
717
+ super(message);
718
+ this.name = "SandboxDestroyedError";
719
+ this.sandboxId = diagnostics.sandboxId;
720
+ this.command = diagnostics.command;
721
+ this.attempts = diagnostics.attempts;
722
+ }
723
+ };
724
+ /**
725
+ * Compose a shell command line from a `command` string and optional `args`.
726
+ *
727
+ * IMPORTANT: `command` is treated as a **shell string** and passed to the
728
+ * remote shell verbatim so callers can use pipes, redirects, and chaining
729
+ * (`ls -la | grep foo`). This matches the contract of {@link MastraSandbox}
730
+ * and the local sandbox implementation. `args` are always shell-quoted so
731
+ * they cannot inject syntax.
732
+ *
733
+ * Callers MUST NOT pass untrusted input as `command`. Untrusted values must
734
+ * be passed via `args`, where they are safely quoted. Passing untrusted
735
+ * input as `command` allows arbitrary shell syntax execution on the remote
736
+ * sandbox.
737
+ */
738
+ function buildCommand(command, args) {
739
+ return args?.length ? `${command} ${args.map(shellQuote).join(" ")}` : command;
740
+ }
741
+ function shellQuote(arg) {
742
+ if (/^[a-zA-Z0-9._\-/=:@]+$/.test(arg)) return arg;
743
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
744
+ }
745
+ var PlatformProcessHandle = class extends ProcessHandle {
746
+ pid;
747
+ resultPromise;
748
+ exitCodeValue;
749
+ constructor(pid, resultPromise, options) {
750
+ super(options);
751
+ this.pid = pid;
752
+ this.resultPromise = resultPromise.then((result) => {
753
+ this.exitCodeValue = result.exitCode;
754
+ if (result.stdout) this.emitStdout(result.stdout);
755
+ if (result.stderr) this.emitStderr(result.stderr);
756
+ return result;
757
+ });
758
+ }
759
+ get exitCode() {
760
+ return this.exitCodeValue;
761
+ }
762
+ async wait() {
763
+ return this.resultPromise;
764
+ }
765
+ async kill() {
766
+ throw new Error("Platform sandbox command execution does not support killing individual processes");
767
+ }
768
+ async sendStdin() {
769
+ throw new Error("Platform sandbox command execution does not support stdin");
770
+ }
771
+ };
772
+ var PlatformProcessManager = class extends SandboxProcessManager {
773
+ spawnCounter = 0;
774
+ /**
775
+ * Spawn a process on the remote sandbox.
776
+ *
777
+ * `command` is interpreted as a shell string by the remote shell, matching
778
+ * the {@link MastraSandbox} contract. See {@link PlatformSandbox.executeCommand}
779
+ * for the untrusted-input caveat: never pass untrusted values as `command`.
780
+ */
781
+ async spawn(command, options = {}) {
782
+ const handle = new PlatformProcessHandle(`platform-proc-${Date.now().toString(36)}-${(this.spawnCounter++).toString(36)}`, this.sandbox.executeCommand(command, void 0, options), options);
783
+ this._tracked.set(handle.pid, handle);
784
+ return handle;
785
+ }
786
+ async list() {
787
+ return Array.from(this._tracked.values()).map((handle) => ({
788
+ pid: handle.pid,
789
+ command: handle.command,
790
+ running: handle.exitCode === void 0,
791
+ ...handle.exitCode !== void 0 && { exitCode: handle.exitCode }
792
+ }));
793
+ }
794
+ };
795
+ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
796
+ id;
797
+ name = "PlatformSandbox";
798
+ provider = "platform";
799
+ status = "pending";
800
+ _client;
801
+ _environmentId;
802
+ _sandboxId;
803
+ _idleTimeoutMinutes;
804
+ _networkIsolation;
805
+ _env;
806
+ _timeout;
807
+ _instructionsOverride;
808
+ _createdAt = null;
809
+ _webSocketFactory;
810
+ _privateNetFetch;
811
+ /**
812
+ * Registry that maps `sandboxId → instanceUrl` for the private-network
813
+ * exec path. Injected by the composition site via
814
+ * {@link PlatformSandboxOptions.addressRegistry} and populated by this
815
+ * class itself in `start()` when the workspace-proxy's create/reattach
816
+ * response includes an `instanceUrl` field. The registry IS the cache —
817
+ * there is no per-instance mirror on `PlatformSandbox`, so every exec is
818
+ * a `Map.get()` (in the default in-process impl) against the live view.
819
+ * When absent, executes go straight to the lease path with no extra
820
+ * round-trip.
821
+ */
822
+ _addressRegistry;
823
+ /**
824
+ * Cached exec lease for this sandbox. `null` before the first exec and
825
+ * after {@link destroy}. Refreshed when `expiresAt - LEASE_REFRESH_MARGIN_MS < now`
826
+ * (see {@link _ensureLease}); a lease without a disclosed `expiresAt`
827
+ * is refreshed on every call.
828
+ */
829
+ _lease = null;
830
+ /**
831
+ * In-flight mint request; concurrent `_ensureLease` callers on a cold or
832
+ * near-expiry cache all await this single promise so we don't burn N
833
+ * `POST /exec-lease` round-trips when the sandbox is doing N parallel execs.
834
+ * Cleared (regardless of success or failure) when the request settles.
835
+ */
836
+ _leaseInFlight = null;
837
+ /**
838
+ * True when this sandbox was constructed with a caller-supplied `id` (the
839
+ * recovery key the proxy hashes into an on-provider checkpoint name).
840
+ * `captureCheckpoint()` needs this to distinguish "no checkpoint intent"
841
+ * (auto-generated random id — capture would land under a name no future
842
+ * boot would look for) from "capture on demand". Cloned sandboxes route
843
+ * `checkpointName` through `id`, so both entry points set this the same
844
+ * way.
845
+ */
846
+ _hasRecoveryKey;
847
+ /**
848
+ * In-flight `captureCheckpoint()` request. Concurrent callers on the same
849
+ * instance coalesce onto this single promise so we don't burn N `POST
850
+ * /checkpoint` round-trips when the fleet fires several turn-end captures
851
+ * before the first one resolves. Cleared when the request settles.
852
+ */
853
+ _captureInFlight = null;
854
+ constructor(options = {}) {
855
+ super({
856
+ ...options,
857
+ name: "PlatformSandbox",
858
+ processes: new PlatformProcessManager()
859
+ });
860
+ this._hasRecoveryKey = options.id !== void 0;
861
+ this.id = options.id ?? this.generateId();
862
+ this._client = new PlatformClient(options);
863
+ this._environmentId = options.environmentId ?? process.env.MASTRA_ENVIRONMENT_ID ?? "";
864
+ if (!this._environmentId && !options.sandboxId) throw new Error("environmentId is required");
865
+ this._sandboxId = options.sandboxId;
866
+ this._idleTimeoutMinutes = options.idleTimeoutMinutes;
867
+ this._networkIsolation = options.networkIsolation;
868
+ this._env = options.env ?? {};
869
+ this._timeout = options.timeout;
870
+ this._instructionsOverride = options.instructions;
871
+ this._webSocketFactory = options.webSocketFactory;
872
+ this._privateNetFetch = options.privateNetFetch;
873
+ this._addressRegistry = options.addressRegistry;
874
+ }
875
+ generateId() {
876
+ return `platform-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
877
+ }
878
+ /**
879
+ * Construct a sibling {@link PlatformSandbox} that inherits this sandbox's
880
+ * credentials and defaults (access token, project, environment, network
881
+ * isolation, timeout, instructions, env, idle timeout) with per-instance
882
+ * overrides from `options`.
883
+ *
884
+ * Performs no I/O and does not require this sandbox to be started — the
885
+ * returned sandbox is not started and provisions (or reattaches, when
886
+ * `sandboxId` is set) on its own `start()`. Use it when one configured
887
+ * sandbox acts as the template for a fleet of independent sandboxes
888
+ * (e.g. one per project).
889
+ */
890
+ clone(options = {}) {
891
+ const id = options.id ?? options.checkpointName;
892
+ return new PlatformSandbox({
893
+ ...id !== void 0 && { id },
894
+ accessToken: this._client.accessToken,
895
+ projectId: this._client.projectId,
896
+ fetch: this._client.fetch,
897
+ environmentId: this._environmentId,
898
+ ...options.sandboxId !== void 0 && { sandboxId: options.sandboxId },
899
+ idleTimeoutMinutes: options.idleTimeoutMinutes ?? this._idleTimeoutMinutes,
900
+ ...this._networkIsolation !== void 0 && { networkIsolation: this._networkIsolation },
901
+ env: options.env ?? this._env,
902
+ ...this._timeout !== void 0 && { timeout: this._timeout },
903
+ ...this._instructionsOverride !== void 0 && { instructions: this._instructionsOverride },
904
+ ...this._webSocketFactory !== void 0 && { webSocketFactory: this._webSocketFactory },
905
+ ...this._privateNetFetch !== void 0 && { privateNetFetch: this._privateNetFetch },
906
+ ...this._addressRegistry !== void 0 && { addressRegistry: this._addressRegistry }
907
+ });
908
+ }
909
+ async start() {
910
+ if (this._sandboxId) try {
911
+ const json = await (await this._client.request(`/sandbox/${encodeURIComponent(this._sandboxId)}`)).json();
912
+ if (!json.destroyedAt) {
913
+ this._createdAt = json.createdAt ? new Date(json.createdAt) : /* @__PURE__ */ new Date();
914
+ this._populateAddressFromResponse(json);
915
+ return;
916
+ }
917
+ this._sandboxId = void 0;
918
+ } catch (error) {
919
+ if (!(error instanceof PlatformApiError) || error.status !== 404) throw error;
920
+ this._sandboxId = void 0;
921
+ }
922
+ if (!this._environmentId) throw new Error("environmentId is required");
923
+ const body = JSON.stringify({
924
+ id: this.id,
925
+ environmentId: this._environmentId,
926
+ idleTimeoutMinutes: this._idleTimeoutMinutes,
927
+ networkIsolation: this._networkIsolation,
928
+ env: this._env
929
+ });
930
+ let response;
931
+ for (let attempt = 1;; attempt++) try {
932
+ response = await this._client.request("/sandbox", {
933
+ method: "POST",
934
+ headers: { "content-type": "application/json" },
935
+ body
936
+ });
937
+ break;
938
+ } catch (error) {
939
+ if (!(error instanceof PlatformApiError && error.status >= 500) || attempt >= CREATE_MAX_ATTEMPTS) throw error;
940
+ await new Promise((resolve) => setTimeout(resolve, CREATE_RETRY_BASE_DELAY_MS * attempt));
941
+ }
942
+ const json = await response.json();
943
+ this._sandboxId = json.id;
944
+ this._createdAt = json.createdAt ? new Date(json.createdAt) : /* @__PURE__ */ new Date();
945
+ this._populateAddressFromResponse(json);
946
+ }
947
+ /**
948
+ * Copy `response.instanceUrl` into the injected {@link SandboxAddressRegistry}
949
+ * when both are present. Called from both {@link start} branches (fresh
950
+ * provision + reattach) with the workspace-proxy response for this sandbox.
951
+ *
952
+ * The proxy discovers the IPv6 during `Sandbox.create()` and stores it in
953
+ * `environment_sandboxes.instance_url`; both the create response and
954
+ * `GET /sandbox/:id` echo the same field. The runtime does not do any
955
+ * discovery of its own — it only mirrors the field into an in-process map
956
+ * so {@link executeCommand} can `Map.get()` before every exec without an
957
+ * HTTP round-trip.
958
+ *
959
+ * `null`/absent `instanceUrl` (proxy discovery failed, or an older proxy
960
+ * that predates the field) leaves the registry untouched — executes fall
961
+ * through to the lease path with no branch here.
962
+ */
963
+ _populateAddressFromResponse(json) {
964
+ if (!this._addressRegistry) return;
965
+ if (!json.instanceUrl) return;
966
+ this._addressRegistry.set(json.id, json.instanceUrl);
967
+ }
968
+ async stop() {
969
+ await this.destroy();
970
+ }
971
+ async destroy() {
972
+ if (!this._sandboxId) return;
973
+ const destroyedSandboxId = this._sandboxId;
974
+ await this._client.request(`/sandbox/${encodeURIComponent(destroyedSandboxId)}`, { method: "DELETE" });
975
+ this._sandboxId = void 0;
976
+ this._createdAt = null;
977
+ this._lease = null;
978
+ this._addressRegistry?.delete(destroyedSandboxId);
979
+ }
980
+ /**
981
+ * Capture the sandbox's checkpoint on demand, outside any refresh timer the
982
+ * workspace-proxy owns internally.
983
+ *
984
+ * Intended for callers (e.g. a factory-side scheduler) that want to refresh
985
+ * the recovery checkpoint at semantic moments — turn end, session-idle,
986
+ * pre-teardown — rather than only just before the upstream's idle destroy.
987
+ *
988
+ * Mirrors the OSS `@mastra/railway` `RailwaySandbox.captureCheckpoint()`
989
+ * shape so factory can call `sandbox.captureCheckpoint()` uniformly and
990
+ * branch on `status`/`reason` without knowing which provider is underneath.
991
+ * Both `captured` and `coalesced` carry the checkpoint name inline so the
992
+ * caller can persist a session→checkpoint binding atomically with the
993
+ * awaited capture.
994
+ *
995
+ * Skip semantics:
996
+ * - No caller-supplied `id`: returns `{ status: 'skipped', reason:
997
+ * 'no-checkpoint-name-configured' }`. An auto-generated random id is
998
+ * never a meaningful recovery key (no future boot would look for a
999
+ * checkpoint under it), so capturing would silently produce dead data.
1000
+ * - Not started (no `_sandboxId`): returns `{ status: 'skipped', reason:
1001
+ * 'sandbox-not-running' }` without a round-trip.
1002
+ * - Upstream 410 (workspace-proxy or Railway reports the sandbox is
1003
+ * already destroyed): returns the same `sandbox-not-running` skip so
1004
+ * the discriminant matches the pre-flight case. Local state
1005
+ * (`_sandboxId`, `_lease`, sidecar address) is cleared as a side
1006
+ * effect so the next `start()` provisions fresh instead of reattaching
1007
+ * to a dead id. The diagnostic distinction (pre-flight vs post-hoc)
1008
+ * is preserved in log level: debug for the expected pre-flight skip,
1009
+ * warn for the surprise upstream destroy.
1010
+ *
1011
+ * Concurrent callers on the same instance coalesce onto a single in-flight
1012
+ * `POST /checkpoint` so N simultaneous turn-end fires (e.g. several tabs)
1013
+ * do not each round-trip the proxy. Both the originator and joiners
1014
+ * receive `{ status: 'coalesced', ... }` for the joined result — the
1015
+ * outer contract does not distinguish who started the request, only that
1016
+ * one upstream capture was made.
1017
+ *
1018
+ * Never throws for expected outcomes. Transport failures (5xx, 4xx other
1019
+ * than 410) propagate as {@link PlatformApiError}; a 410 is normalized
1020
+ * to a skip as described above.
1021
+ */
1022
+ async captureCheckpoint() {
1023
+ if (!this._hasRecoveryKey) {
1024
+ this.logger.debug(`captureCheckpoint skipped: no recovery key configured for sandbox ${this._sandboxId ?? "(unstarted)"}`);
1025
+ return {
1026
+ status: "skipped",
1027
+ reason: "no-checkpoint-name-configured"
1028
+ };
1029
+ }
1030
+ if (!this._sandboxId) {
1031
+ this.logger.debug(`captureCheckpoint skipped: sandbox not running (local pre-flight, id=${this.id})`);
1032
+ return {
1033
+ status: "skipped",
1034
+ reason: "sandbox-not-running"
1035
+ };
1036
+ }
1037
+ if (this._captureInFlight) return this._captureInFlight;
1038
+ const sandboxId = this._sandboxId;
1039
+ const capture = this._doCaptureCheckpoint(sandboxId).finally(() => {
1040
+ if (this._captureInFlight === capture) this._captureInFlight = null;
1041
+ });
1042
+ this._captureInFlight = capture;
1043
+ return capture;
1044
+ }
1045
+ /**
1046
+ * The single `POST /checkpoint` attempt behind {@link captureCheckpoint}.
1047
+ *
1048
+ * Split out so the coalescing wrapper can install a shared in-flight
1049
+ * promise without inlining the transport + response-mapping logic.
1050
+ * Joined callers observe `{ status: 'coalesced', ... }` — the initiator
1051
+ * sees the underlying `captured` / `coalesced` / `skipped` result the
1052
+ * proxy returned. Both are legitimate: the OSS mirror uses the same
1053
+ * "initiator sees the truth, joiners see coalesced" split.
1054
+ */
1055
+ async _doCaptureCheckpoint(sandboxId) {
1056
+ let response;
1057
+ try {
1058
+ response = await this._client.request(`/sandbox/${encodeURIComponent(sandboxId)}/checkpoint`, {
1059
+ method: "POST",
1060
+ headers: { "content-type": "application/json" },
1061
+ body: JSON.stringify({ id: this.id })
1062
+ });
1063
+ } catch (error) {
1064
+ if (error instanceof PlatformApiError && error.status === 410) {
1065
+ this.logger.warn(`captureCheckpoint skipped: sandbox destroyed upstream (proxy 410, sandboxId=${sandboxId})`);
1066
+ this._clearDestroyedState(sandboxId);
1067
+ return {
1068
+ status: "skipped",
1069
+ reason: "sandbox-not-running"
1070
+ };
1071
+ }
1072
+ throw error;
1073
+ }
1074
+ const json = await response.json();
1075
+ if (json.status === "skipped") {
1076
+ this.logger.warn(`captureCheckpoint skipped: sandbox destroyed upstream (proxy reported skipped, sandboxId=${sandboxId})`);
1077
+ this._clearDestroyedState(sandboxId);
1078
+ return {
1079
+ status: "skipped",
1080
+ reason: "sandbox-not-running"
1081
+ };
1082
+ }
1083
+ return {
1084
+ status: json.status,
1085
+ checkpointName: json.checkpointName
1086
+ };
1087
+ }
1088
+ /**
1089
+ * Clear local state that would otherwise let the caller keep exec'ing
1090
+ * against a sandbox the upstream has already destroyed. Mirrors what
1091
+ * `destroy()` does minus the outbound DELETE — the sandbox is already
1092
+ * gone, so all that remains is to stop pointing at it.
1093
+ *
1094
+ * Also resets `status` to `'pending'` so a subsequent `_start()` on this
1095
+ * reused instance re-runs provisioning instead of short-circuiting on
1096
+ * the cached `'running'` state (see `MastraSandbox._start`).
1097
+ */
1098
+ _clearDestroyedState(destroyedSandboxId) {
1099
+ this._sandboxId = void 0;
1100
+ this._createdAt = null;
1101
+ this._lease = null;
1102
+ this._addressRegistry?.delete(destroyedSandboxId);
1103
+ this.status = "pending";
1104
+ }
1105
+ /**
1106
+ * Execute a command on the remote sandbox.
1107
+ *
1108
+ * `command` is a **shell string**: it is concatenated verbatim into the
1109
+ * command line sent to the remote shell, which lets callers use pipes,
1110
+ * redirects, and chaining (`ls -la | grep foo`). This matches the contract
1111
+ * of {@link MastraSandbox} and the local sandbox implementation.
1112
+ *
1113
+ * `args`, when provided, are always shell-quoted so they cannot inject
1114
+ * additional shell syntax.
1115
+ *
1116
+ * Security: callers MUST NOT pass untrusted input as `command`. If any part
1117
+ * of the invocation is derived from an untrusted source, pass it through
1118
+ * `args` (which is safely quoted) or shell-quote it yourself before
1119
+ * inclusion. Untrusted `command` values allow arbitrary shell syntax
1120
+ * execution on the remote sandbox.
1121
+ */
1122
+ async executeCommand(command, args, options) {
1123
+ await this.ensureRunning();
1124
+ if (!this._sandboxId) throw new SandboxNotReadyError(this.id);
1125
+ const started = Date.now();
1126
+ const fullCommand = buildCommand(command, args);
1127
+ const effectiveTimeout = options?.timeout ?? this._timeout;
1128
+ const instanceUrl = this._addressRegistry?.get(this._sandboxId);
1129
+ if (instanceUrl) {
1130
+ const privateNet = await this._tryExecViaPrivateNetwork(instanceUrl, fullCommand, effectiveTimeout, options);
1131
+ if (privateNet) {
1132
+ const privateExit = privateNet.exitCode ?? 124;
1133
+ return {
1134
+ success: privateExit === 0,
1135
+ exitCode: privateExit,
1136
+ stdout: privateNet.stdout,
1137
+ stderr: privateNet.stderr,
1138
+ timedOut: privateNet.timedOut,
1139
+ command: fullCommand,
1140
+ executionTimeMs: Date.now() - started
1141
+ };
1142
+ }
1143
+ }
1144
+ const result = await this._runDirectExec(fullCommand, effectiveTimeout, options);
1145
+ const exitCode = result.exitCode ?? 124;
1146
+ return {
1147
+ success: exitCode === 0,
1148
+ exitCode,
1149
+ stdout: result.stdout,
1150
+ stderr: result.stderr,
1151
+ timedOut: result.timedOut,
1152
+ command: fullCommand,
1153
+ executionTimeMs: Date.now() - started
1154
+ };
1155
+ }
1156
+ /**
1157
+ * Run a single exec against the direct-exec transport, with one in-flight
1158
+ * retry on WebSocket transport failure (socket closed without an `exit`
1159
+ * frame and the exec did not time out). The retry mints a fresh lease
1160
+ * — the failure could be a stale JWT — and reopens a new WebSocket.
1161
+ *
1162
+ * Error taxonomy:
1163
+ * - **410 on `/exec-lease`** (either attempt) → the sandbox is gone.
1164
+ * Nulls the cached `_lease` and `_sandboxId` and throws
1165
+ * {@link SandboxDestroyedError}. Callers (typically `SandboxFleet`) must
1166
+ * catch this, clear the stale binding, and reprovision + replay.
1167
+ * - **Persistent transport failure** (both WS attempts close without an
1168
+ * `exit` frame against a live sandbox) → {@link SandboxExecTransportError}
1169
+ * with WebSocket close diagnostics.
1170
+ * - **Other `PlatformApiError`s** (404/500/501) propagate directly.
1171
+ * - **Real command result** (exit code from Railway's exit frame, or
1172
+ * `timedOut: true`) returns normally.
1173
+ *
1174
+ * Returns a result with a real `exitCode` OR `timedOut: true`. Never
1175
+ * returns `{ exitCode: null, timedOut: false }` — that case throws.
1176
+ */
1177
+ async _runDirectExec(fullCommand, effectiveTimeout, options) {
1178
+ const filteredEnv = options?.env ? Object.fromEntries(Object.entries(options.env).filter((entry) => entry[1] !== void 0)) : void 0;
1179
+ let lastResult;
1180
+ let lastLease;
1181
+ let attemptsMade = 0;
1182
+ for (let attempt = 0; attempt < 2; attempt++) {
1183
+ if (attempt > 0 && lastLease && this._lease === lastLease) this._lease = null;
1184
+ let lease;
1185
+ try {
1186
+ lease = await this._ensureLease();
1187
+ } catch (error) {
1188
+ if (error instanceof PlatformApiError && error.status === 410) {
1189
+ this._lease = null;
1190
+ const priorSandboxId = this._sandboxId;
1191
+ this._sandboxId = void 0;
1192
+ throw new SandboxDestroyedError(`Sandbox ${priorSandboxId ?? "(unknown)"} was destroyed; /exec-lease returned 410`, {
1193
+ ...priorSandboxId && { sandboxId: priorSandboxId },
1194
+ command: fullCommand,
1195
+ attempts: attempt + 1
1196
+ });
1197
+ }
1198
+ throw error;
1199
+ }
1200
+ lastLease = lease;
1201
+ attemptsMade = attempt + 1;
1202
+ const result = await execViaLease(lease, {
1203
+ command: fullCommand,
1204
+ ...options?.cwd !== void 0 && { cwd: options.cwd },
1205
+ ...filteredEnv !== void 0 && { env: filteredEnv },
1206
+ ...effectiveTimeout != null && effectiveTimeout > 0 && { timeoutMs: effectiveTimeout },
1207
+ ...this._webSocketFactory && { webSocketFactory: this._webSocketFactory }
1208
+ });
1209
+ lastResult = result;
1210
+ if (result.exitCode !== null || result.timedOut) return result;
1211
+ }
1212
+ const result = lastResult;
1213
+ const lease = lastLease;
1214
+ if (this._lease === lease) this._lease = null;
1215
+ throw new SandboxExecTransportError(`Direct-exec transport failed for sandbox ${this._sandboxId ?? "(unknown)"} after ${attemptsMade} attempt(s)` + (result.closeCode !== void 0 ? ` (close ${result.closeCode}${result.closeReason ? ` ${result.closeReason}` : ""})` : ""), {
1216
+ ...this._sandboxId && { sandboxId: this._sandboxId },
1217
+ command: fullCommand,
1218
+ attempts: attemptsMade,
1219
+ opened: result.opened ?? false,
1220
+ ...result.closeCode !== void 0 && { closeCode: result.closeCode },
1221
+ ...result.closeReason !== void 0 && { closeReason: result.closeReason },
1222
+ wsEndpoint: lease.wsEndpoint
1223
+ });
1224
+ }
1225
+ /**
1226
+ * Try to run the exec against the in-sandbox sidecar over Railway's private
1227
+ * network. Returns the result on success (including non-zero exit codes and
1228
+ * timeouts — those are real command results, not failures). Returns
1229
+ * `undefined` when the caller should fall back to the lease path:
1230
+ *
1231
+ * - Transport failure (connection refused, mid-stream drop, no `exit`
1232
+ * frame). The registry entry is evicted so subsequent execs skip the
1233
+ * private-net dial until the sidecar re-registers.
1234
+ * - Sidecar answered with a non-2xx HTTP status. Registry is left intact —
1235
+ * the address is still valid; something else is wrong (bad request,
1236
+ * sidecar bug). Only this specific exec falls back.
1237
+ */
1238
+ async _tryExecViaPrivateNetwork(instanceUrl, fullCommand, effectiveTimeout, options) {
1239
+ const filteredEnv = options?.env ? Object.fromEntries(Object.entries(options.env).filter((entry) => entry[1] !== void 0)) : void 0;
1240
+ const execOptions = {
1241
+ command: fullCommand,
1242
+ ...options?.cwd !== void 0 && { cwd: options.cwd },
1243
+ ...filteredEnv !== void 0 && { env: filteredEnv },
1244
+ ...effectiveTimeout != null && effectiveTimeout > 0 && { timeoutMs: effectiveTimeout },
1245
+ ...this._privateNetFetch && { fetch: this._privateNetFetch }
1246
+ };
1247
+ let result;
1248
+ try {
1249
+ result = await execViaPrivateNetwork(instanceUrl, execOptions);
1250
+ } catch (error) {
1251
+ if (error instanceof PrivateNetExecHttpError) return;
1252
+ this._invalidateAddress();
1253
+ return;
1254
+ }
1255
+ if (result.timedOut) {
1256
+ if (!result.opened) this._invalidateAddress();
1257
+ return result;
1258
+ }
1259
+ if (!result.opened || result.exitCode === null) {
1260
+ this._invalidateAddress();
1261
+ return;
1262
+ }
1263
+ return result;
1264
+ }
1265
+ /**
1266
+ * Evict this sandbox's entry from the address registry after an observed
1267
+ * transport failure. The entry stays gone until the next start() re-reads
1268
+ * `instanceUrl` from a workspace-proxy response — until then, execs skip
1269
+ * the private-net dial and go straight to the lease path.
1270
+ */
1271
+ _invalidateAddress() {
1272
+ if (this._sandboxId) this._addressRegistry?.delete(this._sandboxId);
1273
+ }
1274
+ /**
1275
+ * Return a cached exec lease, minting a fresh one when the cache is empty
1276
+ * or the JWT is within {@link LEASE_REFRESH_MARGIN_MS} of `expiresAt`.
1277
+ *
1278
+ * Callers are expected to be on the "sandbox is running" path; we don't
1279
+ * re-check `_sandboxId` here because `executeCommand` already gated on it.
1280
+ */
1281
+ async _ensureLease() {
1282
+ const now = Date.now();
1283
+ if (this._lease && this._lease.expiresAtMs !== null && this._lease.expiresAtMs - LEASE_REFRESH_MARGIN_MS > now) return this._lease;
1284
+ if (this._leaseInFlight) return this._leaseInFlight;
1285
+ if (!this._sandboxId) throw new SandboxNotReadyError(this.id);
1286
+ const sandboxId = this._sandboxId;
1287
+ const inFlight = (async () => {
1288
+ const json = await (await this._client.request(`/sandbox/${encodeURIComponent(sandboxId)}/exec-lease`, { method: "POST" })).json();
1289
+ const expiresAtMs = json.expiresAt ? Date.parse(json.expiresAt) : null;
1290
+ const lease = {
1291
+ jwt: json.jwt,
1292
+ wsEndpoint: json.wsEndpoint,
1293
+ subprotocol: json.subprotocol,
1294
+ expiresAt: json.expiresAt,
1295
+ expiresAtMs: expiresAtMs !== null && !Number.isNaN(expiresAtMs) ? expiresAtMs : null
1296
+ };
1297
+ this._lease = lease;
1298
+ return lease;
1299
+ })();
1300
+ this._leaseInFlight = inFlight;
1301
+ try {
1302
+ return await inFlight;
1303
+ } finally {
1304
+ if (this._leaseInFlight === inFlight) this._leaseInFlight = null;
1305
+ }
1306
+ }
1307
+ async getInfo() {
1308
+ if (!this._sandboxId) return {
1309
+ id: this.id,
1310
+ name: this.name,
1311
+ provider: this.provider,
1312
+ status: this.status,
1313
+ createdAt: this._createdAt ?? /* @__PURE__ */ new Date()
1314
+ };
1315
+ if (this._addressRegistry?.get(this._sandboxId)) return {
1316
+ id: this._sandboxId,
1317
+ name: this.name,
1318
+ provider: this.provider,
1319
+ status: this.status,
1320
+ createdAt: this._createdAt ?? /* @__PURE__ */ new Date(),
1321
+ metadata: { sandboxId: this._sandboxId }
1322
+ };
1323
+ const json = await (await this._client.request(`/sandbox/${encodeURIComponent(this._sandboxId)}`)).json();
1324
+ return {
1325
+ id: json.id,
1326
+ name: this.name,
1327
+ provider: this.provider,
1328
+ status: this.status,
1329
+ createdAt: json.createdAt ? new Date(json.createdAt) : this._createdAt ?? /* @__PURE__ */ new Date(),
1330
+ metadata: {
1331
+ sandboxId: json.id,
1332
+ providerResourceId: json.providerResourceId ?? void 0,
1333
+ platformStatus: json.status
1334
+ }
1335
+ };
1336
+ }
1337
+ getInstructions(opts) {
1338
+ const defaultInstructions = `Platform sandbox${this._sandboxId ? ` ${this._sandboxId}` : ""}. Execute commands with the sandbox command APIs.`;
1339
+ if (typeof this._instructionsOverride === "function") return this._instructionsOverride({
1340
+ defaultInstructions,
1341
+ requestContext: opts?.requestContext
1342
+ });
1343
+ if (typeof this._instructionsOverride === "string") return this._instructionsOverride;
1344
+ return defaultInstructions;
1345
+ }
1346
+ };
1347
+ //#endregion
1348
+ //#region src/provider.ts
1349
+ const platformSandboxProvider = {
1350
+ id: "platform",
1351
+ name: "Mastra Platform Sandbox",
1352
+ description: "Environment-scoped sandbox execution through Mastra Platform workspace proxy",
1353
+ configSchema: {
1354
+ type: "object",
1355
+ properties: {
1356
+ accessToken: {
1357
+ type: "string",
1358
+ description: "Mastra Platform access token (falls back to MASTRA_PLATFORM_ACCESS_TOKEN)"
1359
+ },
1360
+ projectId: {
1361
+ type: "string",
1362
+ description: "Platform project ID (falls back to MASTRA_PROJECT_ID)"
1363
+ },
1364
+ environmentId: {
1365
+ type: "string",
1366
+ description: "Platform environment ID (falls back to MASTRA_ENVIRONMENT_ID)"
1367
+ },
1368
+ sandboxId: {
1369
+ type: "string",
1370
+ description: "Reattach to an existing Platform sandbox by ID"
1371
+ },
1372
+ idleTimeoutMinutes: {
1373
+ type: "number",
1374
+ description: "Minutes before the sandbox can be destroyed while idle"
1375
+ },
1376
+ networkIsolation: {
1377
+ type: "string",
1378
+ description: "Network isolation mode",
1379
+ enum: ["ISOLATED", "PRIVATE"],
1380
+ default: "ISOLATED"
1381
+ },
1382
+ env: {
1383
+ type: "object",
1384
+ description: "Environment variables",
1385
+ additionalProperties: { type: "string" }
1386
+ },
1387
+ timeout: {
1388
+ type: "number",
1389
+ description: "Default command timeout in ms"
1390
+ }
1391
+ }
1392
+ },
1393
+ createSandbox: (config) => new PlatformSandbox(config)
1394
+ };
1395
+ const platformFilesystemProvider = {
1396
+ id: "platform",
1397
+ name: "Mastra Platform Filesystem",
1398
+ description: "Bucket-backed filesystem access through Mastra Platform workspace proxy",
1399
+ configSchema: {
1400
+ type: "object",
1401
+ properties: {
1402
+ accessToken: {
1403
+ type: "string",
1404
+ description: "Mastra Platform access token (falls back to MASTRA_PLATFORM_ACCESS_TOKEN)"
1405
+ },
1406
+ projectId: {
1407
+ type: "string",
1408
+ description: "Platform project ID (falls back to MASTRA_PROJECT_ID)"
1409
+ },
1410
+ bucketName: {
1411
+ type: "string",
1412
+ description: "Platform workspace bucket name (falls back to MASTRA_PLATFORM_BUCKET_NAME)"
1413
+ },
1414
+ readOnly: {
1415
+ type: "boolean",
1416
+ description: "Mount as read-only",
1417
+ default: false
1418
+ }
1419
+ }
1420
+ },
1421
+ createFilesystem: (config) => new PlatformFilesystem(config)
1422
+ };
1423
+ //#endregion
1424
+ //#region src/address-registry.ts
1425
+ /**
1426
+ * Concrete in-process {@link SandboxAddressRegistry}. Backed by a `Map`; no
1427
+ * eviction policy, no TTL — entries live until an observed transport failure
1428
+ * calls `delete`, until the sandbox is explicitly destroyed, or until the
1429
+ * process exits.
1430
+ */
1431
+ var InProcessSandboxAddressRegistry = class {
1432
+ #map = /* @__PURE__ */ new Map();
1433
+ get(sandboxId) {
1434
+ return this.#map.get(sandboxId);
1435
+ }
1436
+ /**
1437
+ * Populate or overwrite the address for a sandbox. Called by
1438
+ * {@link PlatformSandbox.start} on every fresh provision and every reattach;
1439
+ * overwriting is intentional so a re-provision with a fresh IPv6 heals the
1440
+ * map without a branch.
1441
+ */
1442
+ set(sandboxId, instanceUrl) {
1443
+ this.#map.set(sandboxId, instanceUrl);
1444
+ }
1445
+ delete(sandboxId) {
1446
+ this.#map.delete(sandboxId);
1447
+ }
1448
+ /**
1449
+ * Test-only introspection. Not part of {@link SandboxAddressRegistry} —
1450
+ * production callers must not read the registry as a whole.
1451
+ */
1452
+ get size() {
1453
+ return this.#map.size;
1454
+ }
1455
+ };
1456
+ //#endregion
1457
+ export { InProcessSandboxAddressRegistry, PlatformApiError, PlatformClient, PlatformFilesystem, PlatformSandbox, PrivateNetExecHttpError, SandboxDestroyedError, SandboxExecTransportError, execViaPrivateNetwork, platformFilesystemProvider, platformSandboxProvider };
1458
+
1459
+ //# sourceMappingURL=index.js.map