@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/dist/index.js CHANGED
@@ -488,6 +488,175 @@ function execViaLease(lease, options) {
488
488
  });
489
489
  }
490
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
491
660
  //#region src/sandbox.ts
492
661
  /**
493
662
  * How long before a lease's stated `expiresAt` we should treat it as
@@ -638,6 +807,19 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
638
807
  _instructionsOverride;
639
808
  _createdAt = null;
640
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;
641
823
  /**
642
824
  * Cached exec lease for this sandbox. `null` before the first exec and
643
825
  * after {@link destroy}. Refreshed when `expiresAt - LEASE_REFRESH_MARGIN_MS < now`
@@ -669,6 +851,8 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
669
851
  this._timeout = options.timeout;
670
852
  this._instructionsOverride = options.instructions;
671
853
  this._webSocketFactory = options.webSocketFactory;
854
+ this._privateNetFetch = options.privateNetFetch;
855
+ this._addressRegistry = options.addressRegistry;
672
856
  }
673
857
  generateId() {
674
858
  return `platform-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
@@ -699,7 +883,9 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
699
883
  env: options.env ?? this._env,
700
884
  ...this._timeout !== void 0 && { timeout: this._timeout },
701
885
  ...this._instructionsOverride !== void 0 && { instructions: this._instructionsOverride },
702
- ...this._webSocketFactory !== void 0 && { webSocketFactory: this._webSocketFactory }
886
+ ...this._webSocketFactory !== void 0 && { webSocketFactory: this._webSocketFactory },
887
+ ...this._privateNetFetch !== void 0 && { privateNetFetch: this._privateNetFetch },
888
+ ...this._addressRegistry !== void 0 && { addressRegistry: this._addressRegistry }
703
889
  });
704
890
  }
705
891
  async start() {
@@ -707,6 +893,7 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
707
893
  const json = await (await this._client.request(`/sandbox/${encodeURIComponent(this._sandboxId)}`)).json();
708
894
  if (!json.destroyedAt) {
709
895
  this._createdAt = json.createdAt ? new Date(json.createdAt) : /* @__PURE__ */ new Date();
896
+ this._populateAddressFromResponse(json);
710
897
  return;
711
898
  }
712
899
  this._sandboxId = void 0;
@@ -737,16 +924,40 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
737
924
  const json = await response.json();
738
925
  this._sandboxId = json.id;
739
926
  this._createdAt = json.createdAt ? new Date(json.createdAt) : /* @__PURE__ */ new Date();
927
+ this._populateAddressFromResponse(json);
928
+ }
929
+ /**
930
+ * Copy `response.instanceUrl` into the injected {@link SandboxAddressRegistry}
931
+ * when both are present. Called from both {@link start} branches (fresh
932
+ * provision + reattach) with the workspace-proxy response for this sandbox.
933
+ *
934
+ * The proxy discovers the IPv6 during `Sandbox.create()` and stores it in
935
+ * `environment_sandboxes.instance_url`; both the create response and
936
+ * `GET /sandbox/:id` echo the same field. The runtime does not do any
937
+ * discovery of its own — it only mirrors the field into an in-process map
938
+ * so {@link executeCommand} can `Map.get()` before every exec without an
939
+ * HTTP round-trip.
940
+ *
941
+ * `null`/absent `instanceUrl` (proxy discovery failed, or an older proxy
942
+ * that predates the field) leaves the registry untouched — executes fall
943
+ * through to the lease path with no branch here.
944
+ */
945
+ _populateAddressFromResponse(json) {
946
+ if (!this._addressRegistry) return;
947
+ if (!json.instanceUrl) return;
948
+ this._addressRegistry.set(json.id, json.instanceUrl);
740
949
  }
741
950
  async stop() {
742
951
  await this.destroy();
743
952
  }
744
953
  async destroy() {
745
954
  if (!this._sandboxId) return;
746
- await this._client.request(`/sandbox/${encodeURIComponent(this._sandboxId)}`, { method: "DELETE" });
955
+ const destroyedSandboxId = this._sandboxId;
956
+ await this._client.request(`/sandbox/${encodeURIComponent(destroyedSandboxId)}`, { method: "DELETE" });
747
957
  this._sandboxId = void 0;
748
958
  this._createdAt = null;
749
959
  this._lease = null;
960
+ this._addressRegistry?.delete(destroyedSandboxId);
750
961
  }
751
962
  /**
752
963
  * Execute a command on the remote sandbox.
@@ -771,6 +982,22 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
771
982
  const started = Date.now();
772
983
  const fullCommand = buildCommand(command, args);
773
984
  const effectiveTimeout = options?.timeout ?? this._timeout;
985
+ const instanceUrl = this._addressRegistry?.get(this._sandboxId);
986
+ if (instanceUrl) {
987
+ const privateNet = await this._tryExecViaPrivateNetwork(instanceUrl, fullCommand, effectiveTimeout, options);
988
+ if (privateNet) {
989
+ const privateExit = privateNet.exitCode ?? 124;
990
+ return {
991
+ success: privateExit === 0,
992
+ exitCode: privateExit,
993
+ stdout: privateNet.stdout,
994
+ stderr: privateNet.stderr,
995
+ timedOut: privateNet.timedOut,
996
+ command: fullCommand,
997
+ executionTimeMs: Date.now() - started
998
+ };
999
+ }
1000
+ }
774
1001
  const result = await this._runDirectExec(fullCommand, effectiveTimeout, options);
775
1002
  const exitCode = result.exitCode ?? 124;
776
1003
  return {
@@ -853,6 +1080,55 @@ var PlatformSandbox = class PlatformSandbox extends MastraSandbox {
853
1080
  });
854
1081
  }
855
1082
  /**
1083
+ * Try to run the exec against the in-sandbox sidecar over Railway's private
1084
+ * network. Returns the result on success (including non-zero exit codes and
1085
+ * timeouts — those are real command results, not failures). Returns
1086
+ * `undefined` when the caller should fall back to the lease path:
1087
+ *
1088
+ * - Transport failure (connection refused, mid-stream drop, no `exit`
1089
+ * frame). The registry entry is evicted so subsequent execs skip the
1090
+ * private-net dial until the sidecar re-registers.
1091
+ * - Sidecar answered with a non-2xx HTTP status. Registry is left intact —
1092
+ * the address is still valid; something else is wrong (bad request,
1093
+ * sidecar bug). Only this specific exec falls back.
1094
+ */
1095
+ async _tryExecViaPrivateNetwork(instanceUrl, fullCommand, effectiveTimeout, options) {
1096
+ const filteredEnv = options?.env ? Object.fromEntries(Object.entries(options.env).filter((entry) => entry[1] !== void 0)) : void 0;
1097
+ const execOptions = {
1098
+ command: fullCommand,
1099
+ ...options?.cwd !== void 0 && { cwd: options.cwd },
1100
+ ...filteredEnv !== void 0 && { env: filteredEnv },
1101
+ ...effectiveTimeout != null && effectiveTimeout > 0 && { timeoutMs: effectiveTimeout },
1102
+ ...this._privateNetFetch && { fetch: this._privateNetFetch }
1103
+ };
1104
+ let result;
1105
+ try {
1106
+ result = await execViaPrivateNetwork(instanceUrl, execOptions);
1107
+ } catch (error) {
1108
+ if (error instanceof PrivateNetExecHttpError) return;
1109
+ this._invalidateAddress();
1110
+ return;
1111
+ }
1112
+ if (result.timedOut) {
1113
+ if (!result.opened) this._invalidateAddress();
1114
+ return result;
1115
+ }
1116
+ if (!result.opened || result.exitCode === null) {
1117
+ this._invalidateAddress();
1118
+ return;
1119
+ }
1120
+ return result;
1121
+ }
1122
+ /**
1123
+ * Evict this sandbox's entry from the address registry after an observed
1124
+ * transport failure. The entry stays gone until the next start() re-reads
1125
+ * `instanceUrl` from a workspace-proxy response — until then, execs skip
1126
+ * the private-net dial and go straight to the lease path.
1127
+ */
1128
+ _invalidateAddress() {
1129
+ if (this._sandboxId) this._addressRegistry?.delete(this._sandboxId);
1130
+ }
1131
+ /**
856
1132
  * Return a cached exec lease, minting a fresh one when the cache is empty
857
1133
  * or the JWT is within {@link LEASE_REFRESH_MARGIN_MS} of `expiresAt`.
858
1134
  *
@@ -994,6 +1270,39 @@ const platformFilesystemProvider = {
994
1270
  createFilesystem: (config) => new PlatformFilesystem(config)
995
1271
  };
996
1272
  //#endregion
997
- export { PlatformApiError, PlatformClient, PlatformFilesystem, PlatformSandbox, SandboxDestroyedError, SandboxExecTransportError, platformFilesystemProvider, platformSandboxProvider };
1273
+ //#region src/address-registry.ts
1274
+ /**
1275
+ * Concrete in-process {@link SandboxAddressRegistry}. Backed by a `Map`; no
1276
+ * eviction policy, no TTL — entries live until an observed transport failure
1277
+ * calls `delete`, until the sandbox is explicitly destroyed, or until the
1278
+ * process exits.
1279
+ */
1280
+ var InProcessSandboxAddressRegistry = class {
1281
+ #map = /* @__PURE__ */ new Map();
1282
+ get(sandboxId) {
1283
+ return this.#map.get(sandboxId);
1284
+ }
1285
+ /**
1286
+ * Populate or overwrite the address for a sandbox. Called by
1287
+ * {@link PlatformSandbox.start} on every fresh provision and every reattach;
1288
+ * overwriting is intentional so a re-provision with a fresh IPv6 heals the
1289
+ * map without a branch.
1290
+ */
1291
+ set(sandboxId, instanceUrl) {
1292
+ this.#map.set(sandboxId, instanceUrl);
1293
+ }
1294
+ delete(sandboxId) {
1295
+ this.#map.delete(sandboxId);
1296
+ }
1297
+ /**
1298
+ * Test-only introspection. Not part of {@link SandboxAddressRegistry} —
1299
+ * production callers must not read the registry as a whole.
1300
+ */
1301
+ get size() {
1302
+ return this.#map.size;
1303
+ }
1304
+ };
1305
+ //#endregion
1306
+ export { InProcessSandboxAddressRegistry, PlatformApiError, PlatformClient, PlatformFilesystem, PlatformSandbox, PrivateNetExecHttpError, SandboxDestroyedError, SandboxExecTransportError, execViaPrivateNetwork, platformFilesystemProvider, platformSandboxProvider };
998
1307
 
999
1308
  //# sourceMappingURL=index.js.map