@sandboxrouter/sdk 0.2.0 → 0.3.1
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/README.md +229 -50
- package/dist/bench.js +4 -4
- package/dist/catalog.d.ts +159 -0
- package/dist/catalog.js +450 -15
- package/dist/check.d.ts +28 -0
- package/dist/check.js +185 -0
- package/dist/cli.js +171 -12
- package/dist/computers.d.ts +55 -13
- package/dist/computers.js +132 -6
- package/dist/conformance.d.ts +46 -0
- package/dist/conformance.js +65 -0
- package/dist/cost.d.ts +67 -0
- package/dist/cost.js +153 -0
- package/dist/gc.d.ts +29 -0
- package/dist/gc.js +54 -0
- package/dist/index.d.ts +28 -2
- package/dist/index.js +28 -2
- package/dist/leaderboard.d.ts +6 -0
- package/dist/leaderboard.js +5 -4
- package/dist/ledger.d.ts +38 -0
- package/dist/ledger.js +89 -0
- package/dist/providers/agent37.d.ts +40 -0
- package/dist/providers/agent37.js +224 -0
- package/dist/providers/agentcore.d.ts +71 -0
- package/dist/providers/agentcore.js +446 -0
- package/dist/providers/alibaba.d.ts +18 -0
- package/dist/providers/alibaba.js +106 -0
- package/dist/providers/azure.d.ts +43 -0
- package/dist/providers/azure.js +330 -0
- package/dist/providers/beam.d.ts +11 -0
- package/dist/providers/beam.js +179 -0
- package/dist/providers/blaxel.d.ts +3 -1
- package/dist/providers/blaxel.js +32 -22
- package/dist/providers/boat.js +8 -3
- package/dist/providers/cloudflare.js +8 -3
- package/dist/providers/codesandbox.d.ts +59 -0
- package/dist/providers/codesandbox.js +287 -0
- package/dist/providers/daytona.d.ts +25 -4
- package/dist/providers/daytona.js +449 -28
- package/dist/providers/dedalus.d.ts +12 -0
- package/dist/providers/dedalus.js +244 -0
- package/dist/providers/deno.d.ts +50 -0
- package/dist/providers/deno.js +170 -0
- package/dist/providers/docker.js +11 -5
- package/dist/providers/e2b.d.ts +70 -3
- package/dist/providers/e2b.js +376 -8
- package/dist/providers/exedev.js +17 -5
- package/dist/providers/freestyle.d.ts +49 -0
- package/dist/providers/freestyle.js +261 -0
- package/dist/providers/hopx.js +27 -18
- package/dist/providers/instabox.d.ts +76 -0
- package/dist/providers/instabox.js +360 -0
- package/dist/providers/koyeb.d.ts +71 -0
- package/dist/providers/koyeb.js +379 -0
- package/dist/providers/lambda.d.ts +51 -0
- package/dist/providers/lambda.js +467 -0
- package/dist/providers/langsmith.d.ts +16 -0
- package/dist/providers/langsmith.js +149 -0
- package/dist/providers/limrun.d.ts +77 -0
- package/dist/providers/limrun.js +626 -0
- package/dist/providers/maritime.d.ts +5 -1
- package/dist/providers/maritime.js +39 -8
- package/dist/providers/mobilerun.d.ts +44 -0
- package/dist/providers/mobilerun.js +417 -0
- package/dist/providers/modal.js +12 -4
- package/dist/providers/morph.js +12 -4
- package/dist/providers/northflank.js +11 -13
- package/dist/providers/novita.d.ts +12 -0
- package/dist/providers/novita.js +81 -0
- package/dist/providers/openscale.d.ts +17 -0
- package/dist/providers/openscale.js +87 -0
- package/dist/providers/orgo.d.ts +45 -0
- package/dist/providers/orgo.js +414 -0
- package/dist/providers/sail.d.ts +32 -0
- package/dist/providers/sail.js +372 -0
- package/dist/providers/superserve.d.ts +25 -0
- package/dist/providers/superserve.js +337 -0
- package/dist/providers/tensorlake.js +17 -14
- package/dist/providers/upstash.d.ts +30 -0
- package/dist/providers/upstash.js +258 -0
- package/dist/providers/vercel.js +14 -3
- package/dist/providers/workspaces.d.ts +60 -0
- package/dist/providers/workspaces.js +662 -0
- package/dist/router.d.ts +41 -5
- package/dist/router.js +179 -23
- package/dist/server.d.ts +1 -1
- package/dist/server.js +22 -3
- package/dist/types.d.ts +13 -5
- package/dist/util.d.ts +28 -1
- package/dist/util.js +56 -7
- package/package.json +81 -5
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, writeFile as fsWriteFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { ProviderError } from "../types.js";
|
|
5
|
+
import { timeoutError, wrapCommand } from "../util.js";
|
|
6
|
+
import { AwsClient, awsCredentialsHinted } from "./agentcore.js";
|
|
7
|
+
/**
|
|
8
|
+
* AWS Lambda MicroVMs (launched 2026-06-22): Firecracker VMs started from a snapshot of an image you
|
|
9
|
+
* build, each with its own HTTPS endpoint, suspend/resume and up to 8 hours of life.
|
|
10
|
+
*
|
|
11
|
+
* Control plane (SigV4, signing name "lambda", host https://lambda.{region}.amazonaws.com, rest-json):
|
|
12
|
+
* POST /2025-09-09/microvms RunMicrovm {imageIdentifier, imageVersion?, idlePolicy, maximumDurationInSeconds,
|
|
13
|
+
* ingressNetworkConnectors, egressNetworkConnectors, executionRoleArn?, clientToken}
|
|
14
|
+
* -> {microvmId, endpoint, state, ...}
|
|
15
|
+
* GET /2025-09-09/microvms/{id} GetMicrovm -> {state: PENDING|RUNNING|SUSPENDING|SUSPENDED|TERMINATING|TERMINATED, endpoint, ...}
|
|
16
|
+
* POST /2025-09-09/microvms/{id}/auth-token CreateMicrovmAuthToken {expirationInMinutes, allowedPorts:[{port}]} -> {authToken: {"X-aws-proxy-auth": jwe}}
|
|
17
|
+
* POST /2025-09-09/microvms/{id}/suspend | /resume SuspendMicrovm / ResumeMicrovm
|
|
18
|
+
* DELETE /2025-09-09/microvms/{id} TerminateMicrovm
|
|
19
|
+
* Data plane: https://{endpoint}/... with the auth-token entries as headers and X-aws-proxy-port.
|
|
20
|
+
*
|
|
21
|
+
* Lambda gives a MicroVM no exec or file API of its own: the endpoint reaches whatever server your image
|
|
22
|
+
* runs. So the image must run the small server in LAMBDA_MICROVM_SERVER below (Node.js, no deps), which
|
|
23
|
+
* speaks this adapter's protocol on port 8080:
|
|
24
|
+
* POST /sbr/v1/exec {cmd, cwd?, env?, timeoutSeconds} -> NDJSON {"stdout":b64} | {"stderr":b64} ... {"exitCode":N} | {"error":msg}
|
|
25
|
+
* PUT /sbr/v1/files?path=/abs raw body -> 204
|
|
26
|
+
* GET /sbr/v1/files?path=/abs -> raw bytes | 404
|
|
27
|
+
* GET /sbr/v1/health -> 200
|
|
28
|
+
* It also answers the lifecycle hooks under /aws/lambda-microvms/runtime/v1/ with 200.
|
|
29
|
+
*
|
|
30
|
+
* Docs:
|
|
31
|
+
* https://docs.aws.amazon.com/lambda/latest/dg/lambda-microvms-guide.html
|
|
32
|
+
* https://docs.aws.amazon.com/lambda/latest/dg/microvms-launching.html
|
|
33
|
+
* https://docs.aws.amazon.com/lambda/latest/dg/microvms-images.html (sizes, base images)
|
|
34
|
+
* https://docs.aws.amazon.com/lambda/latest/dg/microvms-getting-started.html
|
|
35
|
+
* https://docs.aws.amazon.com/lambda/latest/microvm-api/API_Operations.html
|
|
36
|
+
* https://github.com/boto/botocore/blob/develop/botocore/data/lambda-microvms/2025-09-09/service-2.json (signing name, paths)
|
|
37
|
+
* https://aws.amazon.com/lambda/pricing/
|
|
38
|
+
* https://aws.amazon.com/about-aws/whats-new/2026/06/aws-lambda-microvms/
|
|
39
|
+
*
|
|
40
|
+
* Mapping:
|
|
41
|
+
* - Size is a property of the image (resources[].minimumMemoryInMiB, 2 GB per vCPU, bursts to 4x), not
|
|
42
|
+
* of a run. Set LAMBDA_MICROVM_MEMORY_GB to the image's baseline and create() refuses specs above the
|
|
43
|
+
* 4x burst so the router falls back; otherwise vcpu/memoryGb are not enforced.
|
|
44
|
+
* - spec.image.lambda: run that image ARN/ID instead of LAMBDA_MICROVM_IMAGE ("custom-image").
|
|
45
|
+
* - timeoutMinutes -> maximumDurationInSeconds (max 28800). pause() -> SuspendMicrovm (memory and disk
|
|
46
|
+
* kept); the next call resumes it.
|
|
47
|
+
* - env: exported in front of each command (images take env only at build time).
|
|
48
|
+
*
|
|
49
|
+
* ## Setup (once per region; ARM64 only)
|
|
50
|
+
*
|
|
51
|
+
* 1. Write the server bundle and zip it:
|
|
52
|
+
* import { writeLambdaMicrovmBundle } from "./src/providers/lambda.js"; await writeLambdaMicrovmBundle("sbr-mvm")
|
|
53
|
+
* cd sbr-mvm && zip -r ../sbr-mvm.zip . && aws s3 cp ../sbr-mvm.zip s3://<bucket>/sbr-mvm.zip
|
|
54
|
+
* (Dockerfile: FROM public.ecr.aws/docker/library/node:22-bookworm-slim, CMD node /opt/sbr/server.mjs.
|
|
55
|
+
* Add whatever tools your agents need.)
|
|
56
|
+
* 2. A build role Lambda can assume (lambda.amazonaws.com) with s3:GetObject on the zip.
|
|
57
|
+
* 3. aws lambda-microvms list-managed-microvm-images # base image ARN, e.g. arn:aws:lambda:<region>:aws:microvm-image:al2023-1
|
|
58
|
+
* aws lambda-microvms create-microvm-image --name sbr-sandbox \
|
|
59
|
+
* --code-artifact uri=s3://<bucket>/sbr-mvm.zip --base-image-arn <base-arn> --build-role-arn <role-arn> \
|
|
60
|
+
* --resources '[{"minimumMemoryInMiB":4096}]' --cpu-configurations '[{"architecture":"ARM_64"}]'
|
|
61
|
+
* Wait for the image state CREATED, then set LAMBDA_MICROVM_IMAGE=<image arn>, AWS_REGION, and
|
|
62
|
+
* LAMBDA_MICROVM_MEMORY_GB=4 (the baseline you chose). The caller needs lambda:RunMicrovm, GetMicrovm,
|
|
63
|
+
* CreateMicrovmAuthToken, SuspendMicrovm, ResumeMicrovm and TerminateMicrovm.
|
|
64
|
+
*/
|
|
65
|
+
const ID = "lambda";
|
|
66
|
+
const API = "/2025-09-09/microvms";
|
|
67
|
+
const PORT = 8080;
|
|
68
|
+
const MICROVM_ID = /^[A-Za-z0-9._:-]{1,256}$/;
|
|
69
|
+
export class LambdaMicrovmProvider {
|
|
70
|
+
id = ID;
|
|
71
|
+
region;
|
|
72
|
+
image;
|
|
73
|
+
/** @internal */ opts;
|
|
74
|
+
client;
|
|
75
|
+
constructor(opts = {}) {
|
|
76
|
+
this.opts = opts;
|
|
77
|
+
this.region = opts.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION;
|
|
78
|
+
this.image = opts.image ?? process.env.LAMBDA_MICROVM_IMAGE;
|
|
79
|
+
}
|
|
80
|
+
configured() {
|
|
81
|
+
return !!(this.region && this.image) && (!!this.opts.credentials || awsCredentialsHinted());
|
|
82
|
+
}
|
|
83
|
+
/** @internal */
|
|
84
|
+
aws() {
|
|
85
|
+
if (!this.region)
|
|
86
|
+
throw new ProviderError(ID, "set AWS_REGION");
|
|
87
|
+
this.client ??= new AwsClient(ID, "lambda", this.region, this.opts.credentials);
|
|
88
|
+
return this.client;
|
|
89
|
+
}
|
|
90
|
+
/** @internal */
|
|
91
|
+
url(path = "") {
|
|
92
|
+
const base = this.opts.endpoint ?? `https://lambda.${this.region}.amazonaws.com`;
|
|
93
|
+
return new URL(`${base.replace(/\/+$/, "")}${API}${path}`);
|
|
94
|
+
}
|
|
95
|
+
/** @internal */
|
|
96
|
+
get pollMs() {
|
|
97
|
+
return this.opts.pollMs ?? 1000;
|
|
98
|
+
}
|
|
99
|
+
/** @internal GetMicrovm until it settles in RUNNING (or fails). */
|
|
100
|
+
async waitRunning(id) {
|
|
101
|
+
const deadline = Date.now() + (this.opts.readyTimeoutMs ?? 180_000);
|
|
102
|
+
for (;;) {
|
|
103
|
+
const vm = await this.aws().json("GET", this.url(`/${encodeURIComponent(id)}`));
|
|
104
|
+
if (vm.state === "RUNNING")
|
|
105
|
+
return vm;
|
|
106
|
+
if (vm.state === "TERMINATING" || vm.state === "TERMINATED")
|
|
107
|
+
throw new ProviderError(ID, `MicroVM ${id} is ${vm.state}${vm.stateReason ? `: ${vm.stateReason}` : ""}`, 404);
|
|
108
|
+
if (Date.now() > deadline)
|
|
109
|
+
throw new ProviderError(ID, `MicroVM ${id} still ${vm.state} after ${(this.opts.readyTimeoutMs ?? 180_000) / 1000}s`);
|
|
110
|
+
await sleep(this.pollMs);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async create(spec) {
|
|
114
|
+
const image = spec.image?.lambda ?? this.image;
|
|
115
|
+
if (!image)
|
|
116
|
+
throw new ProviderError(ID, "set LAMBDA_MICROVM_IMAGE (see src/providers/lambda.ts for the one-time image build)");
|
|
117
|
+
const base = this.opts.memoryGb ?? (process.env.LAMBDA_MICROVM_MEMORY_GB ? Number(process.env.LAMBDA_MICROVM_MEMORY_GB) : undefined);
|
|
118
|
+
if (base) {
|
|
119
|
+
const peakMem = base * 4;
|
|
120
|
+
const peakCpu = (base / 2) * 4;
|
|
121
|
+
if ((spec.memoryGb ?? 0) > peakMem || (spec.vcpu ?? 0) > peakCpu) {
|
|
122
|
+
throw new ProviderError(ID, `image baseline ${base} GB bursts to ${peakCpu} vCPU / ${peakMem} GB; asked for ${spec.vcpu ?? "-"} vCPU / ${spec.memoryGb ?? "-"} GB`, 400);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const maxDuration = Math.min(28_800, Math.max(60, Math.round((spec.timeoutMinutes ?? 30) * 60)));
|
|
126
|
+
const egress = this.opts.egressConnectors ??
|
|
127
|
+
(process.env.LAMBDA_MICROVM_EGRESS_CONNECTORS !== undefined
|
|
128
|
+
? process.env.LAMBDA_MICROVM_EGRESS_CONNECTORS.split(",").map((s) => s.trim()).filter(Boolean)
|
|
129
|
+
: [connector(this.region, "INTERNET_EGRESS")]);
|
|
130
|
+
const idle = Math.max(60, this.opts.idleSeconds ?? 900);
|
|
131
|
+
const body = {
|
|
132
|
+
imageIdentifier: image,
|
|
133
|
+
ingressNetworkConnectors: [connector(this.region, "HTTP_INGRESS")],
|
|
134
|
+
egressNetworkConnectors: egress,
|
|
135
|
+
maximumDurationInSeconds: maxDuration,
|
|
136
|
+
idlePolicy: { autoResumeEnabled: true, maxIdleDurationSeconds: idle, suspendedDurationSeconds: maxDuration },
|
|
137
|
+
clientToken: randomBytes(16).toString("hex"),
|
|
138
|
+
};
|
|
139
|
+
const version = this.opts.imageVersion ?? process.env.LAMBDA_MICROVM_IMAGE_VERSION;
|
|
140
|
+
if (version && !spec.image?.lambda)
|
|
141
|
+
body.imageVersion = version;
|
|
142
|
+
const role = this.opts.executionRoleArn ?? process.env.LAMBDA_MICROVM_EXECUTION_ROLE_ARN;
|
|
143
|
+
if (role)
|
|
144
|
+
body.executionRoleArn = role;
|
|
145
|
+
const run = await this.aws().json("POST", this.url(), { json: body });
|
|
146
|
+
if (!run.microvmId)
|
|
147
|
+
throw new ProviderError(ID, "RunMicrovm returned no microvmId");
|
|
148
|
+
const box = new LambdaMicrovm(this, run.microvmId, run.endpoint, spec.env);
|
|
149
|
+
try {
|
|
150
|
+
const vm = run.state === "RUNNING" && run.endpoint ? run : await this.waitRunning(run.microvmId);
|
|
151
|
+
box.setEndpoint(vm.endpoint);
|
|
152
|
+
await box.waitHealthy();
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
await box.kill().catch(() => { });
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
return box;
|
|
159
|
+
}
|
|
160
|
+
async connect(nativeId) {
|
|
161
|
+
if (!MICROVM_ID.test(nativeId))
|
|
162
|
+
throw new ProviderError(ID, `invalid MicroVM id: ${nativeId}`);
|
|
163
|
+
const box = new LambdaMicrovm(this, nativeId);
|
|
164
|
+
await box.resumeIfNeeded();
|
|
165
|
+
return box;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
class LambdaMicrovm {
|
|
169
|
+
p;
|
|
170
|
+
nativeId;
|
|
171
|
+
endpoint;
|
|
172
|
+
env;
|
|
173
|
+
token;
|
|
174
|
+
paused = false;
|
|
175
|
+
constructor(p, nativeId, endpoint, env) {
|
|
176
|
+
this.p = p;
|
|
177
|
+
this.nativeId = nativeId;
|
|
178
|
+
this.endpoint = endpoint;
|
|
179
|
+
this.env = env;
|
|
180
|
+
}
|
|
181
|
+
/** @internal */
|
|
182
|
+
setEndpoint(e) {
|
|
183
|
+
if (e)
|
|
184
|
+
this.endpoint = e;
|
|
185
|
+
}
|
|
186
|
+
base() {
|
|
187
|
+
if (!this.endpoint)
|
|
188
|
+
throw new ProviderError(ID, `MicroVM ${this.nativeId} has no endpoint`);
|
|
189
|
+
return /^https?:\/\//.test(this.endpoint) ? this.endpoint.replace(/\/+$/, "") : `https://${this.endpoint.replace(/\/+$/, "")}`;
|
|
190
|
+
}
|
|
191
|
+
/** @internal Resume a suspended VM (and learn its endpoint). */
|
|
192
|
+
async resumeIfNeeded() {
|
|
193
|
+
const vm = await this.p.aws().json("GET", this.p.url(`/${encodeURIComponent(this.nativeId)}`));
|
|
194
|
+
if (vm.state === "TERMINATING" || vm.state === "TERMINATED")
|
|
195
|
+
throw new ProviderError(ID, `MicroVM ${this.nativeId} is ${vm.state}`, 404);
|
|
196
|
+
if (vm.state === "SUSPENDED" || vm.state === "SUSPENDING") {
|
|
197
|
+
await this.p.aws().json("POST", this.p.url(`/${encodeURIComponent(this.nativeId)}/resume`), { json: {} });
|
|
198
|
+
}
|
|
199
|
+
this.setEndpoint((vm.state === "RUNNING" ? vm : await this.p.waitRunning(this.nativeId)).endpoint);
|
|
200
|
+
this.paused = false;
|
|
201
|
+
}
|
|
202
|
+
/** Auth headers from CreateMicrovmAuthToken, cached until 5 minutes before they expire. */
|
|
203
|
+
async auth() {
|
|
204
|
+
if (this.paused)
|
|
205
|
+
await this.resumeIfNeeded();
|
|
206
|
+
if (!this.endpoint)
|
|
207
|
+
await this.resumeIfNeeded();
|
|
208
|
+
if (this.token && this.token.expiresAt > Date.now())
|
|
209
|
+
return this.token.headers;
|
|
210
|
+
const minutes = 60;
|
|
211
|
+
const r = await this.p.aws().json("POST", this.p.url(`/${encodeURIComponent(this.nativeId)}/auth-token`), {
|
|
212
|
+
json: { expirationInMinutes: minutes, allowedPorts: [{ port: PORT }] },
|
|
213
|
+
});
|
|
214
|
+
if (!r.authToken || !Object.keys(r.authToken).length)
|
|
215
|
+
throw new ProviderError(ID, "CreateMicrovmAuthToken returned no token");
|
|
216
|
+
this.token = { headers: { ...r.authToken, "X-aws-proxy-port": String(PORT) }, expiresAt: Date.now() + (minutes - 5) * 60_000 };
|
|
217
|
+
return this.token.headers;
|
|
218
|
+
}
|
|
219
|
+
async vmFetch(method, path, init = {}) {
|
|
220
|
+
const headers = { ...(await this.auth()), ...init.headers };
|
|
221
|
+
try {
|
|
222
|
+
return await fetch(`${this.base()}${path}`, {
|
|
223
|
+
method,
|
|
224
|
+
headers,
|
|
225
|
+
body: init.body,
|
|
226
|
+
signal: init.timeoutMs ? AbortSignal.timeout(init.timeoutMs) : undefined,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
throw new ProviderError(ID, `${method} ${path}: ${err.message}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async fail(res, what) {
|
|
234
|
+
const text = (await res.text().catch(() => "")).slice(0, 300);
|
|
235
|
+
let detail = text;
|
|
236
|
+
try {
|
|
237
|
+
detail = JSON.parse(text)?.error ?? text;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
/* plain text */
|
|
241
|
+
}
|
|
242
|
+
throw new ProviderError(ID, `${what} -> ${res.status} ${detail}`.trim(), res.status);
|
|
243
|
+
}
|
|
244
|
+
/** @internal The endpoint answers 502/503 until the server is up (and the /run hook has returned). */
|
|
245
|
+
async waitHealthy(timeoutMs = 120_000) {
|
|
246
|
+
const deadline = Date.now() + timeoutMs;
|
|
247
|
+
let last = "";
|
|
248
|
+
for (;;) {
|
|
249
|
+
try {
|
|
250
|
+
const res = await this.vmFetch("GET", "/sbr/v1/health", { timeoutMs: 10_000 });
|
|
251
|
+
if (res.ok)
|
|
252
|
+
return;
|
|
253
|
+
if (res.status === 404)
|
|
254
|
+
throw new ProviderError(ID, "the MicroVM image does not run the sandboxrouter server (GET /sbr/v1/health -> 404); see src/providers/lambda.ts", 404);
|
|
255
|
+
last = `HTTP ${res.status}`;
|
|
256
|
+
await res.body?.cancel().catch(() => { });
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
if (err instanceof ProviderError && err.status === 404)
|
|
260
|
+
throw err;
|
|
261
|
+
last = err.message;
|
|
262
|
+
}
|
|
263
|
+
if (Date.now() > deadline)
|
|
264
|
+
throw new ProviderError(ID, `MicroVM ${this.nativeId} server not healthy after ${timeoutMs / 1000}s (${last})`);
|
|
265
|
+
await sleep(this.p.pollMs);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async exec(cmd, opts = {}) {
|
|
269
|
+
const t0 = Date.now();
|
|
270
|
+
const timeout = Math.max(1, Math.round(opts.timeoutSeconds ?? 60));
|
|
271
|
+
// cwd/env go through the shell wrapper so the server stays a dumb `bash -c`.
|
|
272
|
+
const full = wrapCommand(cmd, { cwd: opts.cwd, env: { ...this.env, ...opts.env } });
|
|
273
|
+
const res = await this.vmFetch("POST", "/sbr/v1/exec", {
|
|
274
|
+
body: JSON.stringify({ cmd: full, timeoutSeconds: timeout }),
|
|
275
|
+
headers: { "content-type": "application/json", accept: "application/x-ndjson" },
|
|
276
|
+
timeoutMs: (timeout + 60) * 1000,
|
|
277
|
+
});
|
|
278
|
+
if (!res.ok)
|
|
279
|
+
await this.fail(res, "exec");
|
|
280
|
+
const outDec = new TextDecoder();
|
|
281
|
+
const errDec = new TextDecoder();
|
|
282
|
+
let stdout = "";
|
|
283
|
+
let stderr = "";
|
|
284
|
+
let exitCode;
|
|
285
|
+
for await (const line of readLines(res)) {
|
|
286
|
+
const j = JSON.parse(line);
|
|
287
|
+
if (j.timedOut)
|
|
288
|
+
throw timeoutError(ID, timeout);
|
|
289
|
+
if (j.stdout !== undefined) {
|
|
290
|
+
const s = outDec.decode(Buffer.from(j.stdout, "base64"), { stream: true });
|
|
291
|
+
if (s)
|
|
292
|
+
(stdout += s), opts.onStdout?.(s);
|
|
293
|
+
}
|
|
294
|
+
if (j.stderr !== undefined) {
|
|
295
|
+
const s = errDec.decode(Buffer.from(j.stderr, "base64"), { stream: true });
|
|
296
|
+
if (s)
|
|
297
|
+
(stderr += s), opts.onStderr?.(s);
|
|
298
|
+
}
|
|
299
|
+
if (j.error !== undefined)
|
|
300
|
+
throw new ProviderError(ID, `exec: ${j.error}`);
|
|
301
|
+
if (j.exitCode !== undefined)
|
|
302
|
+
exitCode = j.exitCode;
|
|
303
|
+
}
|
|
304
|
+
const o = outDec.decode();
|
|
305
|
+
const e = errDec.decode();
|
|
306
|
+
if (o)
|
|
307
|
+
(stdout += o), opts.onStdout?.(o);
|
|
308
|
+
if (e)
|
|
309
|
+
(stderr += e), opts.onStderr?.(e);
|
|
310
|
+
if (exitCode === undefined)
|
|
311
|
+
throw new ProviderError(ID, "exec: stream ended without an exit code");
|
|
312
|
+
return { exitCode, stdout, stderr, durationMs: Date.now() - t0 };
|
|
313
|
+
}
|
|
314
|
+
async writeFile(path, content) {
|
|
315
|
+
const res = await this.vmFetch("PUT", `/sbr/v1/files?path=${encodeURIComponent(absolute(path))}`, {
|
|
316
|
+
body: typeof content === "string" ? new TextEncoder().encode(content) : content,
|
|
317
|
+
headers: { "content-type": "application/octet-stream" },
|
|
318
|
+
timeoutMs: 300_000,
|
|
319
|
+
});
|
|
320
|
+
if (!res.ok)
|
|
321
|
+
await this.fail(res, `writeFile ${path}`);
|
|
322
|
+
await res.body?.cancel().catch(() => { });
|
|
323
|
+
}
|
|
324
|
+
async readFile(path) {
|
|
325
|
+
const res = await this.vmFetch("GET", `/sbr/v1/files?path=${encodeURIComponent(absolute(path))}`, { timeoutMs: 300_000 });
|
|
326
|
+
if (!res.ok)
|
|
327
|
+
await this.fail(res, `readFile ${path}`);
|
|
328
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
329
|
+
}
|
|
330
|
+
/** SuspendMicrovm: memory and disk are kept, compute billing stops; the next call resumes. */
|
|
331
|
+
async pause() {
|
|
332
|
+
await this.p.aws().json("POST", this.p.url(`/${encodeURIComponent(this.nativeId)}/suspend`), { json: {} });
|
|
333
|
+
this.paused = true;
|
|
334
|
+
}
|
|
335
|
+
async kill() {
|
|
336
|
+
try {
|
|
337
|
+
await this.p.aws().json("DELETE", this.p.url(`/${encodeURIComponent(this.nativeId)}`));
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
if (err instanceof ProviderError && err.status === 404)
|
|
341
|
+
return;
|
|
342
|
+
throw err;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function connector(region, name) {
|
|
347
|
+
return `arn:aws:lambda:${region}:aws:network-connector:aws-network-connector:${name}`;
|
|
348
|
+
}
|
|
349
|
+
function absolute(path) {
|
|
350
|
+
if (!path.startsWith("/"))
|
|
351
|
+
throw new ProviderError(ID, `paths must be absolute: ${path}`);
|
|
352
|
+
return path;
|
|
353
|
+
}
|
|
354
|
+
async function* readLines(res) {
|
|
355
|
+
if (!res.body)
|
|
356
|
+
return;
|
|
357
|
+
const reader = res.body.getReader();
|
|
358
|
+
const decoder = new TextDecoder();
|
|
359
|
+
let buffer = "";
|
|
360
|
+
for (;;) {
|
|
361
|
+
const { done, value } = await reader.read();
|
|
362
|
+
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
|
|
363
|
+
let nl;
|
|
364
|
+
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
365
|
+
const line = buffer.slice(0, nl).trim();
|
|
366
|
+
buffer = buffer.slice(nl + 1);
|
|
367
|
+
if (line)
|
|
368
|
+
yield line;
|
|
369
|
+
}
|
|
370
|
+
if (done) {
|
|
371
|
+
if (buffer.trim())
|
|
372
|
+
yield buffer.trim();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function sleep(ms) {
|
|
378
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
379
|
+
}
|
|
380
|
+
/** The in-VM server the MicroVM image must run (Node.js >= 18, built-ins only). */
|
|
381
|
+
export const LAMBDA_MICROVM_SERVER = String.raw `// sandboxrouter MicroVM server: exec + files over HTTP for src/providers/lambda.ts.
|
|
382
|
+
import { createServer } from "node:http";
|
|
383
|
+
import { spawn } from "node:child_process";
|
|
384
|
+
import { constants } from "node:os";
|
|
385
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
386
|
+
import { dirname } from "node:path";
|
|
387
|
+
|
|
388
|
+
const PORT = Number(process.env.SBR_PORT || 8080);
|
|
389
|
+
const json = (res, status, obj) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
|
|
390
|
+
const body = async (req) => { const c = []; for await (const x of req) c.push(x); return Buffer.concat(c); };
|
|
391
|
+
|
|
392
|
+
createServer(async (req, res) => {
|
|
393
|
+
try {
|
|
394
|
+
const url = new URL(req.url, "http://localhost");
|
|
395
|
+
const p = url.pathname;
|
|
396
|
+
if (p.startsWith("/aws/lambda-microvms/runtime/v1/")) return json(res, 200, {});
|
|
397
|
+
if (p === "/sbr/v1/health") return json(res, 200, { ok: true });
|
|
398
|
+
if (p === "/sbr/v1/files") {
|
|
399
|
+
const path = url.searchParams.get("path");
|
|
400
|
+
if (!path || !path.startsWith("/")) return json(res, 400, { error: "absolute path required" });
|
|
401
|
+
if (req.method === "PUT") {
|
|
402
|
+
const data = await body(req);
|
|
403
|
+
await mkdir(dirname(path), { recursive: true });
|
|
404
|
+
const tmp = path + ".sbr-" + process.pid + "-" + Date.now();
|
|
405
|
+
await writeFile(tmp, data);
|
|
406
|
+
await rename(tmp, path);
|
|
407
|
+
res.writeHead(204);
|
|
408
|
+
return res.end();
|
|
409
|
+
}
|
|
410
|
+
if (req.method === "GET") {
|
|
411
|
+
try {
|
|
412
|
+
const data = await readFile(path);
|
|
413
|
+
res.writeHead(200, { "content-type": "application/octet-stream" });
|
|
414
|
+
return res.end(data);
|
|
415
|
+
} catch (e) {
|
|
416
|
+
return json(res, e.code === "ENOENT" ? 404 : 500, { error: String(e.message) });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (p === "/sbr/v1/exec" && req.method === "POST") {
|
|
421
|
+
const j = JSON.parse((await body(req)).toString() || "{}");
|
|
422
|
+
if (typeof j.cmd !== "string") return json(res, 400, { error: "cmd required" });
|
|
423
|
+
res.writeHead(200, { "content-type": "application/x-ndjson" });
|
|
424
|
+
const line = (o) => res.write(JSON.stringify(o) + "\n");
|
|
425
|
+
const child = spawn("bash", ["-c", j.cmd], {
|
|
426
|
+
cwd: j.cwd || process.env.HOME || "/",
|
|
427
|
+
env: { ...process.env, ...(j.env || {}) },
|
|
428
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
429
|
+
detached: true,
|
|
430
|
+
});
|
|
431
|
+
let timedOut = false;
|
|
432
|
+
let finished = false;
|
|
433
|
+
const killGroup = () => { try { process.kill(-child.pid, "SIGKILL"); } catch {} };
|
|
434
|
+
const timer = j.timeoutSeconds > 0 ? setTimeout(() => { timedOut = true; killGroup(); }, j.timeoutSeconds * 1000) : null;
|
|
435
|
+
res.on("close", () => { if (!finished) killGroup(); });
|
|
436
|
+
child.stdout.on("data", (d) => line({ stdout: d.toString("base64") }));
|
|
437
|
+
child.stderr.on("data", (d) => line({ stderr: d.toString("base64") }));
|
|
438
|
+
child.on("error", (e) => { finished = true; if (timer) clearTimeout(timer); line({ error: String(e.message) }); res.end(); });
|
|
439
|
+
child.on("close", (code, signal) => {
|
|
440
|
+
finished = true;
|
|
441
|
+
if (timer) clearTimeout(timer);
|
|
442
|
+
// A timeout kill is reported as such, so the client can tell it from a command that exits 124 itself.
|
|
443
|
+
const exitCode = code ?? (signal ? 128 + (constants.signals[signal] || 0) : 1);
|
|
444
|
+
line(timedOut ? { exitCode: 124, timedOut: true } : { exitCode });
|
|
445
|
+
res.end();
|
|
446
|
+
});
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
json(res, 404, { error: "no route" });
|
|
450
|
+
} catch (e) {
|
|
451
|
+
if (!res.headersSent) json(res, 500, { error: String((e && e.message) || e) });
|
|
452
|
+
else res.end();
|
|
453
|
+
}
|
|
454
|
+
}).listen(PORT, "0.0.0.0", () => console.log("sandboxrouter MicroVM server on :" + PORT));
|
|
455
|
+
`;
|
|
456
|
+
export const LAMBDA_MICROVM_DOCKERFILE = `FROM public.ecr.aws/docker/library/node:22-bookworm-slim
|
|
457
|
+
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git python3 && rm -rf /var/lib/apt/lists/*
|
|
458
|
+
COPY server.mjs /opt/sbr/server.mjs
|
|
459
|
+
EXPOSE 8080
|
|
460
|
+
CMD ["node", "/opt/sbr/server.mjs"]
|
|
461
|
+
`;
|
|
462
|
+
/** Writes server.mjs + Dockerfile into dir, ready to zip and upload for create-microvm-image. */
|
|
463
|
+
export async function writeLambdaMicrovmBundle(dir) {
|
|
464
|
+
await mkdir(dir, { recursive: true });
|
|
465
|
+
await fsWriteFile(join(dir, "server.mjs"), LAMBDA_MICROVM_SERVER);
|
|
466
|
+
await fsWriteFile(join(dir, "Dockerfile"), LAMBDA_MICROVM_DOCKERFILE);
|
|
467
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
/** The size LangSmith will actually run for a request: 2 to 6 GiB per vCPU. */
|
|
3
|
+
export declare function langsmithSize(vcpu: number, memoryGb: number): {
|
|
4
|
+
vcpu: number;
|
|
5
|
+
memoryGb: number;
|
|
6
|
+
};
|
|
7
|
+
export declare class LangSmithProvider implements Provider {
|
|
8
|
+
private apiKey;
|
|
9
|
+
readonly id: "langsmith";
|
|
10
|
+
constructor(apiKey?: string | undefined);
|
|
11
|
+
configured(): boolean;
|
|
12
|
+
private client;
|
|
13
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
14
|
+
/** Reattach by sandbox name; a stopped sandbox wakes on its next command. */
|
|
15
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { ProviderError } from "../types.js";
|
|
2
|
+
import { optionalImport } from "../util.js";
|
|
3
|
+
/**
|
|
4
|
+
* LangSmith Sandboxes (LangChain) via the official `langsmith` SDK's `langsmith/sandbox` module.
|
|
5
|
+
*
|
|
6
|
+
* Auth: LANGSMITH_API_KEY. LANGSMITH_ENDPOINT optionally picks the region, BYOC data plane or
|
|
7
|
+
* self-hosted instance (the SDK reads it; default https://api.smith.langchain.com, GCP US).
|
|
8
|
+
* Size: `vCpus` and `memBytes`. Memory is tied to 4 GiB per vCPU and must stay within 50% of that,
|
|
9
|
+
* i.e. 2 to 6 GiB per vCPU, so a request outside that band is widened (more memory, or more vCPUs)
|
|
10
|
+
* by `langsmithSize`, which the price estimate uses too.
|
|
11
|
+
* Lifetime: there is no hard lifetime, only an idle stop. `timeoutMinutes` becomes `idleTtlSeconds`
|
|
12
|
+
* (a multiple of 60), and a stopped sandbox is deleted a day later (`deleteAfterStopSeconds`)
|
|
13
|
+
* instead of the server default of about 14 days, so a leak bills storage for a day at most.
|
|
14
|
+
* Pause = `stop()`: compute is released and the filesystem kept; the next command wakes it.
|
|
15
|
+
* Image: `image.langsmith` = a snapshot id (UUID) or snapshot name; default is LangSmith's runtime.
|
|
16
|
+
* Streaming goes over a WebSocket, which needs the `ws` package (the SDK's own optional peer).
|
|
17
|
+
*
|
|
18
|
+
* Docs: https://docs.langchain.com/langsmith/sandboxes
|
|
19
|
+
* https://docs.langchain.com/langsmith/sandbox-cli (sizes, stop/wake, LANGSMITH_ENDPOINT)
|
|
20
|
+
* https://reference.langchain.com/javascript/langsmith/sandbox (SandboxClient, Sandbox)
|
|
21
|
+
* https://www.langchain.com/pricing (per-second vCPU, memory and storage rates)
|
|
22
|
+
*/
|
|
23
|
+
const ID = "langsmith";
|
|
24
|
+
const GIB = 1024 ** 3;
|
|
25
|
+
/** The size LangSmith will actually run for a request: 2 to 6 GiB per vCPU. */
|
|
26
|
+
export function langsmithSize(vcpu, memoryGb) {
|
|
27
|
+
let v = Math.max(1, Math.ceil(vcpu));
|
|
28
|
+
if (memoryGb > v * 6)
|
|
29
|
+
v = Math.ceil(memoryGb / 6);
|
|
30
|
+
return { vcpu: v, memoryGb: Math.max(memoryGb, v * 2) };
|
|
31
|
+
}
|
|
32
|
+
export class LangSmithProvider {
|
|
33
|
+
apiKey;
|
|
34
|
+
id = ID;
|
|
35
|
+
constructor(apiKey = process.env.LANGSMITH_API_KEY) {
|
|
36
|
+
this.apiKey = apiKey;
|
|
37
|
+
}
|
|
38
|
+
configured() {
|
|
39
|
+
return !!this.apiKey;
|
|
40
|
+
}
|
|
41
|
+
async client() {
|
|
42
|
+
if (!this.apiKey)
|
|
43
|
+
throw new ProviderError(ID, "LANGSMITH_API_KEY is not set");
|
|
44
|
+
const { SandboxClient } = await optionalImport("langsmith/sandbox", "langsmith");
|
|
45
|
+
return new SandboxClient({ apiKey: this.apiKey });
|
|
46
|
+
}
|
|
47
|
+
async create(spec) {
|
|
48
|
+
const client = await this.client();
|
|
49
|
+
const size = langsmithSize(spec.vcpu ?? 2, spec.memoryGb ?? 4);
|
|
50
|
+
const image = spec.image?.langsmith;
|
|
51
|
+
const isId = !!image && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(image);
|
|
52
|
+
const opts = {
|
|
53
|
+
vCpus: size.vcpu,
|
|
54
|
+
memBytes: Math.round(size.memoryGb * GIB),
|
|
55
|
+
idleTtlSeconds: Math.max(60, Math.ceil(spec.timeoutMinutes ?? 30) * 60),
|
|
56
|
+
deleteAfterStopSeconds: 24 * 3600,
|
|
57
|
+
...(image && !isId ? { snapshotName: image } : {}),
|
|
58
|
+
...(spec.env ? { runConfig: { env_vars: spec.env } } : {}),
|
|
59
|
+
...(spec.metadata ? { labels: spec.metadata } : {}),
|
|
60
|
+
};
|
|
61
|
+
try {
|
|
62
|
+
return new LangSmithBox(await client.createSandbox(isId ? image : undefined, opts));
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
throw wrap(err);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Reattach by sandbox name; a stopped sandbox wakes on its next command. */
|
|
69
|
+
async connect(nativeId) {
|
|
70
|
+
const client = await this.client();
|
|
71
|
+
try {
|
|
72
|
+
return new LangSmithBox(await client.getSandbox(nativeId));
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
throw wrap(err);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
class LangSmithBox {
|
|
80
|
+
sbx;
|
|
81
|
+
constructor(sbx) {
|
|
82
|
+
this.sbx = sbx;
|
|
83
|
+
}
|
|
84
|
+
get nativeId() {
|
|
85
|
+
return this.sbx.name;
|
|
86
|
+
}
|
|
87
|
+
async exec(cmd, opts = {}) {
|
|
88
|
+
const t0 = Date.now();
|
|
89
|
+
const runConfig = opts.cwd || opts.env ? { ...(opts.cwd ? { work_dir: opts.cwd } : {}), ...(opts.env ? { env_vars: opts.env } : {}) } : undefined;
|
|
90
|
+
try {
|
|
91
|
+
const r = await this.sbx.run(cmd, {
|
|
92
|
+
timeout: opts.timeoutSeconds ?? 60,
|
|
93
|
+
...(runConfig ? { runConfig } : {}),
|
|
94
|
+
...(opts.onStdout ? { onStdout: opts.onStdout } : {}),
|
|
95
|
+
...(opts.onStderr ? { onStderr: opts.onStderr } : {}),
|
|
96
|
+
});
|
|
97
|
+
return { exitCode: r.exit_code, stdout: r.stdout, stderr: r.stderr, durationMs: Date.now() - t0 };
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
throw wrap(err);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async writeFile(path, content) {
|
|
104
|
+
try {
|
|
105
|
+
await this.sbx.write(path, content);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
throw wrap(err);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async readFile(path) {
|
|
112
|
+
try {
|
|
113
|
+
return await this.sbx.read(path);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
throw wrap(err);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async pause() {
|
|
120
|
+
try {
|
|
121
|
+
await this.sbx.stop();
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
throw wrap(err);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async kill() {
|
|
128
|
+
try {
|
|
129
|
+
await this.sbx.delete();
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
throw wrap(err);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function wrap(err) {
|
|
137
|
+
if (err instanceof ProviderError)
|
|
138
|
+
return err;
|
|
139
|
+
const status = err?.name === "LangSmithResourceNotFoundError"
|
|
140
|
+
? 404
|
|
141
|
+
: err?.name === "LangSmithSandboxAuthenticationError"
|
|
142
|
+
? 401
|
|
143
|
+
: err?.name === "LangSmithQuotaExceededError"
|
|
144
|
+
? 429
|
|
145
|
+
: typeof err?.status === "number"
|
|
146
|
+
? err.status
|
|
147
|
+
: undefined;
|
|
148
|
+
return new ProviderError(ID, err?.message ?? String(err), status);
|
|
149
|
+
}
|