@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/CHANGELOG.md +462 -0
- package/LICENSE.md +30 -0
- package/README.md +115 -0
- package/dist/address-registry.d.ts +47 -0
- package/dist/address-registry.d.ts.map +1 -0
- package/dist/client.d.ts +44 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/direct-exec.d.ts +98 -0
- package/dist/direct-exec.d.ts.map +1 -0
- package/dist/filesystem.d.ts +62 -0
- package/dist/filesystem.d.ts.map +1 -0
- package/dist/index.cjs +1493 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1459 -0
- package/dist/index.js.map +1 -0
- package/dist/private-net-exec.d.ts +114 -0
- package/dist/private-net-exec.d.ts.map +1 -0
- package/dist/provider.d.ts +6 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/sandbox.d.ts +393 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/package.json +64 -0
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
|