@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.
- package/README.md +224 -48
- package/dist/bench.js +4 -4
- package/dist/catalog.d.ts +159 -0
- package/dist/catalog.js +448 -15
- package/dist/check.d.ts +28 -0
- package/dist/check.js +185 -0
- package/dist/cli.js +148 -11
- 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 +36 -5
- package/dist/router.js +149 -19
- 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 +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
|
|
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;
|
package/dist/providers/blaxel.js
CHANGED
|
@@ -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
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/providers/boat.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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");
|