@mastra/platform-workspace 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,81 @@
1
1
  # @mastra/platform
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - `PlatformSandbox.executeCommand` can now dial the sandbox directly over Railway's private network instead of going through the platform's public exec proxy. On paths where the direct route is available, per-exec latency drops from ~400 ms p50 to ~16 ms p50, and the exec stops touching the platform control plane. This flows through to every filesystem call (`SandboxFilesystem.readFile`, `writeFile`, `readdir`, `mkdir`, `stat`, `exists`, `copyFile`, `moveFile`, `deleteFile`), which is where most agent tool time was going. ([#20664](https://github.com/mastra-ai/mastra/pull/20664))
8
+
9
+ Direct-path availability is a runtime property, not a configuration knob. When it's not available — no address registry wired up, the workspace-proxy hasn't discovered the sandbox address yet, or a direct dial fails — `executeCommand` transparently falls back to the existing exec-lease path with no behavior change. Timed-out execs are never retried on the fallback path (they're returned to the caller as-is), so this is safe for non-idempotent commands.
10
+
11
+ ### Enabling the direct path
12
+
13
+ Wire a `SandboxAddressRegistry` into `PlatformSandbox`:
14
+
15
+ ```ts
16
+ import { PlatformSandbox, InProcessSandboxAddressRegistry } from '@mastra/platform-workspace';
17
+
18
+ const registry = new InProcessSandboxAddressRegistry();
19
+
20
+ const sandbox = new PlatformSandbox({
21
+ accessToken: process.env.MASTRA_PLATFORM_ACCESS_TOKEN,
22
+ projectId: process.env.MASTRA_PLATFORM_PROJECT_ID,
23
+ environmentId: process.env.MASTRA_PLATFORM_ENVIRONMENT_ID,
24
+ addressRegistry: registry,
25
+ });
26
+ ```
27
+
28
+ `PlatformSandbox.start()` populates the registry from the workspace-proxy's response; `executeCommand` reads it, tries the direct path first, evicts on transport failure. `destroy()` also evicts. `clone()` shares the same registry — each child sandbox looks up its own id.
29
+
30
+ ### New public exports
31
+ - `SandboxAddressRegistry` — the `{ get, set, delete }` interface `PlatformSandbox` sees. Callers can implement their own (e.g. shared across a worker pool) or use the default.
32
+ - `InProcessSandboxAddressRegistry` — the default `Map`-backed implementation.
33
+ - `PlatformSandboxOptions.addressRegistry?` — DI seam. Optional; omitted keeps pre-existing behavior.
34
+ - `execViaPrivateNetwork`, `PrivateNetExecHttpError`, `PrivateNetExecOptions`, `PrivateNetExecResult`, `PrivateNetFetch` — standalone transport for callers that want to talk to a sandbox directly without going through `PlatformSandbox`.
35
+
36
+ ### Patch Changes
37
+
38
+ - Updated dependencies [[`8d2399b`](https://github.com/mastra-ai/mastra/commit/8d2399b638f8e0945cf2cda0187dbea8dcf0b784), [`c8002da`](https://github.com/mastra-ai/mastra/commit/c8002da7775c468e2965b6ff5f82045450fa8cb9), [`92be47f`](https://github.com/mastra-ai/mastra/commit/92be47fbd26ffccec0e2131ef7c1d9e70dd5ef4a), [`89200ba`](https://github.com/mastra-ai/mastra/commit/89200bafa05444bb7949b363ce7b743e29867561), [`c950138`](https://github.com/mastra-ai/mastra/commit/c950138e72e4f317a40187e3800588731ab790ce), [`810c7e7`](https://github.com/mastra-ai/mastra/commit/810c7e74929989d8b8b5db52cd3af22cd0998af4), [`063c8b2`](https://github.com/mastra-ai/mastra/commit/063c8b2eb14e4e5ca021779bc33e8c3c031c8604), [`f9f9884`](https://github.com/mastra-ai/mastra/commit/f9f98848ee194dc71a787a709ec430b065cdc41b), [`e0904dc`](https://github.com/mastra-ai/mastra/commit/e0904dc538792e54e1806b70172e5900ac49bff4), [`9672fab`](https://github.com/mastra-ai/mastra/commit/9672fabfbcadb961a35c22a2d6722e077f7b24b9), [`f4e964c`](https://github.com/mastra-ai/mastra/commit/f4e964cad57057301d6bed5c55bcdd730175b941), [`1f7bbd7`](https://github.com/mastra-ai/mastra/commit/1f7bbd7785a8d230aad02454ecabeb4a0b2cc96f), [`e47ff36`](https://github.com/mastra-ai/mastra/commit/e47ff36945720f4ee4caa09f6e83514d7d188608), [`64d6781`](https://github.com/mastra-ai/mastra/commit/64d67814bccddd314f7e09643243821e57cb87b6), [`fb9a6ac`](https://github.com/mastra-ai/mastra/commit/fb9a6ac11c9560518742ece60b49d6b062845fd3), [`aa2cec8`](https://github.com/mastra-ai/mastra/commit/aa2cec8501f634d51c2f3ebfb3dd3aa7af8d2ca2), [`c848e65`](https://github.com/mastra-ai/mastra/commit/c848e655a64ff10331a8ceafafe7f18e70a0f092), [`2adf8eb`](https://github.com/mastra-ai/mastra/commit/2adf8eb4a70ed2b6cff2dd39281496ea0e025fac), [`0494489`](https://github.com/mastra-ai/mastra/commit/049448906e4c3d2d615bbe865b073a0d890ddb7c), [`8d1aeb8`](https://github.com/mastra-ai/mastra/commit/8d1aeb8acf7c20c4bb8e4d8e4bdc6569c83ac561), [`8264611`](https://github.com/mastra-ai/mastra/commit/8264611510e421b818bc7395dc2ae4d9c2d518b2), [`d8fa243`](https://github.com/mastra-ai/mastra/commit/d8fa2430d21113e330c4e676ac65e1235cf44f81), [`44fc98b`](https://github.com/mastra-ai/mastra/commit/44fc98b9d1242aa87a3ab44bdce9e9f12c44d8c9), [`f933ba3`](https://github.com/mastra-ai/mastra/commit/f933ba32700e1d0bf143311c1a08f88300b840b6), [`83065bf`](https://github.com/mastra-ai/mastra/commit/83065bfee9e47c3c6f09132a9034501f6cfb69cf), [`0f2ef41`](https://github.com/mastra-ai/mastra/commit/0f2ef4118da022e4f30dac4e9856cc3a8c97671c), [`01b162f`](https://github.com/mastra-ai/mastra/commit/01b162fe435295881aa7ea55f1759407ad5175ad)]:
39
+ - @mastra/core@1.57.0
40
+
41
+ ## 1.1.0-alpha.0
42
+
43
+ ### Minor Changes
44
+
45
+ - `PlatformSandbox.executeCommand` can now dial the sandbox directly over Railway's private network instead of going through the platform's public exec proxy. On paths where the direct route is available, per-exec latency drops from ~400 ms p50 to ~16 ms p50, and the exec stops touching the platform control plane. This flows through to every filesystem call (`SandboxFilesystem.readFile`, `writeFile`, `readdir`, `mkdir`, `stat`, `exists`, `copyFile`, `moveFile`, `deleteFile`), which is where most agent tool time was going. ([#20664](https://github.com/mastra-ai/mastra/pull/20664))
46
+
47
+ Direct-path availability is a runtime property, not a configuration knob. When it's not available — no address registry wired up, the workspace-proxy hasn't discovered the sandbox address yet, or a direct dial fails — `executeCommand` transparently falls back to the existing exec-lease path with no behavior change. Timed-out execs are never retried on the fallback path (they're returned to the caller as-is), so this is safe for non-idempotent commands.
48
+
49
+ ### Enabling the direct path
50
+
51
+ Wire a `SandboxAddressRegistry` into `PlatformSandbox`:
52
+
53
+ ```ts
54
+ import { PlatformSandbox, InProcessSandboxAddressRegistry } from '@mastra/platform-workspace';
55
+
56
+ const registry = new InProcessSandboxAddressRegistry();
57
+
58
+ const sandbox = new PlatformSandbox({
59
+ accessToken: process.env.MASTRA_PLATFORM_ACCESS_TOKEN,
60
+ projectId: process.env.MASTRA_PLATFORM_PROJECT_ID,
61
+ environmentId: process.env.MASTRA_PLATFORM_ENVIRONMENT_ID,
62
+ addressRegistry: registry,
63
+ });
64
+ ```
65
+
66
+ `PlatformSandbox.start()` populates the registry from the workspace-proxy's response; `executeCommand` reads it, tries the direct path first, evicts on transport failure. `destroy()` also evicts. `clone()` shares the same registry — each child sandbox looks up its own id.
67
+
68
+ ### New public exports
69
+ - `SandboxAddressRegistry` — the `{ get, set, delete }` interface `PlatformSandbox` sees. Callers can implement their own (e.g. shared across a worker pool) or use the default.
70
+ - `InProcessSandboxAddressRegistry` — the default `Map`-backed implementation.
71
+ - `PlatformSandboxOptions.addressRegistry?` — DI seam. Optional; omitted keeps pre-existing behavior.
72
+ - `execViaPrivateNetwork`, `PrivateNetExecHttpError`, `PrivateNetExecOptions`, `PrivateNetExecResult`, `PrivateNetFetch` — standalone transport for callers that want to talk to a sandbox directly without going through `PlatformSandbox`.
73
+
74
+ ### Patch Changes
75
+
76
+ - Updated dependencies [[`89200ba`](https://github.com/mastra-ai/mastra/commit/89200bafa05444bb7949b363ce7b743e29867561), [`c950138`](https://github.com/mastra-ai/mastra/commit/c950138e72e4f317a40187e3800588731ab790ce), [`063c8b2`](https://github.com/mastra-ai/mastra/commit/063c8b2eb14e4e5ca021779bc33e8c3c031c8604), [`f4e964c`](https://github.com/mastra-ai/mastra/commit/f4e964cad57057301d6bed5c55bcdd730175b941), [`1f7bbd7`](https://github.com/mastra-ai/mastra/commit/1f7bbd7785a8d230aad02454ecabeb4a0b2cc96f), [`e47ff36`](https://github.com/mastra-ai/mastra/commit/e47ff36945720f4ee4caa09f6e83514d7d188608), [`fb9a6ac`](https://github.com/mastra-ai/mastra/commit/fb9a6ac11c9560518742ece60b49d6b062845fd3), [`aa2cec8`](https://github.com/mastra-ai/mastra/commit/aa2cec8501f634d51c2f3ebfb3dd3aa7af8d2ca2), [`2adf8eb`](https://github.com/mastra-ai/mastra/commit/2adf8eb4a70ed2b6cff2dd39281496ea0e025fac), [`8264611`](https://github.com/mastra-ai/mastra/commit/8264611510e421b818bc7395dc2ae4d9c2d518b2), [`44fc98b`](https://github.com/mastra-ai/mastra/commit/44fc98b9d1242aa87a3ab44bdce9e9f12c44d8c9), [`0f2ef41`](https://github.com/mastra-ai/mastra/commit/0f2ef4118da022e4f30dac4e9856cc3a8c97671c)]:
77
+ - @mastra/core@1.57.0-alpha.1
78
+
3
79
  ## 1.0.0
4
80
 
5
81
  ### Major Changes
@@ -0,0 +1,47 @@
1
+ /**
2
+ * In-process `sandboxId → instanceUrl` registry populated by
3
+ * {@link PlatformSandbox.start} from the `instanceUrl` field workspace-proxy
4
+ * includes on create + get responses, and consumed by
5
+ * {@link PlatformSandbox.executeCommand} on the private-network fast path.
6
+ *
7
+ * The registry lives in the same Node process as the `PlatformSandbox`
8
+ * consumer — for shipyard, that is the Mastra runtime deployed from
9
+ * `mastracode/web`. There is no receiver route and no cross-service dance;
10
+ * the address is just a field copied from a response the runtime already
11
+ * receives.
12
+ *
13
+ * State intentionally does not persist:
14
+ *
15
+ * - The IPv6 rotates on every sandbox recreate.
16
+ * - The URL has no meaning after the sandbox is destroyed.
17
+ * - The live sandbox binding, session context, and lease all die with the
18
+ * runtime process; the address dying with them is correct.
19
+ * - The proxy's `environment_sandboxes.instance_url` column is the durable
20
+ * source of truth — a runtime restart re-populates the registry on the
21
+ * next `start()` / reattach from the proxy's response.
22
+ */
23
+ import type { SandboxAddressRegistry } from './sandbox.js';
24
+ /**
25
+ * Concrete in-process {@link SandboxAddressRegistry}. Backed by a `Map`; no
26
+ * eviction policy, no TTL — entries live until an observed transport failure
27
+ * calls `delete`, until the sandbox is explicitly destroyed, or until the
28
+ * process exits.
29
+ */
30
+ export declare class InProcessSandboxAddressRegistry implements SandboxAddressRegistry {
31
+ #private;
32
+ get(sandboxId: string): string | undefined;
33
+ /**
34
+ * Populate or overwrite the address for a sandbox. Called by
35
+ * {@link PlatformSandbox.start} on every fresh provision and every reattach;
36
+ * overwriting is intentional so a re-provision with a fresh IPv6 heals the
37
+ * map without a branch.
38
+ */
39
+ set(sandboxId: string, instanceUrl: string): void;
40
+ delete(sandboxId: string): void;
41
+ /**
42
+ * Test-only introspection. Not part of {@link SandboxAddressRegistry} —
43
+ * production callers must not read the registry as a whole.
44
+ */
45
+ get size(): number;
46
+ }
47
+ //# sourceMappingURL=address-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"address-registry.d.ts","sourceRoot":"","sources":["../src/address-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AAE3D;;;;;GAKG;AACH,qBAAa,+BAAgC,YAAW,sBAAsB;;IAG5E,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAI1C;;;;;OAKG;IACH,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,IAAI;IAIjD,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAI/B;;;OAGG;IACH,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF"}
package/dist/index.cjs CHANGED
@@ -512,6 +512,175 @@ function execViaLease(lease, options) {
512
512
  });
513
513
  }
514
514
  //#endregion
515
+ //#region src/private-net-exec.ts
516
+ /**
517
+ * Thrown by {@link execViaPrivateNetwork} when the sidecar returns a non-2xx
518
+ * HTTP response. This is an *application* error, not a transport error — the
519
+ * sidecar is reachable and answered, it just refused the exec. Callers should
520
+ * fall back to the lease path for this one call but MUST NOT invalidate the
521
+ * cached `instanceUrl` (the address is still good).
522
+ */
523
+ var PrivateNetExecHttpError = class extends Error {
524
+ status;
525
+ body;
526
+ constructor(status, body) {
527
+ super(`Sidecar /exec returned ${status}${body ? `: ${body.slice(0, 200)}` : ""}`);
528
+ this.name = "PrivateNetExecHttpError";
529
+ this.status = status;
530
+ this.body = body;
531
+ }
532
+ };
533
+ const DEFAULT_FETCH = (input, init) => {
534
+ const f = globalThis.fetch;
535
+ if (!f) throw new Error("Private-network exec requires a fetch implementation. Node 22+ provides one globally; on older runtimes, pass fetch explicitly.");
536
+ return f(input, init);
537
+ };
538
+ /**
539
+ * Dial `${instanceUrl}/exec` and stream the response, resolving with the
540
+ * accumulated stdout/stderr + exit code.
541
+ *
542
+ * Errors:
543
+ * - Connection failure (DNS, refused, reset) → resolves with
544
+ * `{opened:false, exitCode:null, transportErrorMessage}`. Never throws for
545
+ * transport failures — the shape matches the lease-path result so the
546
+ * caller can treat both transports uniformly.
547
+ * - Non-2xx HTTP response from the sidecar → throws {@link PrivateNetExecHttpError}.
548
+ * Application-level; caller decides whether to fall back.
549
+ * - Stream ends without an `exit` frame → resolves with
550
+ * `{opened:true, exitCode:null}`, matching the lease-path semantics for a
551
+ * mid-stream drop.
552
+ * - `timeoutMs` elapsed → aborts the request, resolves with
553
+ * `{timedOut:true, exitCode:124}`.
554
+ */
555
+ async function execViaPrivateNetwork(instanceUrl, options) {
556
+ const fetchImpl = options.fetch ?? DEFAULT_FETCH;
557
+ const url = `${instanceUrl.replace(/\/$/, "")}/exec`;
558
+ const controller = new AbortController();
559
+ let timedOut = false;
560
+ let timeoutTimer;
561
+ if (options.timeoutMs !== void 0 && options.timeoutMs > 0) timeoutTimer = setTimeout(() => {
562
+ timedOut = true;
563
+ controller.abort();
564
+ }, options.timeoutMs);
565
+ const body = { command: options.command };
566
+ if (options.cwd) body.cwd = options.cwd;
567
+ if (options.env && Object.keys(options.env).length > 0) body.env = options.env;
568
+ if (options.timeoutMs !== void 0 && options.timeoutMs > 0) body.timeoutMs = options.timeoutMs;
569
+ const headers = { "content-type": "application/json" };
570
+ if (options.bearerToken) headers.authorization = `Bearer ${options.bearerToken}`;
571
+ let response;
572
+ try {
573
+ response = await fetchImpl(url, {
574
+ method: "POST",
575
+ headers,
576
+ body: JSON.stringify(body),
577
+ signal: controller.signal
578
+ });
579
+ } catch (error) {
580
+ if (timeoutTimer) clearTimeout(timeoutTimer);
581
+ if (timedOut) return {
582
+ exitCode: 124,
583
+ stdout: "",
584
+ stderr: "",
585
+ timedOut: true,
586
+ opened: false
587
+ };
588
+ return {
589
+ exitCode: null,
590
+ stdout: "",
591
+ stderr: "",
592
+ timedOut: false,
593
+ opened: false,
594
+ transportErrorMessage: error instanceof Error ? error.message : String(error)
595
+ };
596
+ }
597
+ if (!response.ok) {
598
+ if (timeoutTimer) clearTimeout(timeoutTimer);
599
+ const text = await response.text().catch(() => "");
600
+ throw new PrivateNetExecHttpError(response.status, text);
601
+ }
602
+ if (!response.body) {
603
+ if (timeoutTimer) clearTimeout(timeoutTimer);
604
+ return {
605
+ exitCode: null,
606
+ stdout: "",
607
+ stderr: "",
608
+ timedOut: false,
609
+ opened: true,
610
+ status: response.status
611
+ };
612
+ }
613
+ let stdout = "";
614
+ let stderr = "";
615
+ let exitCode = null;
616
+ const decoder = new TextDecoder();
617
+ let buffer = "";
618
+ const handleLine = (line) => {
619
+ if (!line) return;
620
+ let frame;
621
+ try {
622
+ frame = JSON.parse(line);
623
+ } catch {
624
+ return;
625
+ }
626
+ if (!frame || typeof frame !== "object") return;
627
+ if (frame.type === "stdout" && typeof frame.data === "string") {
628
+ stdout += frame.data;
629
+ options.onStdout?.(frame.data);
630
+ } else if (frame.type === "stderr" && typeof frame.data === "string") {
631
+ stderr += frame.data;
632
+ options.onStderr?.(frame.data);
633
+ } else if (frame.type === "exit" && typeof frame.code === "number") exitCode = frame.code;
634
+ };
635
+ try {
636
+ const reader = response.body.getReader();
637
+ while (true) {
638
+ const { value, done } = await reader.read();
639
+ if (done) break;
640
+ buffer += decoder.decode(value, { stream: true });
641
+ let newlineIdx = buffer.indexOf("\n");
642
+ while (newlineIdx !== -1) {
643
+ const line = buffer.slice(0, newlineIdx).trim();
644
+ buffer = buffer.slice(newlineIdx + 1);
645
+ handleLine(line);
646
+ newlineIdx = buffer.indexOf("\n");
647
+ }
648
+ }
649
+ buffer += decoder.decode();
650
+ const trailing = buffer.trim();
651
+ if (trailing) handleLine(trailing);
652
+ } catch (error) {
653
+ if (timeoutTimer) clearTimeout(timeoutTimer);
654
+ if (timedOut) return {
655
+ exitCode: 124,
656
+ stdout,
657
+ stderr,
658
+ timedOut: true,
659
+ opened: true,
660
+ status: response.status
661
+ };
662
+ return {
663
+ exitCode,
664
+ stdout,
665
+ stderr,
666
+ timedOut: false,
667
+ opened: true,
668
+ status: response.status,
669
+ transportErrorMessage: error instanceof Error ? error.message : String(error)
670
+ };
671
+ } finally {
672
+ if (timeoutTimer) clearTimeout(timeoutTimer);
673
+ }
674
+ return {
675
+ exitCode,
676
+ stdout,
677
+ stderr,
678
+ timedOut: false,
679
+ opened: true,
680
+ status: response.status
681
+ };
682
+ }
683
+ //#endregion
515
684
  //#region src/sandbox.ts
516
685
  /**
517
686
  * How long before a lease's stated `expiresAt` we should treat it as
@@ -662,6 +831,19 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
662
831
  _instructionsOverride;
663
832
  _createdAt = null;
664
833
  _webSocketFactory;
834
+ _privateNetFetch;
835
+ /**
836
+ * Registry that maps `sandboxId → instanceUrl` for the private-network
837
+ * exec path. Injected by the composition site via
838
+ * {@link PlatformSandboxOptions.addressRegistry} and populated by this
839
+ * class itself in `start()` when the workspace-proxy's create/reattach
840
+ * response includes an `instanceUrl` field. The registry IS the cache —
841
+ * there is no per-instance mirror on `PlatformSandbox`, so every exec is
842
+ * a `Map.get()` (in the default in-process impl) against the live view.
843
+ * When absent, executes go straight to the lease path with no extra
844
+ * round-trip.
845
+ */
846
+ _addressRegistry;
665
847
  /**
666
848
  * Cached exec lease for this sandbox. `null` before the first exec and
667
849
  * after {@link destroy}. Refreshed when `expiresAt - LEASE_REFRESH_MARGIN_MS < now`
@@ -693,6 +875,8 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
693
875
  this._timeout = options.timeout;
694
876
  this._instructionsOverride = options.instructions;
695
877
  this._webSocketFactory = options.webSocketFactory;
878
+ this._privateNetFetch = options.privateNetFetch;
879
+ this._addressRegistry = options.addressRegistry;
696
880
  }
697
881
  generateId() {
698
882
  return `platform-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
@@ -723,7 +907,9 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
723
907
  env: options.env ?? this._env,
724
908
  ...this._timeout !== void 0 && { timeout: this._timeout },
725
909
  ...this._instructionsOverride !== void 0 && { instructions: this._instructionsOverride },
726
- ...this._webSocketFactory !== void 0 && { webSocketFactory: this._webSocketFactory }
910
+ ...this._webSocketFactory !== void 0 && { webSocketFactory: this._webSocketFactory },
911
+ ...this._privateNetFetch !== void 0 && { privateNetFetch: this._privateNetFetch },
912
+ ...this._addressRegistry !== void 0 && { addressRegistry: this._addressRegistry }
727
913
  });
728
914
  }
729
915
  async start() {
@@ -731,6 +917,7 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
731
917
  const json = await (await this._client.request(`/sandbox/${encodeURIComponent(this._sandboxId)}`)).json();
732
918
  if (!json.destroyedAt) {
733
919
  this._createdAt = json.createdAt ? new Date(json.createdAt) : /* @__PURE__ */ new Date();
920
+ this._populateAddressFromResponse(json);
734
921
  return;
735
922
  }
736
923
  this._sandboxId = void 0;
@@ -761,16 +948,40 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
761
948
  const json = await response.json();
762
949
  this._sandboxId = json.id;
763
950
  this._createdAt = json.createdAt ? new Date(json.createdAt) : /* @__PURE__ */ new Date();
951
+ this._populateAddressFromResponse(json);
952
+ }
953
+ /**
954
+ * Copy `response.instanceUrl` into the injected {@link SandboxAddressRegistry}
955
+ * when both are present. Called from both {@link start} branches (fresh
956
+ * provision + reattach) with the workspace-proxy response for this sandbox.
957
+ *
958
+ * The proxy discovers the IPv6 during `Sandbox.create()` and stores it in
959
+ * `environment_sandboxes.instance_url`; both the create response and
960
+ * `GET /sandbox/:id` echo the same field. The runtime does not do any
961
+ * discovery of its own — it only mirrors the field into an in-process map
962
+ * so {@link executeCommand} can `Map.get()` before every exec without an
963
+ * HTTP round-trip.
964
+ *
965
+ * `null`/absent `instanceUrl` (proxy discovery failed, or an older proxy
966
+ * that predates the field) leaves the registry untouched — executes fall
967
+ * through to the lease path with no branch here.
968
+ */
969
+ _populateAddressFromResponse(json) {
970
+ if (!this._addressRegistry) return;
971
+ if (!json.instanceUrl) return;
972
+ this._addressRegistry.set(json.id, json.instanceUrl);
764
973
  }
765
974
  async stop() {
766
975
  await this.destroy();
767
976
  }
768
977
  async destroy() {
769
978
  if (!this._sandboxId) return;
770
- await this._client.request(`/sandbox/${encodeURIComponent(this._sandboxId)}`, { method: "DELETE" });
979
+ const destroyedSandboxId = this._sandboxId;
980
+ await this._client.request(`/sandbox/${encodeURIComponent(destroyedSandboxId)}`, { method: "DELETE" });
771
981
  this._sandboxId = void 0;
772
982
  this._createdAt = null;
773
983
  this._lease = null;
984
+ this._addressRegistry?.delete(destroyedSandboxId);
774
985
  }
775
986
  /**
776
987
  * Execute a command on the remote sandbox.
@@ -795,6 +1006,22 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
795
1006
  const started = Date.now();
796
1007
  const fullCommand = buildCommand(command, args);
797
1008
  const effectiveTimeout = options?.timeout ?? this._timeout;
1009
+ const instanceUrl = this._addressRegistry?.get(this._sandboxId);
1010
+ if (instanceUrl) {
1011
+ const privateNet = await this._tryExecViaPrivateNetwork(instanceUrl, fullCommand, effectiveTimeout, options);
1012
+ if (privateNet) {
1013
+ const privateExit = privateNet.exitCode ?? 124;
1014
+ return {
1015
+ success: privateExit === 0,
1016
+ exitCode: privateExit,
1017
+ stdout: privateNet.stdout,
1018
+ stderr: privateNet.stderr,
1019
+ timedOut: privateNet.timedOut,
1020
+ command: fullCommand,
1021
+ executionTimeMs: Date.now() - started
1022
+ };
1023
+ }
1024
+ }
798
1025
  const result = await this._runDirectExec(fullCommand, effectiveTimeout, options);
799
1026
  const exitCode = result.exitCode ?? 124;
800
1027
  return {
@@ -877,6 +1104,55 @@ var PlatformSandbox = class PlatformSandbox extends _mastra_core_workspace.Mastr
877
1104
  });
878
1105
  }
879
1106
  /**
1107
+ * Try to run the exec against the in-sandbox sidecar over Railway's private
1108
+ * network. Returns the result on success (including non-zero exit codes and
1109
+ * timeouts — those are real command results, not failures). Returns
1110
+ * `undefined` when the caller should fall back to the lease path:
1111
+ *
1112
+ * - Transport failure (connection refused, mid-stream drop, no `exit`
1113
+ * frame). The registry entry is evicted so subsequent execs skip the
1114
+ * private-net dial until the sidecar re-registers.
1115
+ * - Sidecar answered with a non-2xx HTTP status. Registry is left intact —
1116
+ * the address is still valid; something else is wrong (bad request,
1117
+ * sidecar bug). Only this specific exec falls back.
1118
+ */
1119
+ async _tryExecViaPrivateNetwork(instanceUrl, fullCommand, effectiveTimeout, options) {
1120
+ const filteredEnv = options?.env ? Object.fromEntries(Object.entries(options.env).filter((entry) => entry[1] !== void 0)) : void 0;
1121
+ const execOptions = {
1122
+ command: fullCommand,
1123
+ ...options?.cwd !== void 0 && { cwd: options.cwd },
1124
+ ...filteredEnv !== void 0 && { env: filteredEnv },
1125
+ ...effectiveTimeout != null && effectiveTimeout > 0 && { timeoutMs: effectiveTimeout },
1126
+ ...this._privateNetFetch && { fetch: this._privateNetFetch }
1127
+ };
1128
+ let result;
1129
+ try {
1130
+ result = await execViaPrivateNetwork(instanceUrl, execOptions);
1131
+ } catch (error) {
1132
+ if (error instanceof PrivateNetExecHttpError) return;
1133
+ this._invalidateAddress();
1134
+ return;
1135
+ }
1136
+ if (result.timedOut) {
1137
+ if (!result.opened) this._invalidateAddress();
1138
+ return result;
1139
+ }
1140
+ if (!result.opened || result.exitCode === null) {
1141
+ this._invalidateAddress();
1142
+ return;
1143
+ }
1144
+ return result;
1145
+ }
1146
+ /**
1147
+ * Evict this sandbox's entry from the address registry after an observed
1148
+ * transport failure. The entry stays gone until the next start() re-reads
1149
+ * `instanceUrl` from a workspace-proxy response — until then, execs skip
1150
+ * the private-net dial and go straight to the lease path.
1151
+ */
1152
+ _invalidateAddress() {
1153
+ if (this._sandboxId) this._addressRegistry?.delete(this._sandboxId);
1154
+ }
1155
+ /**
880
1156
  * Return a cached exec lease, minting a fresh one when the cache is empty
881
1157
  * or the JWT is within {@link LEASE_REFRESH_MARGIN_MS} of `expiresAt`.
882
1158
  *
@@ -1018,12 +1294,48 @@ const platformFilesystemProvider = {
1018
1294
  createFilesystem: (config) => new PlatformFilesystem(config)
1019
1295
  };
1020
1296
  //#endregion
1297
+ //#region src/address-registry.ts
1298
+ /**
1299
+ * Concrete in-process {@link SandboxAddressRegistry}. Backed by a `Map`; no
1300
+ * eviction policy, no TTL — entries live until an observed transport failure
1301
+ * calls `delete`, until the sandbox is explicitly destroyed, or until the
1302
+ * process exits.
1303
+ */
1304
+ var InProcessSandboxAddressRegistry = class {
1305
+ #map = /* @__PURE__ */ new Map();
1306
+ get(sandboxId) {
1307
+ return this.#map.get(sandboxId);
1308
+ }
1309
+ /**
1310
+ * Populate or overwrite the address for a sandbox. Called by
1311
+ * {@link PlatformSandbox.start} on every fresh provision and every reattach;
1312
+ * overwriting is intentional so a re-provision with a fresh IPv6 heals the
1313
+ * map without a branch.
1314
+ */
1315
+ set(sandboxId, instanceUrl) {
1316
+ this.#map.set(sandboxId, instanceUrl);
1317
+ }
1318
+ delete(sandboxId) {
1319
+ this.#map.delete(sandboxId);
1320
+ }
1321
+ /**
1322
+ * Test-only introspection. Not part of {@link SandboxAddressRegistry} —
1323
+ * production callers must not read the registry as a whole.
1324
+ */
1325
+ get size() {
1326
+ return this.#map.size;
1327
+ }
1328
+ };
1329
+ //#endregion
1330
+ exports.InProcessSandboxAddressRegistry = InProcessSandboxAddressRegistry;
1021
1331
  exports.PlatformApiError = PlatformApiError;
1022
1332
  exports.PlatformClient = PlatformClient;
1023
1333
  exports.PlatformFilesystem = PlatformFilesystem;
1024
1334
  exports.PlatformSandbox = PlatformSandbox;
1335
+ exports.PrivateNetExecHttpError = PrivateNetExecHttpError;
1025
1336
  exports.SandboxDestroyedError = SandboxDestroyedError;
1026
1337
  exports.SandboxExecTransportError = SandboxExecTransportError;
1338
+ exports.execViaPrivateNetwork = execViaPrivateNetwork;
1027
1339
  exports.platformFilesystemProvider = platformFilesystemProvider;
1028
1340
  exports.platformSandboxProvider = platformSandboxProvider;
1029
1341