@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 +76 -0
- package/dist/address-registry.d.ts +47 -0
- package/dist/address-registry.d.ts.map +1 -0
- package/dist/index.cjs +314 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +312 -3
- package/dist/index.js.map +1 -1
- package/dist/private-net-exec.d.ts +114 -0
- package/dist/private-net-exec.d.ts.map +1 -0
- package/dist/sandbox.d.ts +96 -0
- package/dist/sandbox.d.ts.map +1 -1
- package/package.json +4 -4
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
|
-
|
|
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
|
-
|
|
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
|