@mastra/platform-workspace 0.2.2-alpha.0 → 0.2.3-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # @mastra/platform
2
2
 
3
+ ## 0.2.3-alpha.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Fix direct-exec fallback loop: when the WebSocket transport fails on a sandbox (Railway rejects the handshake, mid-stream drop, etc.), disable direct-exec permanently for that sandbox instead of re-minting a fresh lease on every subsequent exec. Also surface WebSocket close code, reason, and `opened` state in `DirectExecResult` and emit a diagnostic warning on transport failure so we can see why Railway is refusing the upgrade in production. ([#20412](https://github.com/mastra-ai/mastra/pull/20412))
8
+
9
+ - Updated dependencies [[`55c9e24`](https://github.com/mastra-ai/mastra/commit/55c9e248c27c1d72b5bb7e94ea6b8a3999eee49f), [`07f5b4b`](https://github.com/mastra-ai/mastra/commit/07f5b4ba9d608d88865030732e580298296adf99)]:
10
+ - @mastra/core@1.55.0-alpha.2
11
+
12
+ ## 0.2.2
13
+
14
+ ### Patch Changes
15
+
16
+ - **Added:** Direct exec data plane for `PlatformSandbox`. ([#20326](https://github.com/mastra-ai/mastra/pull/20326))
17
+
18
+ Commands now execute against Railway's WebSocket endpoint directly using a short-lived JWT lease minted by the workspace proxy, instead of proxying every exec through the HTTP proxy. This removes the workspace proxy from the exec hot path — cutting latency for large-payload commands (e.g. `pnpm install`) and eliminating duplicated observability spans.
19
+
20
+ The change is transparent: `executeCommand` still returns the same `CommandResult` shape. If the proxy's `/exec-lease` endpoint is unavailable (older deployments), the client automatically falls back to the legacy `POST /sandbox/:id/exec` route for the lifetime of the sandbox.
21
+
22
+ - Fixed platform sandbox reattach and made provisioning resilient to transient proxy failures: ([#20294](https://github.com/mastra-ai/mastra/pull/20294))
23
+
24
+ - The workspace proxy assigns its own sandbox id on create (the advisory id in the request body is not honored), but `getInfo()` never exposed it, so callers persisting a reattach id (e.g. the Factory sandbox fleet, which reads `metadata.sandboxId`) stored the locally generated construction id instead. Every reattach then 404'd and each session open provisioned a brand-new sandbox and re-cloned the repository. `getInfo()` now reports the platform-assigned id in `metadata.sandboxId`, and `start()` treats a sandbox record with `destroyedAt` set as gone (falls through to a fresh provision) instead of pointing exec at a dead resource.
25
+ - Sandbox creation retries transient workspace-proxy 5xx responses with a short backoff. Provisioning intermittently fails with proxy 500s while the provider is under load; a retry keeps a single flaky window from failing the caller's whole workflow (e.g. Factory kickoff runs). Non-transient errors (4xx) still fail immediately.
26
+
27
+ - Updated dependencies [[`ce93a3c`](https://github.com/mastra-ai/mastra/commit/ce93a3c114ea1cbfbd576f3db41d7c26c9844f5b), [`5718a22`](https://github.com/mastra-ai/mastra/commit/5718a229281dcfd36bcd1f42a242e3717e510a33), [`a211d09`](https://github.com/mastra-ai/mastra/commit/a211d09185dc65a746534914cf38b67f21ee9bac), [`0dca9d0`](https://github.com/mastra-ai/mastra/commit/0dca9d0b1356024a53b72ea6f040db528b126caa), [`6218217`](https://github.com/mastra-ai/mastra/commit/62182171b6cfca0b099f1c6a77a2e65e7639ab86), [`5807d3a`](https://github.com/mastra-ai/mastra/commit/5807d3ae1d259b8b7d6df7e5bf2b485c694af9c8), [`57661af`](https://github.com/mastra-ai/mastra/commit/57661afeca52ff9af4e72675ede2134fa503d5a5), [`05db566`](https://github.com/mastra-ai/mastra/commit/05db566fcbdcbf33d0bffca0c72ec30129e2e3ca), [`57661af`](https://github.com/mastra-ai/mastra/commit/57661afeca52ff9af4e72675ede2134fa503d5a5), [`57661af`](https://github.com/mastra-ai/mastra/commit/57661afeca52ff9af4e72675ede2134fa503d5a5), [`5718a22`](https://github.com/mastra-ai/mastra/commit/5718a229281dcfd36bcd1f42a242e3717e510a33), [`57661af`](https://github.com/mastra-ai/mastra/commit/57661afeca52ff9af4e72675ede2134fa503d5a5), [`d1b7e3a`](https://github.com/mastra-ai/mastra/commit/d1b7e3a978a309a5653eeaa490d2d6c7c53bd093), [`29c584a`](https://github.com/mastra-ai/mastra/commit/29c584a13a88831e5ed1fdeb0ff8e82eae180433), [`c093146`](https://github.com/mastra-ai/mastra/commit/c0931466404d3c521308ea119cb165bb7e695155), [`8124754`](https://github.com/mastra-ai/mastra/commit/8124754ae89fbc69f8136d1df4a91904d0f84c4e), [`d12b2e4`](https://github.com/mastra-ai/mastra/commit/d12b2e4023fd9e3d3e93a9169f5088bcee2a849c)]:
28
+ - @mastra/core@1.54.0
29
+
3
30
  ## 0.2.2-alpha.0
4
31
 
5
32
  ### Patch Changes
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Direct exec client — opens Railway's tcp-proxy exec WebSocket directly using
3
+ * a short-lived JWT minted by the workspace proxy's exec-lease endpoint. This
4
+ * removes the platform data plane from the exec stdout/stderr path entirely
5
+ * (see `docs/factory/direct-sandbox-connection.md` in the Platform repo),
6
+ * cutting payload-scaled Cloud Run egress and RTT for commands like
7
+ * `pnpm install` that stream tens of MB of output.
8
+ *
9
+ * The frame protocol below mirrors `connectExecWs()` in `railway@3.5.5`
10
+ * (`workspaces/railway/node_modules/railway/dist/index.js`). The `railway`
11
+ * SDK's version is pinned on both sides (platform + here); a version bump
12
+ * signals the protocol may have drifted and this module must be revisited.
13
+ */
14
+ /**
15
+ * Minimal WebSocket surface this module depends on. Matches both the browser
16
+ * `WebSocket` global and Node 22+'s built-in `WebSocket`. Extracted so tests
17
+ * can inject a fake without pulling in `ws` or jsdom.
18
+ */
19
+ export interface DirectExecWebSocket {
20
+ binaryType: 'blob' | 'arraybuffer';
21
+ onopen: ((event: unknown) => void) | null;
22
+ onmessage: ((event: {
23
+ data: unknown;
24
+ }) => void) | null;
25
+ onclose: ((event: {
26
+ code: number;
27
+ reason: string;
28
+ }) => void) | null;
29
+ onerror: ((event: unknown) => void) | null;
30
+ send(data: string): void;
31
+ close(code?: number, reason?: string): void;
32
+ }
33
+ /**
34
+ * Factory that opens a WebSocket to `endpoint` with the given subprotocols.
35
+ * Defaults to the global `WebSocket` when omitted, which works on Node 22+
36
+ * (the package's minimum) and in the browser. Tests inject a fake here.
37
+ */
38
+ export type DirectExecWebSocketFactory = (endpoint: string, subprotocols: string[]) => DirectExecWebSocket;
39
+ /** Lease payload returned by `POST /v1/projects/:projectId/sandbox/:sandboxId/exec-lease`. */
40
+ export interface ExecLease {
41
+ jwt: string;
42
+ wsEndpoint: string;
43
+ subprotocol: string;
44
+ /** ISO-8601 UTC. Null when the provider issues a JWT without an `exp` claim. */
45
+ expiresAt: string | null;
46
+ }
47
+ /** Inputs to a direct exec invocation. Mirrors the shape of the `/exec` route body. */
48
+ export interface DirectExecOptions {
49
+ command: string;
50
+ cwd?: string;
51
+ env?: Record<string, string>;
52
+ /**
53
+ * Wall-clock cap for the exec. When elapsed, we close the socket and
54
+ * return `{timedOut: true, exitCode: 124}` matching the semantics of the
55
+ * proxy's `/exec` route.
56
+ */
57
+ timeoutMs?: number;
58
+ onStdout?: (chunk: string) => void;
59
+ onStderr?: (chunk: string) => void;
60
+ /** Injected for tests. Defaults to `globalThis.WebSocket`. */
61
+ webSocketFactory?: DirectExecWebSocketFactory;
62
+ }
63
+ /**
64
+ * Result of a direct exec. Shape matches the workspace-proxy `/exec` response
65
+ * so the caller (`PlatformSandbox.executeCommand`) can hand it back with no
66
+ * translation.
67
+ *
68
+ * `exitCode` is `null` when the socket closed without an `exit` frame AND the
69
+ * exec did not time out (rare — usually a mid-stream network drop). Callers
70
+ * currently coerce `null` to `1` upstream; kept nullable here to preserve the
71
+ * distinction for future observability.
72
+ */
73
+ export interface DirectExecResult {
74
+ exitCode: number | null;
75
+ stdout: string;
76
+ stderr: string;
77
+ truncated: boolean;
78
+ timedOut: boolean;
79
+ /**
80
+ * WebSocket close metadata. Populated on any close (normal or transport
81
+ * failure). `opened` distinguishes handshake failures (never opened) from
82
+ * mid-stream drops. Callers use this for diagnostic logging; not part of
83
+ * the CommandResult contract.
84
+ */
85
+ closeCode?: number;
86
+ closeReason?: string;
87
+ opened?: boolean;
88
+ }
89
+ /**
90
+ * Open the provider exec WebSocket using `lease`, run `command`, and resolve
91
+ * with the accumulated stdout/stderr + exit code. See the module docstring
92
+ * for the wire protocol reference.
93
+ *
94
+ * The client sends `stdin_close` immediately after `init_exec`, matching the
95
+ * SDK's own one-shot exec behavior — we never stream stdin from the caller.
96
+ */
97
+ export declare function execViaLease(lease: ExecLease, options: DirectExecOptions): Promise<DirectExecResult>;
98
+ //# sourceMappingURL=direct-exec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"direct-exec.d.ts","sourceRoot":"","sources":["../src/direct-exec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAeH;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,MAAM,GAAG,aAAa,CAAC;IACnC,MAAM,EAAE,CAAC,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IAC1C,SAAS,EAAE,CAAC,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IACvD,OAAO,EAAE,CAAC,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IACpE,OAAO,EAAE,CAAC,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IAC3C,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7C;AAED;;;;GAIG;AACH,MAAM,MAAM,0BAA0B,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,KAAK,mBAAmB,CAAC;AAE3G,8FAA8F;AAC9F,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,uFAAuF;AACvF,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,8DAA8D;IAC9D,gBAAgB,CAAC,EAAE,0BAA0B,CAAC;CAC/C;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,OAAO,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAcD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAyJpG"}
package/dist/index.cjs CHANGED
@@ -358,7 +358,167 @@ function matchesExtension(name, extension) {
358
358
  return (Array.isArray(extension) ? extension : [extension]).some((ext) => name.endsWith(ext));
359
359
  }
360
360
  //#endregion
361
+ //#region src/direct-exec.ts
362
+ /**
363
+ * Direct exec client — opens Railway's tcp-proxy exec WebSocket directly using
364
+ * a short-lived JWT minted by the workspace proxy's exec-lease endpoint. This
365
+ * removes the platform data plane from the exec stdout/stderr path entirely
366
+ * (see `docs/factory/direct-sandbox-connection.md` in the Platform repo),
367
+ * cutting payload-scaled Cloud Run egress and RTT for commands like
368
+ * `pnpm install` that stream tens of MB of output.
369
+ *
370
+ * The frame protocol below mirrors `connectExecWs()` in `railway@3.5.5`
371
+ * (`workspaces/railway/node_modules/railway/dist/index.js`). The `railway`
372
+ * SDK's version is pinned on both sides (platform + here); a version bump
373
+ * signals the protocol may have drifted and this module must be revisited.
374
+ */
375
+ /** Byte-0 tag on binary WS frames for stdout output. */
376
+ const STDOUT_FRAME = 1;
377
+ /** Byte-0 tag on binary WS frames for stderr output. */
378
+ const STDERR_FRAME = 3;
379
+ /**
380
+ * Upper bound on how long we'll wait for the WebSocket to open when the
381
+ * caller didn't supply a `timeoutMs`. Guards against a stalled TLS/WS
382
+ * handshake leaving the promise unresolved forever. Not applied once the
383
+ * socket has opened — a caller with no timeout has opted in to unbounded
384
+ * command runtime, just not to unbounded connection setup.
385
+ */
386
+ const HANDSHAKE_DEADLINE_MS = 3e4;
387
+ const DEFAULT_WS_FACTORY = (endpoint, subprotocols) => {
388
+ const WS = globalThis.WebSocket;
389
+ if (!WS) throw new Error("Direct exec requires a WebSocket implementation. Node 22+ provides one globally; on older runtimes, pass webSocketFactory explicitly.");
390
+ return new WS(endpoint, subprotocols);
391
+ };
392
+ /**
393
+ * Open the provider exec WebSocket using `lease`, run `command`, and resolve
394
+ * with the accumulated stdout/stderr + exit code. See the module docstring
395
+ * for the wire protocol reference.
396
+ *
397
+ * The client sends `stdin_close` immediately after `init_exec`, matching the
398
+ * SDK's own one-shot exec behavior — we never stream stdin from the caller.
399
+ */
400
+ function execViaLease(lease, options) {
401
+ const factory = options.webSocketFactory ?? DEFAULT_WS_FACTORY;
402
+ const stdoutDecoder = new TextDecoder();
403
+ const stderrDecoder = new TextDecoder();
404
+ return new Promise((resolve) => {
405
+ let stdout = "";
406
+ let stderr = "";
407
+ let exitCode = null;
408
+ let timedOut = false;
409
+ let settled = false;
410
+ let opened = false;
411
+ let closeCode;
412
+ let closeReason;
413
+ let timer;
414
+ let handshakeTimer;
415
+ const settle = () => {
416
+ if (settled) return;
417
+ settled = true;
418
+ if (timer) clearTimeout(timer);
419
+ if (handshakeTimer) clearTimeout(handshakeTimer);
420
+ const stdoutTail = stdoutDecoder.decode();
421
+ if (stdoutTail) {
422
+ stdout += stdoutTail;
423
+ options.onStdout?.(stdoutTail);
424
+ }
425
+ const stderrTail = stderrDecoder.decode();
426
+ if (stderrTail) {
427
+ stderr += stderrTail;
428
+ options.onStderr?.(stderrTail);
429
+ }
430
+ try {
431
+ socket.close(1e3, "");
432
+ } catch {}
433
+ resolve({
434
+ exitCode,
435
+ stdout,
436
+ stderr,
437
+ truncated: false,
438
+ timedOut,
439
+ ...closeCode !== void 0 && { closeCode },
440
+ ...closeReason !== void 0 && { closeReason },
441
+ opened
442
+ });
443
+ };
444
+ if (options.timeoutMs !== void 0 && options.timeoutMs > 0) timer = setTimeout(() => {
445
+ timedOut = true;
446
+ if (exitCode === null) exitCode = 124;
447
+ settle();
448
+ }, options.timeoutMs);
449
+ else handshakeTimer = setTimeout(() => {
450
+ if (!opened) settle();
451
+ }, HANDSHAKE_DEADLINE_MS);
452
+ const socket = factory(lease.wsEndpoint, [lease.subprotocol, lease.jwt]);
453
+ socket.binaryType = "arraybuffer";
454
+ socket.onopen = () => {
455
+ opened = true;
456
+ if (handshakeTimer) {
457
+ clearTimeout(handshakeTimer);
458
+ handshakeTimer = void 0;
459
+ }
460
+ const data = { command: options.command };
461
+ if (options.cwd) data.cwd = options.cwd;
462
+ if (options.env && Object.keys(options.env).length > 0) data.env = options.env;
463
+ socket.send(JSON.stringify({
464
+ type: "init_exec",
465
+ data
466
+ }));
467
+ socket.send(JSON.stringify({ type: "stdin_close" }));
468
+ };
469
+ socket.onmessage = (event) => {
470
+ const { data } = event;
471
+ if (data instanceof ArrayBuffer) handleBinaryFrame(data);
472
+ else if (typeof data === "string") handleTextFrame(data);
473
+ };
474
+ socket.onclose = (event) => {
475
+ closeCode = event.code;
476
+ closeReason = event.reason;
477
+ if (!opened) {
478
+ settle();
479
+ return;
480
+ }
481
+ settle();
482
+ };
483
+ socket.onerror = () => {
484
+ if (settled) return;
485
+ if (!opened) settle();
486
+ };
487
+ function handleBinaryFrame(buffer) {
488
+ const view = new Uint8Array(buffer);
489
+ if (view.length <= 1) return;
490
+ if (view[0] === STDOUT_FRAME) {
491
+ const chunk = stdoutDecoder.decode(view.subarray(1), { stream: true });
492
+ stdout += chunk;
493
+ options.onStdout?.(chunk);
494
+ } else if (view[0] === STDERR_FRAME) {
495
+ const chunk = stderrDecoder.decode(view.subarray(1), { stream: true });
496
+ stderr += chunk;
497
+ options.onStderr?.(chunk);
498
+ }
499
+ }
500
+ function handleTextFrame(text) {
501
+ let frame;
502
+ try {
503
+ frame = JSON.parse(text);
504
+ } catch {
505
+ return;
506
+ }
507
+ if (frame.type === "exit") {
508
+ exitCode = frame.data?.exit_code ?? 0;
509
+ settle();
510
+ }
511
+ }
512
+ });
513
+ }
514
+ //#endregion
361
515
  //#region src/sandbox.ts
516
+ /**
517
+ * How long before a lease's stated `expiresAt` we should treat it as
518
+ * expired. Avoids a race where the JWT is valid at cache-hit time but the
519
+ * server rejects it by the time the WebSocket handshake completes.
520
+ */
521
+ const LEASE_REFRESH_MARGIN_MS = 6e4;
362
522
  /** Max attempts for `POST /sandbox` when the proxy returns transient 5xx errors. */
363
523
  const CREATE_MAX_ATTEMPTS = 3;
364
524
  /** Base delay between create retries; multiplied by the attempt number. */
@@ -448,6 +608,31 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
448
608
  _timeout;
449
609
  _instructionsOverride;
450
610
  _createdAt = null;
611
+ _webSocketFactory;
612
+ /**
613
+ * Cached exec lease for this sandbox. `null` before the first exec and
614
+ * after {@link destroy}. Refreshed when `expiresAt - LEASE_REFRESH_MARGIN_MS < now`
615
+ * (see {@link _ensureLease}); a lease without a disclosed `expiresAt`
616
+ * is refreshed on every call.
617
+ */
618
+ _lease = null;
619
+ /**
620
+ * In-flight mint request; concurrent `_ensureLease` callers on a cold or
621
+ * near-expiry cache all await this single promise so we don't burn N
622
+ * `POST /exec-lease` round-trips when the sandbox is doing N parallel execs.
623
+ * Cleared (regardless of success or failure) when the request settles.
624
+ */
625
+ _leaseInFlight = null;
626
+ /**
627
+ * Tri-state feature detection for the platform's exec-lease endpoint:
628
+ * undefined — not yet tried (default; try direct on first exec)
629
+ * true — endpoint present, use direct exec
630
+ * false — endpoint absent (404/501) OR the WebSocket transport failed
631
+ * once; fall back permanently to /exec for this sandbox
632
+ * Sticky per instance so we make the fallback decision once per sandbox
633
+ * lifetime instead of paying an extra round-trip on every exec.
634
+ */
635
+ _directExecAvailable = void 0;
451
636
  constructor(options = {}) {
452
637
  super({
453
638
  ...options,
@@ -464,6 +649,7 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
464
649
  this._env = options.env ?? {};
465
650
  this._timeout = options.timeout;
466
651
  this._instructionsOverride = options.instructions;
652
+ this._webSocketFactory = options.webSocketFactory;
467
653
  }
468
654
  generateId() {
469
655
  return `platform-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
@@ -492,7 +678,8 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
492
678
  ...this._networkIsolation !== void 0 && { networkIsolation: this._networkIsolation },
493
679
  env: options.env ?? this._env,
494
680
  ...this._timeout !== void 0 && { timeout: this._timeout },
495
- ...this._instructionsOverride !== void 0 && { instructions: this._instructionsOverride }
681
+ ...this._instructionsOverride !== void 0 && { instructions: this._instructionsOverride },
682
+ ...this._webSocketFactory !== void 0 && { webSocketFactory: this._webSocketFactory }
496
683
  });
497
684
  }
498
685
  async start() {
@@ -539,6 +726,7 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
539
726
  await this._client.request(`/sandbox/${encodeURIComponent(this._sandboxId)}`, { method: "DELETE" });
540
727
  this._sandboxId = void 0;
541
728
  this._createdAt = null;
729
+ this._lease = null;
542
730
  }
543
731
  /**
544
732
  * Execute a command on the remote sandbox.
@@ -563,6 +751,59 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
563
751
  const started = Date.now();
564
752
  const fullCommand = buildCommand(command, args);
565
753
  const effectiveTimeout = options?.timeout ?? this._timeout;
754
+ if (this._directExecAvailable !== false) {
755
+ const leaseResult = await this._tryDirectExec(fullCommand, effectiveTimeout, options);
756
+ if (leaseResult) return {
757
+ ...leaseResult,
758
+ executionTimeMs: Date.now() - started
759
+ };
760
+ }
761
+ return this._execViaProxy(fullCommand, effectiveTimeout, options, started);
762
+ }
763
+ async _tryDirectExec(fullCommand, effectiveTimeout, options) {
764
+ let lease;
765
+ try {
766
+ lease = await this._ensureLease();
767
+ } catch (error) {
768
+ if (error instanceof PlatformApiError && (error.status === 404 || error.status === 501)) {
769
+ this._directExecAvailable = false;
770
+ return null;
771
+ }
772
+ throw error;
773
+ }
774
+ this._directExecAvailable = true;
775
+ const filteredEnv = options?.env ? Object.fromEntries(Object.entries(options.env).filter((entry) => entry[1] !== void 0)) : void 0;
776
+ const result = await execViaLease(lease, {
777
+ command: fullCommand,
778
+ ...options?.cwd !== void 0 && { cwd: options.cwd },
779
+ ...filteredEnv !== void 0 && { env: filteredEnv },
780
+ ...effectiveTimeout != null && effectiveTimeout > 0 && { timeoutMs: effectiveTimeout },
781
+ ...this._webSocketFactory && { webSocketFactory: this._webSocketFactory }
782
+ });
783
+ if (result.exitCode === null && !result.timedOut) {
784
+ console.warn("[platform-workspace] direct-exec transport failed; falling back to /exec permanently for this sandbox", {
785
+ sandboxId: this._sandboxId,
786
+ opened: result.opened,
787
+ closeCode: result.closeCode,
788
+ closeReason: result.closeReason,
789
+ wsEndpoint: lease.wsEndpoint
790
+ });
791
+ this._lease = null;
792
+ this._directExecAvailable = false;
793
+ return null;
794
+ }
795
+ const exitCode = result.exitCode ?? 124;
796
+ return {
797
+ success: exitCode === 0,
798
+ exitCode,
799
+ stdout: result.stdout,
800
+ stderr: result.stderr,
801
+ timedOut: result.timedOut,
802
+ command: fullCommand
803
+ };
804
+ }
805
+ async _execViaProxy(fullCommand, effectiveTimeout, options, started) {
806
+ if (!this._sandboxId) throw new _mastra_core_workspace.SandboxNotReadyError(this.id);
566
807
  const timeoutSec = effectiveTimeout != null ? Math.ceil(effectiveTimeout / 1e3) : void 0;
567
808
  const clientSignal = effectiveTimeout != null && effectiveTimeout > 0 ? AbortSignal.timeout(effectiveTimeout + 3e4) : void 0;
568
809
  const json = await (await this._client.request(`/sandbox/${encodeURIComponent(this._sandboxId)}/exec`, {
@@ -587,6 +828,39 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
587
828
  command: fullCommand
588
829
  };
589
830
  }
831
+ /**
832
+ * Return a cached exec lease, minting a fresh one when the cache is empty
833
+ * or the JWT is within {@link LEASE_REFRESH_MARGIN_MS} of `expiresAt`.
834
+ *
835
+ * Callers are expected to be on the "sandbox is running" path; we don't
836
+ * re-check `_sandboxId` here because `executeCommand` already gated on it.
837
+ */
838
+ async _ensureLease() {
839
+ const now = Date.now();
840
+ if (this._lease && this._lease.expiresAtMs !== null && this._lease.expiresAtMs - LEASE_REFRESH_MARGIN_MS > now) return this._lease;
841
+ if (this._leaseInFlight) return this._leaseInFlight;
842
+ if (!this._sandboxId) throw new _mastra_core_workspace.SandboxNotReadyError(this.id);
843
+ const sandboxId = this._sandboxId;
844
+ const inFlight = (async () => {
845
+ const json = await (await this._client.request(`/sandbox/${encodeURIComponent(sandboxId)}/exec-lease`, { method: "POST" })).json();
846
+ const expiresAtMs = json.expiresAt ? Date.parse(json.expiresAt) : null;
847
+ const lease = {
848
+ jwt: json.jwt,
849
+ wsEndpoint: json.wsEndpoint,
850
+ subprotocol: json.subprotocol,
851
+ expiresAt: json.expiresAt,
852
+ expiresAtMs: expiresAtMs !== null && !Number.isNaN(expiresAtMs) ? expiresAtMs : null
853
+ };
854
+ this._lease = lease;
855
+ return lease;
856
+ })();
857
+ this._leaseInFlight = inFlight;
858
+ try {
859
+ return await inFlight;
860
+ } finally {
861
+ if (this._leaseInFlight === inFlight) this._leaseInFlight = null;
862
+ }
863
+ }
590
864
  async getInfo() {
591
865
  if (!this._sandboxId) return {
592
866
  id: this.id,