@sandboxrouter/sdk 0.2.0 → 0.3.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.
Files changed (91) hide show
  1. package/README.md +224 -48
  2. package/dist/bench.js +4 -4
  3. package/dist/catalog.d.ts +159 -0
  4. package/dist/catalog.js +448 -15
  5. package/dist/check.d.ts +28 -0
  6. package/dist/check.js +185 -0
  7. package/dist/cli.js +148 -11
  8. package/dist/computers.d.ts +55 -13
  9. package/dist/computers.js +132 -6
  10. package/dist/conformance.d.ts +46 -0
  11. package/dist/conformance.js +65 -0
  12. package/dist/cost.d.ts +67 -0
  13. package/dist/cost.js +153 -0
  14. package/dist/gc.d.ts +29 -0
  15. package/dist/gc.js +54 -0
  16. package/dist/index.d.ts +28 -2
  17. package/dist/index.js +28 -2
  18. package/dist/leaderboard.d.ts +6 -0
  19. package/dist/leaderboard.js +5 -4
  20. package/dist/ledger.d.ts +38 -0
  21. package/dist/ledger.js +89 -0
  22. package/dist/providers/agent37.d.ts +40 -0
  23. package/dist/providers/agent37.js +224 -0
  24. package/dist/providers/agentcore.d.ts +71 -0
  25. package/dist/providers/agentcore.js +446 -0
  26. package/dist/providers/alibaba.d.ts +18 -0
  27. package/dist/providers/alibaba.js +106 -0
  28. package/dist/providers/azure.d.ts +43 -0
  29. package/dist/providers/azure.js +330 -0
  30. package/dist/providers/beam.d.ts +11 -0
  31. package/dist/providers/beam.js +179 -0
  32. package/dist/providers/blaxel.d.ts +3 -1
  33. package/dist/providers/blaxel.js +32 -22
  34. package/dist/providers/boat.js +8 -3
  35. package/dist/providers/cloudflare.js +8 -3
  36. package/dist/providers/codesandbox.d.ts +59 -0
  37. package/dist/providers/codesandbox.js +287 -0
  38. package/dist/providers/daytona.d.ts +25 -4
  39. package/dist/providers/daytona.js +449 -28
  40. package/dist/providers/dedalus.d.ts +12 -0
  41. package/dist/providers/dedalus.js +244 -0
  42. package/dist/providers/deno.d.ts +50 -0
  43. package/dist/providers/deno.js +170 -0
  44. package/dist/providers/docker.js +11 -5
  45. package/dist/providers/e2b.d.ts +70 -3
  46. package/dist/providers/e2b.js +376 -8
  47. package/dist/providers/exedev.js +17 -5
  48. package/dist/providers/freestyle.d.ts +49 -0
  49. package/dist/providers/freestyle.js +261 -0
  50. package/dist/providers/hopx.js +27 -18
  51. package/dist/providers/instabox.d.ts +76 -0
  52. package/dist/providers/instabox.js +360 -0
  53. package/dist/providers/koyeb.d.ts +71 -0
  54. package/dist/providers/koyeb.js +379 -0
  55. package/dist/providers/lambda.d.ts +51 -0
  56. package/dist/providers/lambda.js +467 -0
  57. package/dist/providers/langsmith.d.ts +16 -0
  58. package/dist/providers/langsmith.js +149 -0
  59. package/dist/providers/limrun.d.ts +77 -0
  60. package/dist/providers/limrun.js +626 -0
  61. package/dist/providers/maritime.d.ts +5 -1
  62. package/dist/providers/maritime.js +39 -8
  63. package/dist/providers/mobilerun.d.ts +44 -0
  64. package/dist/providers/mobilerun.js +417 -0
  65. package/dist/providers/modal.js +12 -4
  66. package/dist/providers/morph.js +12 -4
  67. package/dist/providers/northflank.js +11 -13
  68. package/dist/providers/novita.d.ts +12 -0
  69. package/dist/providers/novita.js +81 -0
  70. package/dist/providers/openscale.d.ts +17 -0
  71. package/dist/providers/openscale.js +87 -0
  72. package/dist/providers/orgo.d.ts +45 -0
  73. package/dist/providers/orgo.js +414 -0
  74. package/dist/providers/sail.d.ts +32 -0
  75. package/dist/providers/sail.js +372 -0
  76. package/dist/providers/superserve.d.ts +25 -0
  77. package/dist/providers/superserve.js +337 -0
  78. package/dist/providers/tensorlake.js +17 -14
  79. package/dist/providers/upstash.d.ts +30 -0
  80. package/dist/providers/upstash.js +258 -0
  81. package/dist/providers/vercel.js +14 -3
  82. package/dist/providers/workspaces.d.ts +60 -0
  83. package/dist/providers/workspaces.js +662 -0
  84. package/dist/router.d.ts +36 -5
  85. package/dist/router.js +149 -19
  86. package/dist/server.d.ts +1 -1
  87. package/dist/server.js +22 -3
  88. package/dist/types.d.ts +13 -5
  89. package/dist/util.d.ts +28 -1
  90. package/dist/util.js +56 -7
  91. package/package.json +80 -5
@@ -0,0 +1,330 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { ProviderError } from "../types.js";
3
+ import { dirname, killedByTimeout, shq, timeoutError, toBytes, wrapCommand } from "../util.js";
4
+ /**
5
+ * Azure Container Apps dynamic sessions, Shell session pools (plain REST, no Azure SDK).
6
+ *
7
+ * A Shell pool is a built-in ("code interpreter" type) pool whose sessions are Hyper-V isolated
8
+ * Linux shells. A session is addressed by an identifier you choose; the first request with a new
9
+ * identifier allocates a session from the pool's prewarmed set.
10
+ *
11
+ * POST {endpoint}/executions?api-version=2025-10-02-preview&identifier={id}
12
+ * {"shellCommand": "...", "timeoutInSeconds": N, "outputStreamsMaxLength": N}
13
+ * -> {"id", "identifier", "status": "0" | "Succeeded" | ..., "result": {"stdout", "stderr", "executionTimeInMilliseconds"}}
14
+ * POST {endpoint}/files?api-version=...&identifier={id} multipart field "file" (lands in /mnt/data, 128 MB max)
15
+ * GET {endpoint}/files/{name}/content?api-version=...&identifier={id}
16
+ * GET {endpoint}/session?api-version=...&identifier={id} -> {identifier, expireAt, createdAt, lastAccessedAt, etag}
17
+ * DELETE {endpoint}/session?api-version=...&identifier={id} -> 204
18
+ * Auth: Authorization: Bearer <Entra token with aud https://dynamicsessions.io>, from an identity holding
19
+ * "Azure ContainerApps Session Executor" on the pool.
20
+ *
21
+ * Docs:
22
+ * https://learn.microsoft.com/en-us/azure/container-apps/sessions-tutorial-shell
23
+ * https://learn.microsoft.com/en-us/azure/container-apps/session-pool
24
+ * https://learn.microsoft.com/en-us/azure/container-apps/sessions-usage
25
+ * https://learn.microsoft.com/en-us/rest/api/data-plane/containerapps/code-execution/execute?view=rest-data-plane-containerapps-2025-10-02-preview
26
+ * https://learn.microsoft.com/en-us/rest/api/data-plane/containerapps/session-resource-files/upload?view=rest-data-plane-containerapps-2025-10-02-preview
27
+ * https://learn.microsoft.com/en-us/rest/api/data-plane/containerapps/session-management/delete?view=rest-data-plane-containerapps-2025-10-02-preview
28
+ * https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow
29
+ * Billing: https://learn.microsoft.com/en-us/azure/container-apps/billing#dynamic-sessions
30
+ *
31
+ * Custom container pools are not supported: their exec surface is whatever HTTP server your image
32
+ * runs, so there is no API to adapt to.
33
+ *
34
+ * What the API does not let us honour, and how the adapter copes:
35
+ * - Session size is fixed by the platform (not published); vcpu/memoryGb are ignored, and the catalog
36
+ * caps limits so bigger requests route elsewhere.
37
+ * - Lifetime is the pool's cooldown (300-3600 s idle), not per session; timeoutMinutes is ignored. kill()
38
+ * deletes the session at once.
39
+ * - No env API: spec.env and opts.env are exported in front of each command.
40
+ * - stdout/stderr are capped per response (outputStreamsMaxLength, default 4096). exec writes output to
41
+ * files in the session and returns it inline when it fits, otherwise downloads the files. No streaming:
42
+ * onStdout/onStderr get the whole output once, at the end.
43
+ *
44
+ * ## Setup (once)
45
+ *
46
+ * az extension add --name containerapp --upgrade --allow-preview true
47
+ * az provider register --namespace Microsoft.App
48
+ * # Shell pool via ARM (see the shell tutorial for deploy.json): "containerType": "Shell",
49
+ * # "poolManagementType": "Dynamic", lifecycleConfiguration {"lifecycleType": "Timed",
50
+ * # "cooldownPeriodInSeconds": 300}, sessionNetworkConfiguration {"status": "EgressEnabled"}
51
+ * az deployment group create -g <rg> --template-file deploy.json
52
+ * az containerapp sessionpool show -n <pool> -g <rg> --query properties.poolManagementEndpoint -o tsv
53
+ * az role assignment create --role "Azure ContainerApps Session Executor" \
54
+ * --assignee <principal-id> --scope <pool-resource-id>
55
+ *
56
+ * Then set AZURE_SESSIONS_POOL_ENDPOINT to the endpoint, and either AZURE_SESSIONS_TOKEN
57
+ * (`az account get-access-token --resource https://dynamicsessions.io --query accessToken -o tsv`,
58
+ * expires in about an hour) or a service principal: AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET.
59
+ */
60
+ const ID = "azure";
61
+ const AUDIENCE = "https://dynamicsessions.io";
62
+ const DEFAULT_API_VERSION = "2025-10-02-preview";
63
+ /** Identifier: 4-128 chars; we only ever generate [a-z0-9-]. */
64
+ const IDENTIFIER = /^[A-Za-z0-9|\-&^%$#(){}[\];<>]{4,128}$/;
65
+ export class AzureSessionsProvider {
66
+ id = ID;
67
+ /** @internal */ endpoint;
68
+ /** @internal */ apiVersion;
69
+ /** @internal */ filesDir;
70
+ /** @internal */ outputCap;
71
+ staticToken;
72
+ tenantId;
73
+ clientId;
74
+ clientSecret;
75
+ authorityHost;
76
+ cached;
77
+ constructor(opts = {}) {
78
+ this.endpoint = (opts.endpoint ?? process.env.AZURE_SESSIONS_POOL_ENDPOINT)?.replace(/\/+$/, "");
79
+ this.staticToken = opts.token ?? process.env.AZURE_SESSIONS_TOKEN;
80
+ this.tenantId = opts.tenantId ?? process.env.AZURE_TENANT_ID;
81
+ this.clientId = opts.clientId ?? process.env.AZURE_CLIENT_ID;
82
+ this.clientSecret = opts.clientSecret ?? process.env.AZURE_CLIENT_SECRET;
83
+ this.authorityHost = (opts.authorityHost ?? process.env.AZURE_AUTHORITY_HOST ?? "https://login.microsoftonline.com").replace(/\/+$/, "");
84
+ this.apiVersion = opts.apiVersion ?? process.env.AZURE_SESSIONS_API_VERSION ?? DEFAULT_API_VERSION;
85
+ this.filesDir = (opts.filesDir ?? process.env.AZURE_SESSIONS_FILES_DIR ?? "/mnt/data").replace(/\/+$/, "");
86
+ this.outputCap = opts.outputCap ?? 4096;
87
+ }
88
+ configured() {
89
+ return !!this.endpoint && (!!this.staticToken || !!(this.tenantId && this.clientId && this.clientSecret));
90
+ }
91
+ /** @internal Bearer token: the static one, or a cached client-credentials token. */
92
+ async token() {
93
+ if (this.staticToken)
94
+ return this.staticToken;
95
+ if (!(this.tenantId && this.clientId && this.clientSecret)) {
96
+ throw new ProviderError(ID, "set AZURE_SESSIONS_TOKEN, or AZURE_TENANT_ID + AZURE_CLIENT_ID + AZURE_CLIENT_SECRET");
97
+ }
98
+ if (this.cached && this.cached.expiresAt - 300_000 > Date.now())
99
+ return this.cached.token;
100
+ const res = await fetch(`${this.authorityHost}/${encodeURIComponent(this.tenantId)}/oauth2/v2.0/token`, {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
103
+ body: new URLSearchParams({
104
+ grant_type: "client_credentials",
105
+ client_id: this.clientId,
106
+ client_secret: this.clientSecret,
107
+ scope: `${AUDIENCE}/.default`,
108
+ }),
109
+ });
110
+ const text = await res.text();
111
+ let j = {};
112
+ try {
113
+ j = JSON.parse(text);
114
+ }
115
+ catch {
116
+ /* not JSON */
117
+ }
118
+ if (!res.ok || !j.access_token) {
119
+ throw new ProviderError(ID, `Entra token -> ${res.status} ${j.error_description ?? j.error ?? text.slice(0, 200)}`, res.status);
120
+ }
121
+ this.cached = { token: j.access_token, expiresAt: Date.now() + Number(j.expires_in ?? 3600) * 1000 };
122
+ return j.access_token;
123
+ }
124
+ /** @internal One data-plane call. Throws ProviderError on non-2xx. */
125
+ async call(method, path, identifier, init = {}) {
126
+ if (!this.endpoint)
127
+ throw new ProviderError(ID, "AZURE_SESSIONS_POOL_ENDPOINT is not set (see src/providers/azure.ts for the pool setup)");
128
+ const qs = new URLSearchParams({ "api-version": this.apiVersion, identifier });
129
+ const headers = { Authorization: `Bearer ${await this.token()}` };
130
+ let body;
131
+ if (init.json !== undefined) {
132
+ headers["Content-Type"] = "application/json";
133
+ body = JSON.stringify(init.json);
134
+ }
135
+ else if (init.form) {
136
+ body = init.form; // fetch sets the multipart boundary
137
+ }
138
+ let res;
139
+ try {
140
+ res = await fetch(`${this.endpoint}/${path}?${qs}`, {
141
+ method,
142
+ headers,
143
+ body,
144
+ signal: init.timeoutMs ? AbortSignal.timeout(init.timeoutMs) : undefined,
145
+ });
146
+ }
147
+ catch (err) {
148
+ throw new ProviderError(ID, `${method} ${path}: ${err.message}`);
149
+ }
150
+ if (!res.ok) {
151
+ const text = await res.text().catch(() => "");
152
+ let code = "";
153
+ let detail = text.slice(0, 300);
154
+ try {
155
+ const j = JSON.parse(text);
156
+ code = j?.error?.code ?? "";
157
+ detail = j?.error?.message ?? j?.message ?? detail;
158
+ }
159
+ catch {
160
+ /* plain text */
161
+ }
162
+ // An unknown or expired identifier answers 400 SessionWithIdentifierNotFound; surface it as 404.
163
+ const status = code === "SessionWithIdentifierNotFound" ? 404 : res.status;
164
+ throw new ProviderError(ID, `${method} ${path} -> ${res.status} ${code ? `${code}: ` : ""}${detail}`.trim(), status);
165
+ }
166
+ return res;
167
+ }
168
+ async create(spec) {
169
+ const box = new AzureSession(this, `sbr-${randomBytes(12).toString("hex")}`, spec.env);
170
+ // The first request with a new identifier allocates the session; prove it runs commands.
171
+ const r = await box.exec("true", { timeoutSeconds: 30 });
172
+ if (r.exitCode !== 0) {
173
+ await box.kill().catch(() => { });
174
+ throw new ProviderError(ID, `new session did not run a command (exit ${r.exitCode}): ${r.stderr.trim()}`);
175
+ }
176
+ return box;
177
+ }
178
+ async connect(nativeId) {
179
+ if (!IDENTIFIER.test(nativeId))
180
+ throw new ProviderError(ID, `invalid session identifier: ${nativeId}`);
181
+ // GET /session does not allocate; an unknown identifier fails instead of starting a fresh session.
182
+ await this.call("GET", "session", nativeId);
183
+ return new AzureSession(this, nativeId);
184
+ }
185
+ }
186
+ class AzureSession {
187
+ p;
188
+ nativeId;
189
+ env;
190
+ constructor(p, nativeId, env) {
191
+ this.p = p;
192
+ this.nativeId = nativeId;
193
+ this.env = env;
194
+ }
195
+ /** @internal One shellCommand execution, returned raw. */
196
+ async run(shellCommand, timeoutSeconds) {
197
+ const res = await this.p.call("POST", "executions", this.nativeId, {
198
+ json: { shellCommand, timeoutInSeconds: timeoutSeconds, outputStreamsMaxLength: this.p.outputCap },
199
+ timeoutMs: (timeoutSeconds + 60) * 1000,
200
+ });
201
+ const j = (await res.json());
202
+ if (j.status === "Failed" || j.status === "Canceled") {
203
+ const e = j.error;
204
+ throw new ProviderError(ID, `execution ${j.status.toLowerCase()}: ${e?.error?.message ?? e?.message ?? "no detail"}`);
205
+ }
206
+ return j;
207
+ }
208
+ /** Run a short helper script; throws with its stderr when it fails. */
209
+ async sh(script, what) {
210
+ const r = await this.run(script, 120);
211
+ const code = statusCode(r.status);
212
+ if (code !== undefined && code !== 0)
213
+ throw new ProviderError(ID, `${what}: ${(r.result?.stderr ?? "").trim() || `exit ${code}`}`);
214
+ return r.result?.stdout ?? "";
215
+ }
216
+ /**
217
+ * The command runs with stdout/stderr redirected to files under filesDir. A trailer on stdout
218
+ * reports the exit code and whether the output came inline (it fit under the response cap) or must
219
+ * be downloaded through the files API.
220
+ */
221
+ async exec(cmd, opts = {}) {
222
+ const t0 = Date.now();
223
+ const timeout = Math.max(1, Math.round(opts.timeoutSeconds ?? 60));
224
+ const job = `sbr-${randomBytes(8).toString("hex")}`;
225
+ const fd = this.p.filesDir;
226
+ const base = `${fd}/${job}`;
227
+ const marker = `__SBR_DONE_${job}__`;
228
+ const full = wrapCommand(cmd, { cwd: opts.cwd, env: { ...this.env, ...opts.env } });
229
+ const b64 = Buffer.from(full).toString("base64");
230
+ const inlineMax = Math.max(0, this.p.outputCap - marker.length - 32);
231
+ const script = [
232
+ `mkdir -p ${shq(fd)} && echo ${b64} | base64 -d > ${shq(`${base}.sh`)} || exit 97`,
233
+ `timeout -k 5 ${timeout} bash ${shq(`${base}.sh`)} > ${shq(`${base}.out`)} 2> ${shq(`${base}.err`)} < /dev/null`,
234
+ `c=$?`,
235
+ `so=$(stat -c %s ${shq(`${base}.out`)}); se=$(stat -c %s ${shq(`${base}.err`)})`,
236
+ `if [ $((so + se)) -le ${inlineMax} ]; then`,
237
+ ` cat ${shq(`${base}.out`)}; cat ${shq(`${base}.err`)} >&2; rm -f ${shq(base)}.*; i=1`,
238
+ `else rm -f ${shq(`${base}.sh`)}; i=0; fi`,
239
+ `printf '\\n${marker} %d %d' "$c" "$i"`,
240
+ `exit 0`,
241
+ ].join("\n");
242
+ const r = await this.run(script, timeout + 30);
243
+ let stdout = r.result?.stdout ?? "";
244
+ let stderr = r.result?.stderr ?? "";
245
+ const m = new RegExp(`\\n${marker} (\\d+) ([01])$`).exec(stdout);
246
+ let exitCode;
247
+ if (!m) {
248
+ // The wrapper itself did not finish (killed, or the platform timed it out): report what came back.
249
+ exitCode = statusCode(r.status) ?? 1;
250
+ if (exitCode === 0)
251
+ exitCode = 1;
252
+ }
253
+ else {
254
+ exitCode = Number(m[1]);
255
+ stdout = stdout.slice(0, m.index);
256
+ if (m[2] === "0") {
257
+ stdout = new TextDecoder().decode(await this.download(`${job}.out`));
258
+ stderr = new TextDecoder().decode(await this.download(`${job}.err`));
259
+ await this.sh(`rm -f ${shq(base)}.*`, "cleanup").catch(() => { });
260
+ }
261
+ }
262
+ if (stdout)
263
+ opts.onStdout?.(stdout);
264
+ if (stderr)
265
+ opts.onStderr?.(stderr);
266
+ const durationMs = Date.now() - t0;
267
+ // `timeout -k 5` leaves exit 124 (or 137 after SIGKILL) when it had to stop the command.
268
+ if (killedByTimeout(exitCode, durationMs, timeout))
269
+ throw timeoutError(ID, timeout);
270
+ return { exitCode, stdout, stderr, durationMs };
271
+ }
272
+ /** GET files/{name}/content for a file directly under filesDir. */
273
+ async download(name) {
274
+ const res = await this.p.call("GET", `files/${encodeURIComponent(name)}/content`, this.nativeId);
275
+ const buf = new Uint8Array(await res.arrayBuffer());
276
+ // The reference example shows the content as a JSON string; unwrap it only when it is one.
277
+ if (res.headers.get("content-type")?.includes("application/json")) {
278
+ try {
279
+ const v = JSON.parse(new TextDecoder().decode(buf));
280
+ if (typeof v === "string")
281
+ return new TextEncoder().encode(v);
282
+ }
283
+ catch {
284
+ /* raw bytes */
285
+ }
286
+ }
287
+ return buf;
288
+ }
289
+ /** Upload into filesDir through the files API, then move it into place. */
290
+ async writeFile(path, content) {
291
+ const name = `sbr-${randomBytes(8).toString("hex")}.upload`;
292
+ const form = new FormData();
293
+ form.append("file", new Blob([Buffer.from(toBytes(content))], { type: "application/octet-stream" }), name);
294
+ await this.p.call("POST", "files", this.nativeId, { form, timeoutMs: 300_000 });
295
+ const staged = `${this.p.filesDir}/${name}`;
296
+ await this.sh(`mkdir -p ${shq(dirname(path))} && mv -f ${shq(staged)} ${shq(path)}`, `writeFile ${path}`);
297
+ }
298
+ /** Copy into filesDir, download through the files API, then remove the copy. */
299
+ async readFile(path) {
300
+ const name = `sbr-${randomBytes(8).toString("hex")}.download`;
301
+ const staged = `${this.p.filesDir}/${name}`;
302
+ await this.sh(`mkdir -p ${shq(this.p.filesDir)} && cp -f -- ${shq(path)} ${shq(staged)}`, `readFile ${path}`);
303
+ try {
304
+ return await this.download(name);
305
+ }
306
+ finally {
307
+ await this.sh(`rm -f ${shq(staged)}`, "cleanup").catch(() => { });
308
+ }
309
+ }
310
+ async kill() {
311
+ try {
312
+ await this.p.call("DELETE", "session", this.nativeId);
313
+ }
314
+ catch (err) {
315
+ if (err instanceof ProviderError && err.status === 404)
316
+ return; // already gone
317
+ throw err;
318
+ }
319
+ }
320
+ }
321
+ /** Shell pools put the exit code in `status` ("0"); other pools use OperationState names. */
322
+ function statusCode(status) {
323
+ if (status === undefined)
324
+ return undefined;
325
+ if (/^-?\d+$/.test(status))
326
+ return Number(status);
327
+ if (status === "Succeeded")
328
+ return 0;
329
+ return undefined;
330
+ }
@@ -0,0 +1,11 @@
1
+ import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
2
+ export declare class BeamProvider implements Provider {
3
+ private token;
4
+ private workspaceId;
5
+ readonly id: "beam";
6
+ constructor(token?: string | undefined, workspaceId?: string | undefined);
7
+ configured(): boolean;
8
+ private beam;
9
+ create(spec: SandboxSpec): Promise<ProviderSandbox>;
10
+ connect(nativeId: string): Promise<ProviderSandbox>;
11
+ }
@@ -0,0 +1,179 @@
1
+ import { DEFAULT_MEMORY_GB, DEFAULT_VCPU } from "../catalog.js";
2
+ import { ProviderError } from "../types.js";
3
+ import { DEFAULT_EXEC_TIMEOUT_S, dirname, optionalImport, shq, timeoutError, toBytes } from "../util.js";
4
+ /**
5
+ * Beam (beam.cloud) sandboxes via the official `@beamcloud/beam-js` SDK (checked against 1.1.6).
6
+ * Auth: BEAM_TOKEN and BEAM_WORKSPACE_ID (the TS SDK refuses requests without the workspace id).
7
+ * new Sandbox({ name, cpu, memory: MiB, gpu?, image?, keepWarmSeconds, env }).create()
8
+ * -> SandboxInstance (containerId is the sandbox id)
9
+ * Sandbox.connect(id) reattach by id
10
+ * instance.execShell(text, { cwd, env, wait }) start a process; .wait() -> exit code,
11
+ * .stdout / .stderr are polled streams (async-iterable by line, or .read() for all so far)
12
+ * instance.fs.writeBytes(path, buf) / fs.readBytes(path)
13
+ * instance.terminate()
14
+ * keepWarmSeconds is the sandbox's TTL, not an idle timeout, so it carries timeoutMinutes.
15
+ * There is no pause: snapshot() + Sandbox.createFromSnapshot() restores into a NEW sandbox id, so
16
+ * it can't back router.get(); the pause/resume Beam documents is for its separate VM product.
17
+ * GPUs: spec.gpu is passed straight to Beam's `gpu` (e.g. "T4", "A10G", "A100-40", "H100",
18
+ * "RTX4090"). The catalog doesn't list "gpu" for beam because GPU sandboxes bill different rates.
19
+ * The SDK keeps its auth in a module-global (`beamOpts`) and caches its HTTP client on the first
20
+ * request, so one process can only talk to one Beam token/workspace.
21
+ * Docs: https://docs.beam.cloud/v2/reference/ts-sdk, https://docs.beam.cloud/v2/sandbox/configuration,
22
+ * https://docs.beam.cloud/v2/getting-started/quickstart-sandbox, https://www.beam.cloud/pricing,
23
+ * https://github.com/beam-cloud/beam-client/tree/master/js
24
+ */
25
+ const ID = "beam";
26
+ /** Beam groups sandboxes under an app name. */
27
+ const APP_NAME = process.env.SANDBOXROUTER_BEAM_APP ?? "sandboxrouter";
28
+ function fail(err, what) {
29
+ if (err instanceof ProviderError)
30
+ return err;
31
+ const status = err?.response?.status;
32
+ const detail = err?.response?.data?.errorMsg || err?.response?.data?.message || err?.message || String(err);
33
+ return new ProviderError(ID, `${what}: ${detail}`, status);
34
+ }
35
+ export class BeamProvider {
36
+ token;
37
+ workspaceId;
38
+ id = ID;
39
+ constructor(token = process.env.BEAM_TOKEN, workspaceId = process.env.BEAM_WORKSPACE_ID) {
40
+ this.token = token;
41
+ this.workspaceId = workspaceId;
42
+ }
43
+ configured() {
44
+ return !!this.token && !!this.workspaceId;
45
+ }
46
+ async beam() {
47
+ if (!this.token || !this.workspaceId)
48
+ throw new ProviderError(ID, "BEAM_TOKEN and BEAM_WORKSPACE_ID must both be set");
49
+ const mod = await optionalImport("@beamcloud/beam-js", ID);
50
+ mod.beamOpts.token = this.token;
51
+ mod.beamOpts.workspaceId = this.workspaceId;
52
+ return mod;
53
+ }
54
+ async create(spec) {
55
+ const { Sandbox, Image } = await this.beam();
56
+ const image = spec.image?.beam;
57
+ const config = {
58
+ name: APP_NAME,
59
+ cpu: spec.vcpu ?? DEFAULT_VCPU,
60
+ memory: Math.round((spec.memoryGb ?? DEFAULT_MEMORY_GB) * 1024), // MiB
61
+ keepWarmSeconds: Math.max(60, Math.round((spec.timeoutMinutes ?? 30) * 60)),
62
+ env: spec.env ?? {},
63
+ };
64
+ if (image)
65
+ config.image = Image.fromRegistry(image);
66
+ if (spec.gpu)
67
+ config.gpu = spec.gpu;
68
+ try {
69
+ const sb = await new Sandbox(config).create();
70
+ return new BeamBox(sb, spec.env);
71
+ }
72
+ catch (err) {
73
+ throw fail(err, "create");
74
+ }
75
+ }
76
+ async connect(nativeId) {
77
+ const { Sandbox } = await this.beam();
78
+ try {
79
+ return new BeamBox(await Sandbox.connect(nativeId));
80
+ }
81
+ catch (err) {
82
+ throw fail(err, `connect ${nativeId}`);
83
+ }
84
+ }
85
+ }
86
+ /** Drain a Beam output stream line by line, passing each line to `on`. */
87
+ async function drain(stream, on) {
88
+ let out = "";
89
+ for await (const line of stream) {
90
+ out += line;
91
+ on?.(line);
92
+ }
93
+ return out;
94
+ }
95
+ class BeamBox {
96
+ sb;
97
+ env;
98
+ nativeId;
99
+ constructor(sb, env) {
100
+ this.sb = sb;
101
+ this.env = env;
102
+ this.nativeId = sb.containerId;
103
+ }
104
+ async exec(cmd, opts = {}) {
105
+ const t0 = Date.now();
106
+ const seconds = opts.timeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_S;
107
+ const limit = seconds * 1000;
108
+ const streaming = !!(opts.onStdout || opts.onStderr);
109
+ const env = this.env || opts.env ? { ...this.env, ...opts.env } : undefined;
110
+ let proc;
111
+ try {
112
+ // Never `wait: true`: that call returns only when the command exits, so the deadline below
113
+ // could not start until it was already too late to enforce.
114
+ proc = await this.sb.execShell(cmd, { cwd: opts.cwd, env, wait: false });
115
+ }
116
+ catch (err) {
117
+ throw fail(err, "exec");
118
+ }
119
+ const work = (async () => {
120
+ if (streaming) {
121
+ const [stdout, stderr] = await Promise.all([drain(proc.stdout, opts.onStdout), drain(proc.stderr, opts.onStderr)]);
122
+ return { exitCode: await proc.wait(), stdout, stderr };
123
+ }
124
+ const exitCode = await proc.wait();
125
+ const [stdout, stderr] = await Promise.all([proc.stdout.read(), proc.stderr.read()]);
126
+ return { exitCode, stdout, stderr };
127
+ })();
128
+ let timer;
129
+ const timeout = new Promise((resolve) => (timer = setTimeout(() => resolve("timeout"), limit)));
130
+ try {
131
+ const r = await Promise.race([work, timeout]);
132
+ if (r === "timeout") {
133
+ work.catch(() => { });
134
+ await proc.kill().catch(() => { });
135
+ throw timeoutError(ID, seconds);
136
+ }
137
+ return { ...r, durationMs: Date.now() - t0 };
138
+ }
139
+ catch (err) {
140
+ throw fail(err, "exec");
141
+ }
142
+ finally {
143
+ clearTimeout(timer);
144
+ }
145
+ }
146
+ async writeFile(path, content) {
147
+ const buf = Buffer.from(toBytes(content));
148
+ try {
149
+ await this.sb.fs.writeBytes(path, buf);
150
+ }
151
+ catch {
152
+ // Most likely a missing parent directory: create it and try once more.
153
+ try {
154
+ const p = await this.sb.execShell(`mkdir -p ${shq(dirname(path))}`, { wait: true });
155
+ await p.wait();
156
+ await this.sb.fs.writeBytes(path, buf);
157
+ }
158
+ catch (err) {
159
+ throw fail(err, `write ${path}`);
160
+ }
161
+ }
162
+ }
163
+ async readFile(path) {
164
+ try {
165
+ return new Uint8Array(await this.sb.fs.readBytes(path));
166
+ }
167
+ catch (err) {
168
+ throw fail(err, `read ${path}`);
169
+ }
170
+ }
171
+ async kill() {
172
+ try {
173
+ await this.sb.terminate();
174
+ }
175
+ catch (err) {
176
+ throw fail(err, "terminate");
177
+ }
178
+ }
179
+ }
@@ -3,7 +3,9 @@ import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.
3
3
  * Blaxel sandboxes via `@blaxel/core`. Auth: BL_API_KEY + BL_WORKSPACE (or `bl login`).
4
4
  * Region: BL_REGION, else us-pdx-1. Blaxel sizes by memory only and gives 1 vCPU per 2 GB,
5
5
  * so memory is max(memoryGb, 2 × vcpu) GB. Idle sandboxes drop to standby on their own and
6
- * wake on the next call; pause() archives explicitly.
6
+ * wake on the next call. No pause(): archiving is a preview Blaxel enables per workspace (others
7
+ * get 403 "Archiving a sandbox is in preview and is not enabled for this workspace", 2026-11-05).
8
+ * kill() waits until the sandbox is gone, because Blaxel deletes asynchronously.
7
9
  */
8
10
  export declare class BlaxelProvider implements Provider {
9
11
  private apiKey;
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_MEMORY_GB, DEFAULT_VCPU } from "../catalog.js";
2
2
  import { ProviderError } from "../types.js";
3
- import { optionalImport, toBytes } from "../util.js";
3
+ import { DEFAULT_EXEC_TIMEOUT_S, optionalImport, timeoutError, toBytes } from "../util.js";
4
4
  /** Blaxel's public base image (the SDK's own default). Set image.blaxel for another image or a `bl deploy` template. */
5
5
  const DEFAULT_IMAGE = "blaxel/base-image:latest";
6
6
  /** Blaxel rejects a max-age ttl under 5 minutes. */
@@ -9,7 +9,9 @@ const MIN_TTL_MINUTES = 5;
9
9
  * Blaxel sandboxes via `@blaxel/core`. Auth: BL_API_KEY + BL_WORKSPACE (or `bl login`).
10
10
  * Region: BL_REGION, else us-pdx-1. Blaxel sizes by memory only and gives 1 vCPU per 2 GB,
11
11
  * so memory is max(memoryGb, 2 × vcpu) GB. Idle sandboxes drop to standby on their own and
12
- * wake on the next call; pause() archives explicitly.
12
+ * wake on the next call. No pause(): archiving is a preview Blaxel enables per workspace (others
13
+ * get 403 "Archiving a sandbox is in preview and is not enabled for this workspace", 2026-11-05).
14
+ * kill() waits until the sandbox is gone, because Blaxel deletes asynchronously.
13
15
  */
14
16
  export class BlaxelProvider {
15
17
  apiKey;
@@ -80,10 +82,11 @@ class BlaxelBox {
80
82
  }
81
83
  async exec(cmd, opts = {}) {
82
84
  const t0 = Date.now();
83
- const timeoutSeconds = opts.timeoutSeconds ?? 60;
85
+ const timeoutSeconds = opts.timeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_S;
84
86
  let r;
85
87
  try {
86
88
  // With onStdout/onStderr the SDK streams logs from the same request as the command runs.
89
+ // The sandbox kills the command at `timeout` and reports it with status "killed".
87
90
  r = await this.sbx.process.exec({
88
91
  command: cmd,
89
92
  workingDir: opts.cwd,
@@ -94,13 +97,19 @@ class BlaxelBox {
94
97
  onStderr: opts.onStderr,
95
98
  });
96
99
  if (r.status === "running")
97
- r = await this.sbx.process.wait(r.pid, { maxWait: timeoutSeconds * 1000 });
100
+ r = await this.sbx.process.wait(r.pid, { maxWait: (timeoutSeconds + 15) * 1000 });
98
101
  }
99
102
  catch (err) {
100
- throw wrap(err);
103
+ const e = wrap(err);
104
+ // A real account answers a command that passes its limit with 422 "process timed out after N seconds".
105
+ if (e.status === 422 && /timed out/i.test(e.message))
106
+ throw timeoutError("blaxel", timeoutSeconds);
107
+ throw e;
101
108
  }
102
109
  if (r.status === "killed" || r.status === "stopped") {
103
- throw new ProviderError("blaxel", `command ${r.status} (timeout ${timeoutSeconds}s?)`);
110
+ if (Date.now() - t0 >= timeoutSeconds * 1000 - 250)
111
+ throw timeoutError("blaxel", timeoutSeconds);
112
+ throw new ProviderError("blaxel", `command ${r.status} before it finished`);
104
113
  }
105
114
  // A nonzero exit is a result (status "failed"), not an error.
106
115
  return { exitCode: r.exitCode, stdout: r.stdout ?? "", stderr: r.stderr ?? "", durationMs: Date.now() - t0 };
@@ -121,20 +130,6 @@ class BlaxelBox {
121
130
  throw wrap(err);
122
131
  }
123
132
  }
124
- /**
125
- * Archive: the filesystem is kept, memory and processes are lost. The max-age ttl is cleared
126
- * first so the archive survives until kill(). router.get() unarchives it.
127
- */
128
- async pause() {
129
- const { SandboxInstance } = await optionalImport("@blaxel/core", "blaxel");
130
- try {
131
- await SandboxInstance.updateTtl(this.nativeId, null);
132
- await this.sbx.archive();
133
- }
134
- catch (err) {
135
- throw wrap(err);
136
- }
137
- }
138
133
  async kill() {
139
134
  try {
140
135
  await this.sbx.delete();
@@ -142,11 +137,26 @@ class BlaxelBox {
142
137
  catch (err) {
143
138
  throw wrap(err);
144
139
  }
140
+ // Deletion is asynchronous: wait (up to 20 s) until the sandbox is gone or terminated.
141
+ const { SandboxInstance } = await optionalImport("@blaxel/core", "blaxel");
142
+ for (let i = 0; i < 40; i++) {
143
+ try {
144
+ const s = await SandboxInstance.get(this.nativeId);
145
+ if (s.status === "TERMINATED")
146
+ return;
147
+ }
148
+ catch {
149
+ return;
150
+ }
151
+ await new Promise((r) => setTimeout(r, 500));
152
+ }
145
153
  }
146
154
  }
147
155
  function wrap(err) {
148
156
  if (err instanceof ProviderError)
149
157
  return err;
150
- const status = typeof err?.status === "number" ? err.status : err?.response?.status;
151
- return new ProviderError("blaxel", err?.message ?? String(err), status);
158
+ const status = typeof err?.status === "number" ? err.status : typeof err?.code === "number" ? err.code : err?.response?.status;
159
+ // The SDK sometimes throws the plain API body ({ code, error }), not an Error.
160
+ const message = err?.message ?? err?.error ?? (typeof err === "object" ? JSON.stringify(err) : String(err));
161
+ return new ProviderError("blaxel", message, status);
152
162
  }
@@ -1,6 +1,6 @@
1
1
  import { BOAT_SIZES } from "../catalog.js";
2
2
  import { ProviderError } from "../types.js";
3
- import { dirname, shq, toBytes, wrapCommand } from "../util.js";
3
+ import { DEFAULT_EXEC_TIMEOUT_S, dirname, killedByTimeout, shq, timeoutError, toBytes, wrapCommand } from "../util.js";
4
4
  /**
5
5
  * Boat (boat.dev) over plain REST. One host, one key: Authorization: Bearer boat_... (BOAT_API_KEY).
6
6
  * POST /sandboxes create (202, state "provisioning"); poll GET until ready
@@ -158,9 +158,11 @@ class BoatSandbox {
158
158
  async exec(cmd, opts = {}) {
159
159
  const t0 = Date.now();
160
160
  const streaming = !!(opts.onStdout || opts.onStderr);
161
+ // Boat caps a command at 600 s; it stops the command at the deadline and flags it as timedOut.
162
+ const seconds = Math.min(600, Math.max(1, Math.round(opts.timeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_S)));
161
163
  const res = await this.call("POST", "/commands", {
162
164
  command: wrapCommand(cmd, { cwd: opts.cwd, env: opts.env }),
163
- timeoutSeconds: Math.min(600, Math.max(1, Math.round(opts.timeoutSeconds ?? 60))),
165
+ timeoutSeconds: seconds,
164
166
  stream: streaming,
165
167
  });
166
168
  let result;
@@ -194,7 +196,10 @@ class BoatSandbox {
194
196
  if (result.stderr)
195
197
  opts.onStderr?.(result.stderr);
196
198
  }
197
- return { exitCode: result.exitCode ?? -1, stdout: result.stdout ?? "", stderr: result.stderr ?? "", durationMs: Date.now() - t0 };
199
+ const durationMs = Date.now() - t0;
200
+ if (result.timedOut || killedByTimeout(result.exitCode ?? -1, durationMs, seconds))
201
+ throw timeoutError(ID, seconds);
202
+ return { exitCode: result.exitCode ?? -1, stdout: result.stdout ?? "", stderr: result.stderr ?? "", durationMs };
198
203
  }
199
204
  async writeFile(path, content) {
200
205
  const b64 = Buffer.from(toBytes(content)).toString("base64");