withruntime 0.8.4 → 0.10.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/BLAXEL.md +4 -4
- package/COMPATIBILITY.md +145 -0
- package/DAYTONA.md +13 -7
- package/E2B.md +34 -20
- package/README.md +22 -11
- package/README.src.md +22 -11
- package/VERCEL.md +13 -3
- package/compatibility-lock.json +357 -0
- package/dist/ai-harness/index.js +2 -2
- package/dist/api-defaults.d.ts +17 -0
- package/dist/api-defaults.js +22 -0
- package/dist/blaxel/errors.js +1 -1
- package/dist/blaxel/lookup.d.ts +3 -2
- package/dist/blaxel/lookup.js +3 -2
- package/dist/blaxel/pagination.d.ts +2 -2
- package/dist/blaxel/pagination.js +45 -17
- package/dist/blaxel/preview.d.ts +3 -2
- package/dist/blaxel/preview.js +16 -14
- package/dist/blaxel/process.d.ts +1 -1
- package/dist/blaxel/process.js +66 -11
- package/dist/blaxel/sandbox.d.ts +2 -0
- package/dist/blaxel/sandbox.js +2 -0
- package/dist/blaxel/snapshot.js +3 -5
- package/dist/cli-words.d.ts +27 -0
- package/dist/cli-words.js +135 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +351 -85
- package/dist/client.d.ts +33 -4
- package/dist/client.js +47 -12
- package/dist/cloudflare/identity.d.ts +7 -0
- package/dist/cloudflare/identity.js +13 -0
- package/dist/cloudflare/index.d.ts +303 -0
- package/dist/cloudflare/index.js +592 -0
- package/dist/cloudflare/worker.d.ts +419 -0
- package/dist/cloudflare/worker.js +447 -0
- package/dist/codesandbox/guest-service.d.ts +31 -0
- package/dist/codesandbox/guest-service.js +490 -0
- package/dist/codesandbox/index.d.ts +386 -0
- package/dist/codesandbox/index.js +1347 -0
- package/dist/compat/core.d.ts +40 -0
- package/dist/compat/core.js +238 -0
- package/dist/compat/files.d.ts +26 -0
- package/dist/compat/files.js +133 -0
- package/dist/compat/shell.d.ts +4 -0
- package/dist/compat/shell.js +165 -0
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +107 -38
- package/dist/daytona/context.js +2 -1
- package/dist/daytona/daytona.js +11 -4
- package/dist/daytona/filesystem.d.ts +17 -4
- package/dist/daytona/filesystem.js +121 -38
- package/dist/daytona/index.d.ts +1 -1
- package/dist/daytona/process.d.ts +1 -1
- package/dist/daytona/process.js +27 -4
- package/dist/daytona/sandbox.d.ts +1 -1
- package/dist/daytona/sandbox.js +16 -7
- package/dist/e2b/client.js +1 -1
- package/dist/e2b/code-interpreter.d.ts +4 -5
- package/dist/e2b/code-interpreter.js +54 -56
- package/dist/e2b/commands.d.ts +35 -12
- package/dist/e2b/commands.js +169 -73
- package/dist/e2b/errors.js +1 -1
- package/dist/e2b/filesystem.d.ts +8 -6
- package/dist/e2b/filesystem.js +127 -28
- package/dist/e2b/index.d.ts +1 -0
- package/dist/e2b/index.js +1 -0
- package/dist/e2b/pty.d.ts +24 -0
- package/dist/e2b/pty.js +80 -0
- package/dist/e2b/sandbox.d.ts +6 -4
- package/dist/e2b/sandbox.js +59 -25
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +15 -2
- package/dist/file-source.d.ts +16 -0
- package/dist/file-source.js +162 -0
- package/dist/freestyle/index.d.ts +309 -0
- package/dist/freestyle/index.js +343 -0
- package/dist/index.d.ts +3 -1
- package/dist/login.js +16 -10
- package/dist/mcp.js +2 -2
- package/dist/modal/index.d.ts +205 -0
- package/dist/modal/index.js +486 -0
- package/dist/network-cli.js +112 -44
- package/dist/openai-agents/index.js +18 -71
- package/dist/page.d.ts +1 -1
- package/dist/page.js +2 -2
- package/dist/products/audit.d.ts +3 -0
- package/dist/products/index.d.ts +14 -0
- package/dist/products/index.js +12 -0
- package/dist/products/interpreter.d.ts +11 -4
- package/dist/products/interpreter.js +33 -9
- package/dist/products/jobs.js +3 -2
- package/dist/products/limits.d.ts +10 -0
- package/dist/products/observability.d.ts +44 -0
- package/dist/products/observability.js +6 -0
- package/dist/products/previews.d.ts +18 -4
- package/dist/products/previews.js +14 -5
- package/dist/products/tailscale.d.ts +57 -0
- package/dist/products/tailscale.js +17 -0
- package/dist/products/volumes.d.ts +42 -0
- package/dist/products/volumes.js +36 -0
- package/dist/products/watch.d.ts +25 -4
- package/dist/products/watch.js +132 -18
- package/dist/proxy.js +3 -1
- package/dist/runloop/index.d.ts +326 -0
- package/dist/runloop/index.js +535 -0
- package/dist/runloop/pagination.d.ts +46 -0
- package/dist/runloop/pagination.js +82 -0
- package/dist/runloop/snapshots.d.ts +52 -0
- package/dist/runloop/snapshots.js +244 -0
- package/dist/sandbox.d.ts +76 -16
- package/dist/sandbox.js +696 -135
- package/dist/snapshots.d.ts +20 -5
- package/dist/snapshots.js +11 -1
- package/dist/sprites/index.d.ts +251 -0
- package/dist/sprites/index.js +588 -0
- package/dist/tar.d.ts +10 -1
- package/dist/tar.js +311 -35
- package/dist/tools/index.js +2 -1
- package/dist/transport.d.ts +23 -8
- package/dist/transport.js +247 -127
- package/dist/types.d.ts +52 -3
- package/dist/vercel/command.d.ts +1 -1
- package/dist/vercel/command.js +15 -3
- package/dist/vercel/errors.d.ts +2 -2
- package/dist/vercel/errors.js +19 -2
- package/dist/vercel/filesystem.d.ts +21 -18
- package/dist/vercel/filesystem.js +95 -48
- package/dist/vercel/sandbox.d.ts +8 -8
- package/dist/vercel/sandbox.js +159 -64
- package/dist/vercel/snapshot.d.ts +3 -2
- package/dist/vercel/snapshot.js +9 -5
- package/package.json +46 -3
package/dist/sandbox.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { KEEP_ALIVE_MARGIN_SECONDS, STREAMED_EXEC_TIMEOUT_MS, WAIT_FOR_TIMEOUT_SECONDS, } from "./api-defaults.js";
|
|
1
2
|
import { CommandError, ConnectionError, NotFoundError, RuntimeError } from "./errors.js";
|
|
2
3
|
import { Page } from "./page.js";
|
|
3
4
|
import { sandboxWatches, watchDirectory, } from "./products/watch.js";
|
|
@@ -12,6 +13,58 @@ const CHUNK = 1_048_576;
|
|
|
12
13
|
* connections at once. */
|
|
13
14
|
const PARALLEL = 8;
|
|
14
15
|
const enc = (id) => encodeURIComponent(id);
|
|
16
|
+
/** One explicit deadline across a composed operation and its queue/retries. */
|
|
17
|
+
function requestScope(options) {
|
|
18
|
+
const deadline = options.timeoutMs === undefined || options.timeoutMs === 0
|
|
19
|
+
? undefined
|
|
20
|
+
: AbortSignal.timeout(options.timeoutMs);
|
|
21
|
+
const signal = deadline && options.signal
|
|
22
|
+
? AbortSignal.any([deadline, options.signal])
|
|
23
|
+
: (deadline ?? options.signal);
|
|
24
|
+
const request = {
|
|
25
|
+
...options,
|
|
26
|
+
...(deadline ? { timeoutMs: 0 } : {}),
|
|
27
|
+
...(signal ? { signal } : {}),
|
|
28
|
+
};
|
|
29
|
+
const cancelled = () => new ConnectionError({
|
|
30
|
+
message: "The call was cancelled or ran past its deadline.",
|
|
31
|
+
code: "timeout",
|
|
32
|
+
status: 0,
|
|
33
|
+
cause: signal?.reason,
|
|
34
|
+
});
|
|
35
|
+
const check = () => {
|
|
36
|
+
if (signal?.aborted)
|
|
37
|
+
throw cancelled();
|
|
38
|
+
};
|
|
39
|
+
const wait = (pending) => {
|
|
40
|
+
if (!signal)
|
|
41
|
+
return pending;
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const abort = () => reject(cancelled());
|
|
44
|
+
if (signal.aborted)
|
|
45
|
+
abort();
|
|
46
|
+
else
|
|
47
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
48
|
+
pending.then((value) => {
|
|
49
|
+
signal.removeEventListener("abort", abort);
|
|
50
|
+
resolve(value);
|
|
51
|
+
}, (error) => {
|
|
52
|
+
signal.removeEventListener("abort", abort);
|
|
53
|
+
reject(error instanceof Error ? error : new Error(String(error), { cause: error }));
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
return { request, check, wait };
|
|
58
|
+
}
|
|
59
|
+
/** DELETE /v1/sandboxes/{id}, for `sandbox.delete()` and
|
|
60
|
+
* `runtime.sandboxes.delete(id)`. */
|
|
61
|
+
export function deleteSandbox(t, id, options = {}) {
|
|
62
|
+
return t.json({
|
|
63
|
+
method: "DELETE",
|
|
64
|
+
path: `/v1/sandboxes/${enc(id)}`,
|
|
65
|
+
...options,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
15
68
|
/* Web APIs only, so the sandbox's commands, processes and files work in a
|
|
16
69
|
browser with a session token (Sandbox.fromSession): no Buffer, no
|
|
17
70
|
node:crypto. */
|
|
@@ -85,6 +138,13 @@ export class Sandbox {
|
|
|
85
138
|
});
|
|
86
139
|
return new Sandbox(transport, { id: session.sandboxId });
|
|
87
140
|
}
|
|
141
|
+
/** The API's latest answer. `start`, the report of an image's start
|
|
142
|
+
* command, is the create's alone: the API keeps no record of it, so a
|
|
143
|
+
* later read of this sandbox keeps it here. */
|
|
144
|
+
#keep(info) {
|
|
145
|
+
const start = this.#info?.start;
|
|
146
|
+
this.#info = info.start === undefined && start !== undefined ? { ...info, start } : info;
|
|
147
|
+
}
|
|
88
148
|
/** What the API last said about this sandbox. `refresh()` asks again. */
|
|
89
149
|
get info() {
|
|
90
150
|
return this.#info;
|
|
@@ -93,21 +153,24 @@ export class Sandbox {
|
|
|
93
153
|
return this.#info.state;
|
|
94
154
|
}
|
|
95
155
|
async refresh(options = {}) {
|
|
96
|
-
this.#
|
|
156
|
+
this.#keep(await this.#t.json({
|
|
97
157
|
method: "GET",
|
|
98
158
|
path: `/v1/sandboxes/${enc(this.id)}`,
|
|
99
159
|
...options,
|
|
100
|
-
});
|
|
160
|
+
}));
|
|
101
161
|
return this;
|
|
102
162
|
}
|
|
103
163
|
/** Waits (server-side, no polling) until the sandbox reaches `state`. */
|
|
104
164
|
async waitFor(state, options = {}) {
|
|
105
|
-
this.#
|
|
165
|
+
this.#keep(await this.#t.json({
|
|
106
166
|
method: "GET",
|
|
107
167
|
path: `/v1/sandboxes/${enc(this.id)}`,
|
|
108
|
-
query: {
|
|
168
|
+
query: {
|
|
169
|
+
waitFor: state,
|
|
170
|
+
timeoutSeconds: options.timeoutSeconds ?? WAIT_FOR_TIMEOUT_SECONDS,
|
|
171
|
+
},
|
|
109
172
|
...options,
|
|
110
|
-
});
|
|
173
|
+
}));
|
|
111
174
|
return this;
|
|
112
175
|
}
|
|
113
176
|
/** Runs a command and returns its exit code and output. A string runs under
|
|
@@ -136,7 +199,7 @@ export class Sandbox {
|
|
|
136
199
|
processId = event.processId;
|
|
137
200
|
else if (event.type === "stdout" || event.type === "stderr") {
|
|
138
201
|
out[event.type] += event.data;
|
|
139
|
-
(event.type === "stdout" ? options.onStdout : options.onStderr)?.(event.data);
|
|
202
|
+
await (event.type === "stdout" ? options.onStdout : options.onStderr)?.(event.data);
|
|
140
203
|
}
|
|
141
204
|
else if (event.type === "truncated")
|
|
142
205
|
dropped = true;
|
|
@@ -206,9 +269,12 @@ export class Sandbox {
|
|
|
206
269
|
const events = this.#t.events({
|
|
207
270
|
method: "POST",
|
|
208
271
|
path: `/v1/sandboxes/${enc(this.id)}:exec`,
|
|
209
|
-
body: {
|
|
272
|
+
body: {
|
|
273
|
+
...commandBody(command, { timeoutMs: STREAMED_EXEC_TIMEOUT_MS, ...options }),
|
|
274
|
+
stream: true,
|
|
275
|
+
},
|
|
210
276
|
...pick(options),
|
|
211
|
-
timeoutMs: options.timeoutMs === undefined ?
|
|
277
|
+
timeoutMs: options.timeoutMs === undefined ? STREAMED_EXEC_TIMEOUT_MS : options.timeoutMs + 60_000,
|
|
212
278
|
});
|
|
213
279
|
try {
|
|
214
280
|
for await (const event of events) {
|
|
@@ -245,7 +311,7 @@ export class Sandbox {
|
|
|
245
311
|
* once. `stdin: "pipe"` keeps its input open for write(); `pty` gives it a
|
|
246
312
|
* terminal. */
|
|
247
313
|
async spawn(command, options = {}) {
|
|
248
|
-
const { stdin, pty, ...rest } = options;
|
|
314
|
+
const { stdin, pty, outputEncoding, request, ...rest } = options;
|
|
249
315
|
const info = await this.#t.json({
|
|
250
316
|
method: "POST",
|
|
251
317
|
path: `/v1/sandboxes/${enc(this.id)}/processes`,
|
|
@@ -253,8 +319,10 @@ export class Sandbox {
|
|
|
253
319
|
...commandBody(command, rest),
|
|
254
320
|
...(stdin === "pipe" ? { stdinMode: "pipe" } : stdin === undefined ? {} : { stdin }),
|
|
255
321
|
...(pty ? { pty } : {}),
|
|
322
|
+
...(outputEncoding ? { outputEncoding } : {}),
|
|
256
323
|
},
|
|
257
324
|
...pick(rest),
|
|
325
|
+
...request,
|
|
258
326
|
});
|
|
259
327
|
return new Process(this.#t, this.id, info);
|
|
260
328
|
}
|
|
@@ -304,6 +372,14 @@ export class Sandbox {
|
|
|
304
372
|
async pause(options = {}) {
|
|
305
373
|
return this.#lifecycle("pause", options);
|
|
306
374
|
}
|
|
375
|
+
/** Deletes it for good: stops it if it runs or is paused, deletes its disk
|
|
376
|
+
* and paused memory, revokes its previews and ports, and removes it from
|
|
377
|
+
* lists. Its snapshots, usage and audit entries stay. Deleting it again
|
|
378
|
+
* answers the same. */
|
|
379
|
+
async delete(options = {}) {
|
|
380
|
+
this.#keepAlive?.();
|
|
381
|
+
return deleteSandbox(this.#t, this.id, options);
|
|
382
|
+
}
|
|
307
383
|
/** Carries on a paused sandbox; `timeoutSeconds` is its new lease. */
|
|
308
384
|
async wake(options = {}) {
|
|
309
385
|
const { timeoutSeconds, ...rest } = options;
|
|
@@ -331,6 +407,26 @@ export class Sandbox {
|
|
|
331
407
|
async update(settings, options = {}) {
|
|
332
408
|
return this.#lifecycle("update", { ...options, wait: false }, { ...settings });
|
|
333
409
|
}
|
|
410
|
+
/** Moves it to another image (id, name, name:tag or name@version), keeping
|
|
411
|
+
* its id, /workspace (its home: dotfiles, pip --user and npm -g installs),
|
|
412
|
+
* volumes, environment, name and previews. Its processes restart, and
|
|
413
|
+
* everything else on its old disk (sudo installs, apt packages, /etc) is
|
|
414
|
+
* lost, which `keep: "workspace"` says you know; snapshot it first to keep
|
|
415
|
+
* everything. A running sandbox is paused first. A switch that fails is
|
|
416
|
+
* undone (`switch_undone`), the sandbox on its old image with nothing lost.
|
|
417
|
+
* Charged as a wake. */
|
|
418
|
+
async switchImage(image, options) {
|
|
419
|
+
const { keep, ...rest } = options;
|
|
420
|
+
this.#keep(await this.#t.json({
|
|
421
|
+
method: "POST",
|
|
422
|
+
path: `/v1/sandboxes/${enc(this.id)}:switch-image`,
|
|
423
|
+
body: { image, keep },
|
|
424
|
+
// A pause, two boots and the copy between them.
|
|
425
|
+
wait: 120,
|
|
426
|
+
...pick(rest),
|
|
427
|
+
}));
|
|
428
|
+
return this;
|
|
429
|
+
}
|
|
334
430
|
/** Keeps a running sandbox's lease ahead of now, in the background, until
|
|
335
431
|
* stop() or the returned function ends it: every `everySeconds` (60) it
|
|
336
432
|
* extends the lease so that `marginSeconds` (600) remain, never more than
|
|
@@ -341,7 +437,7 @@ export class Sandbox {
|
|
|
341
437
|
keepAlive(options = {}) {
|
|
342
438
|
this.#keepAlive?.();
|
|
343
439
|
const every = Math.max(10, options.everySeconds ?? 60) * 1000;
|
|
344
|
-
const margin = Math.min(3600, Math.max(60, options.marginSeconds ??
|
|
440
|
+
const margin = Math.min(3600, Math.max(60, options.marginSeconds ?? KEEP_ALIVE_MARGIN_SECONDS));
|
|
345
441
|
let ended = false;
|
|
346
442
|
let timer;
|
|
347
443
|
const end = () => {
|
|
@@ -386,34 +482,112 @@ export class Sandbox {
|
|
|
386
482
|
* the moment it takes, then woken; a paused one stays paused. */
|
|
387
483
|
async snapshot(options = {}) {
|
|
388
484
|
const { idempotencyKey, signal, timeoutMs, ...body } = options;
|
|
389
|
-
|
|
485
|
+
if (body.mode !== undefined && body.mode !== "memory" && body.mode !== "disk")
|
|
486
|
+
throw new TypeError("Snapshot mode must be memory or disk.");
|
|
487
|
+
signal?.throwIfAborted();
|
|
488
|
+
const until = performance.now() + Math.min(timeoutMs && timeoutMs > 0 ? timeoutMs : 60_000, 60_000);
|
|
489
|
+
const request = () => {
|
|
490
|
+
signal?.throwIfAborted();
|
|
491
|
+
const left = Math.ceil(until - performance.now());
|
|
492
|
+
if (left <= 0)
|
|
493
|
+
throw new RuntimeError({
|
|
494
|
+
code: "snapshot_timeout",
|
|
495
|
+
status: 0,
|
|
496
|
+
message: "Snapshot capture ran past its deadline.",
|
|
497
|
+
});
|
|
498
|
+
return { ...(signal ? { signal } : {}), timeoutMs: left };
|
|
499
|
+
};
|
|
500
|
+
await this.refresh(request());
|
|
390
501
|
// Straight after a fork or a wake the sandbox is still `resuming`, and
|
|
391
502
|
// after a pause still `pausing`: wait for where it is going, or a
|
|
392
503
|
// snapshot of it is refused as not paused (user lane, 23 September 2026).
|
|
393
504
|
if (this.state === "resuming" || this.state === "starting")
|
|
394
|
-
await this.waitFor("running");
|
|
505
|
+
await this.waitFor("running", request());
|
|
395
506
|
else if (this.state === "pausing")
|
|
396
|
-
await this.waitFor("paused");
|
|
507
|
+
await this.waitFor("paused", request());
|
|
397
508
|
const running = this.state === "running";
|
|
398
|
-
|
|
399
|
-
|
|
509
|
+
// Check before entering cleanup: an abort during refresh must not pause or wake.
|
|
510
|
+
const pauseOptions = running ? request() : undefined;
|
|
511
|
+
let captured;
|
|
512
|
+
let primary;
|
|
513
|
+
let failed = false;
|
|
400
514
|
try {
|
|
401
|
-
|
|
515
|
+
if (running)
|
|
516
|
+
await this.pause(pauseOptions);
|
|
517
|
+
let snapshot = await this.#t.json({
|
|
402
518
|
method: "POST",
|
|
403
519
|
path: `/v1/sandboxes/${enc(this.id)}:snapshot`,
|
|
404
520
|
body,
|
|
405
521
|
wait: 10,
|
|
406
|
-
...
|
|
407
|
-
|
|
408
|
-
...(signal ? { signal } : {}),
|
|
409
|
-
...(timeoutMs ? { timeoutMs } : {}),
|
|
410
|
-
}),
|
|
522
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
523
|
+
...request(),
|
|
411
524
|
});
|
|
525
|
+
captured = snapshot;
|
|
526
|
+
// Prefer: wait is bounded on the server. A capture still in progress
|
|
527
|
+
// must keep its source paused, or the worker refuses it after we wake.
|
|
528
|
+
while (snapshot.state === "capturing") {
|
|
529
|
+
signal?.throwIfAborted();
|
|
530
|
+
if (performance.now() >= until)
|
|
531
|
+
throw new RuntimeError({
|
|
532
|
+
code: "snapshot_timeout",
|
|
533
|
+
status: 0,
|
|
534
|
+
message: "Snapshot capture did not finish within one minute.",
|
|
535
|
+
details: { snapshotId: snapshot.id },
|
|
536
|
+
});
|
|
537
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
538
|
+
snapshot = await this.#t.json({
|
|
539
|
+
method: "GET",
|
|
540
|
+
path: `/v1/snapshots/${enc(snapshot.id)}`,
|
|
541
|
+
...request(),
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
if (snapshot.state !== "ready")
|
|
545
|
+
throw new RuntimeError({
|
|
546
|
+
code: "snapshot_failed",
|
|
547
|
+
status: 409,
|
|
548
|
+
message: snapshot.error ?? `Snapshot capture ended in state ${snapshot.state}.`,
|
|
549
|
+
details: { snapshotId: snapshot.id },
|
|
550
|
+
});
|
|
551
|
+
if (body.mode === "disk" && snapshot.mode !== "disk")
|
|
552
|
+
throw new RuntimeError({
|
|
553
|
+
code: "snapshot_mode_mismatch",
|
|
554
|
+
status: 409,
|
|
555
|
+
message: "The server did not confirm a disk-only snapshot.",
|
|
556
|
+
details: { snapshotId: snapshot.id },
|
|
557
|
+
});
|
|
558
|
+
captured = snapshot;
|
|
412
559
|
}
|
|
413
|
-
|
|
414
|
-
|
|
560
|
+
catch (error) {
|
|
561
|
+
failed = true;
|
|
562
|
+
primary = error;
|
|
563
|
+
}
|
|
564
|
+
if (running) {
|
|
565
|
+
try {
|
|
415
566
|
await this.wake();
|
|
567
|
+
}
|
|
568
|
+
catch (wakeError) {
|
|
569
|
+
const recovery = {
|
|
570
|
+
...(captured ? { snapshotId: captured.id } : {}),
|
|
571
|
+
sourceSandboxId: this.id,
|
|
572
|
+
sourceWakeError: {
|
|
573
|
+
...(wakeError instanceof RuntimeError ? { code: wakeError.code } : {}),
|
|
574
|
+
message: wakeError instanceof Error ? wakeError.message : String(wakeError),
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
const reported = failed ? primary : wakeError;
|
|
578
|
+
if (reported instanceof Error && Object.isExtensible(reported)) {
|
|
579
|
+
if (reported instanceof RuntimeError)
|
|
580
|
+
Object.assign(reported, { details: { ...reported.details, ...recovery } });
|
|
581
|
+
else
|
|
582
|
+
Object.assign(reported, recovery);
|
|
583
|
+
}
|
|
584
|
+
if (!failed)
|
|
585
|
+
throw wakeError;
|
|
586
|
+
}
|
|
416
587
|
}
|
|
588
|
+
if (failed)
|
|
589
|
+
throw primary;
|
|
590
|
+
return captured;
|
|
417
591
|
}
|
|
418
592
|
async fork(options = {}) {
|
|
419
593
|
const { idempotencyKey, signal, timeoutMs, ...body } = options;
|
|
@@ -436,13 +610,13 @@ export class Sandbox {
|
|
|
436
610
|
return this.#lifecycle("restart", options);
|
|
437
611
|
}
|
|
438
612
|
async #lifecycle(verb, options, body = {}) {
|
|
439
|
-
this.#
|
|
613
|
+
this.#keep(await this.#t.json({
|
|
440
614
|
method: "POST",
|
|
441
615
|
path: `/v1/sandboxes/${enc(this.id)}:${verb}`,
|
|
442
616
|
body,
|
|
443
617
|
wait: options.wait === false ? 0 : 60,
|
|
444
618
|
...pick(options),
|
|
445
|
-
});
|
|
619
|
+
}));
|
|
446
620
|
return this;
|
|
447
621
|
}
|
|
448
622
|
async [Symbol.asyncDispose]() {
|
|
@@ -481,11 +655,27 @@ class OutputCursor {
|
|
|
481
655
|
droppedBytes: event.offset - this.cursor,
|
|
482
656
|
resumeAt: event.offset,
|
|
483
657
|
};
|
|
484
|
-
this.cursor = Math.max(this.cursor, event.offset +
|
|
658
|
+
this.cursor = Math.max(this.cursor, event.offset +
|
|
659
|
+
(event.base64 === undefined
|
|
660
|
+
? utf8(event.data).byteLength
|
|
661
|
+
: processBytes(event.base64).length));
|
|
485
662
|
}
|
|
486
663
|
yield event;
|
|
487
664
|
}
|
|
488
665
|
}
|
|
666
|
+
function processBytes(encoded) {
|
|
667
|
+
try {
|
|
668
|
+
return atob(encoded);
|
|
669
|
+
}
|
|
670
|
+
catch (cause) {
|
|
671
|
+
throw new RuntimeError({
|
|
672
|
+
code: "invalid_process_output",
|
|
673
|
+
status: 502,
|
|
674
|
+
message: "The process returned invalid base64 output.",
|
|
675
|
+
cause,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
489
679
|
/** A stream cut off by the network rather than refused by Runtime: worth
|
|
490
680
|
* reconnecting to from the cursor. */
|
|
491
681
|
function cutOff(error) {
|
|
@@ -575,16 +765,18 @@ export class Processes {
|
|
|
575
765
|
this.t = t;
|
|
576
766
|
this.sandboxId = sandboxId;
|
|
577
767
|
}
|
|
578
|
-
async list() {
|
|
768
|
+
async list(options = {}) {
|
|
579
769
|
return (await this.t.json({
|
|
580
770
|
method: "GET",
|
|
581
771
|
path: `/v1/sandboxes/${enc(this.sandboxId)}/processes`,
|
|
772
|
+
...options,
|
|
582
773
|
})).data;
|
|
583
774
|
}
|
|
584
|
-
async get(processId) {
|
|
775
|
+
async get(processId, options = {}) {
|
|
585
776
|
const info = await this.t.json({
|
|
586
777
|
method: "GET",
|
|
587
778
|
path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(processId)}`,
|
|
779
|
+
...options,
|
|
588
780
|
});
|
|
589
781
|
return new Process(this.t, this.sandboxId, info);
|
|
590
782
|
}
|
|
@@ -637,6 +829,7 @@ export class Process {
|
|
|
637
829
|
sandboxId;
|
|
638
830
|
info;
|
|
639
831
|
#inputOffset;
|
|
832
|
+
#inputWrites = Promise.resolve();
|
|
640
833
|
constructor(t, sandboxId, info) {
|
|
641
834
|
this.t = t;
|
|
642
835
|
this.sandboxId = sandboxId;
|
|
@@ -650,6 +843,34 @@ export class Process {
|
|
|
650
843
|
output(options = {}) {
|
|
651
844
|
return new Processes(this.t, this.sandboxId).follow(this.id, options);
|
|
652
845
|
}
|
|
846
|
+
/** Lossless output for a process spawned with outputEncoding: "base64".
|
|
847
|
+
* Refuses text-only output rather than inventing bytes that were discarded. */
|
|
848
|
+
async *outputBytes(options = {}) {
|
|
849
|
+
if (this.info.outputEncoding !== "base64")
|
|
850
|
+
throw new RuntimeError({
|
|
851
|
+
code: "binary_output_unavailable",
|
|
852
|
+
status: 409,
|
|
853
|
+
message: "Spawn the process with outputEncoding: base64 to read lossless bytes.",
|
|
854
|
+
});
|
|
855
|
+
for await (const event of this.output(options)) {
|
|
856
|
+
if (event.type === "stdout" || event.type === "stderr") {
|
|
857
|
+
if (event.base64 === undefined)
|
|
858
|
+
throw new RuntimeError({
|
|
859
|
+
code: "binary_output_unavailable",
|
|
860
|
+
status: 502,
|
|
861
|
+
message: "The process returned text without its original bytes.",
|
|
862
|
+
});
|
|
863
|
+
const raw = processBytes(event.base64);
|
|
864
|
+
yield {
|
|
865
|
+
type: event.type,
|
|
866
|
+
offset: event.offset,
|
|
867
|
+
data: Uint8Array.from(raw, (c) => c.charCodeAt(0)),
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
else
|
|
871
|
+
yield event;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
653
874
|
/** Waits for the process to end and returns its result. */
|
|
654
875
|
async wait(options = {}) {
|
|
655
876
|
const out = { stdout: "", stderr: "" };
|
|
@@ -671,36 +892,76 @@ export class Process {
|
|
|
671
892
|
processId: this.id,
|
|
672
893
|
};
|
|
673
894
|
}
|
|
674
|
-
/** Sends input. Offsets
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
895
|
+
/** Sends input. Offsets protect transport retries; signal and timeoutMs cover
|
|
896
|
+
* the whole logical write, including its queue and backpressure. */
|
|
897
|
+
write(data, options = {}) {
|
|
898
|
+
if (options.idempotencyKey !== undefined)
|
|
899
|
+
return Promise.reject(new RuntimeError({
|
|
900
|
+
message: "Process input writes do not accept caller idempotency keys; offsets protect transport retries.",
|
|
901
|
+
code: "unsupported_process_write_option",
|
|
902
|
+
status: 400,
|
|
903
|
+
}));
|
|
904
|
+
// Copy before queuing: callers may reuse their buffer while a previous
|
|
905
|
+
// write waits for the guest to drain its pipe.
|
|
906
|
+
const bytes = new Uint8Array(utf8(data));
|
|
907
|
+
const eof = options.eof === true;
|
|
908
|
+
const { eof: _eof, ...requestOptions } = options;
|
|
909
|
+
const scope = requestScope(requestOptions);
|
|
910
|
+
const write = this.#inputWrites.then(async () => {
|
|
911
|
+
let sent = 0;
|
|
912
|
+
let chunkSize = CHUNK;
|
|
913
|
+
do {
|
|
914
|
+
scope.check();
|
|
915
|
+
const chunk = bytes.subarray(sent, sent + chunkSize);
|
|
916
|
+
const reply = await this.t.json({
|
|
917
|
+
method: "POST",
|
|
918
|
+
path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(this.id)}:write`,
|
|
919
|
+
body: {
|
|
920
|
+
base64: base64(chunk),
|
|
921
|
+
offset: this.#inputOffset,
|
|
922
|
+
...(eof && sent + chunk.length === bytes.length ? { eof: true } : {}),
|
|
923
|
+
},
|
|
924
|
+
...scope.request,
|
|
925
|
+
});
|
|
926
|
+
const accepted = reply.offset - this.#inputOffset;
|
|
927
|
+
if (!Number.isSafeInteger(reply.offset) || accepted < 0 || accepted > chunk.length)
|
|
928
|
+
throw new ConnectionError({
|
|
929
|
+
message: "The process returned an invalid input offset.",
|
|
930
|
+
code: "connection_error",
|
|
931
|
+
status: 0,
|
|
932
|
+
});
|
|
933
|
+
sent += accepted;
|
|
934
|
+
this.#inputOffset = reply.offset;
|
|
935
|
+
// A slow pipe may take only part of a chunk. Keep later uploads within
|
|
936
|
+
// that observed capacity instead of resending almost a MiB each time.
|
|
937
|
+
// Zero is legitimate backpressure: retry a bounded probe at the same
|
|
938
|
+
// offset without discarding bytes or inventing progress.
|
|
939
|
+
if (accepted < chunk.length) {
|
|
940
|
+
chunkSize = accepted > 0 ? accepted : Math.min(chunkSize, 65_536);
|
|
941
|
+
// A full pipe must yield even if a proxy answers immediately; don't
|
|
942
|
+
// spin requests while the guest has accepted none of this input.
|
|
943
|
+
if (accepted === 0)
|
|
944
|
+
await scope.wait(new Promise((resolve) => setTimeout(resolve, 100)));
|
|
945
|
+
}
|
|
946
|
+
} while (sent < bytes.length);
|
|
947
|
+
});
|
|
948
|
+
this.#inputWrites = write.catch(() => undefined);
|
|
949
|
+
return scope.wait(write);
|
|
691
950
|
}
|
|
692
|
-
async kill(signal = "SIGTERM") {
|
|
951
|
+
async kill(signal = "SIGTERM", options = {}) {
|
|
693
952
|
await this.t.json({
|
|
694
953
|
method: "POST",
|
|
695
954
|
path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(this.id)}:signal`,
|
|
696
955
|
body: { signal },
|
|
956
|
+
...options,
|
|
697
957
|
});
|
|
698
958
|
}
|
|
699
|
-
async resize(cols, rows) {
|
|
959
|
+
async resize(cols, rows, options = {}) {
|
|
700
960
|
await this.t.json({
|
|
701
961
|
method: "POST",
|
|
702
962
|
path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(this.id)}:resize`,
|
|
703
963
|
body: { cols, rows },
|
|
964
|
+
...options,
|
|
704
965
|
});
|
|
705
966
|
}
|
|
706
967
|
async refresh() {
|
|
@@ -834,6 +1095,8 @@ export class Files {
|
|
|
834
1095
|
* sets its permissions (0o755 for a program); 0o644 when left out. */
|
|
835
1096
|
async write(path, data, writeOptions = {}) {
|
|
836
1097
|
const { mode, ...options } = writeOptions;
|
|
1098
|
+
const scope = requestScope(options);
|
|
1099
|
+
scope.check();
|
|
837
1100
|
const octal = mode === undefined ? undefined : (mode & 0o777).toString(8).padStart(3, "0");
|
|
838
1101
|
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
839
1102
|
if (bytes.length <= CHUNK) {
|
|
@@ -844,27 +1107,100 @@ export class Files {
|
|
|
844
1107
|
path: this.#path("/files/content"),
|
|
845
1108
|
query: { path, ...(octal ? { mode: octal } : {}) },
|
|
846
1109
|
bytes,
|
|
847
|
-
...
|
|
1110
|
+
...scope.request,
|
|
848
1111
|
});
|
|
849
1112
|
return { path, size: bytes.length };
|
|
850
1113
|
}
|
|
851
|
-
const sha256 = await sha256Hex(bytes);
|
|
1114
|
+
const sha256 = await scope.wait(sha256Hex(bytes));
|
|
1115
|
+
return this.#writeUpload(path, {
|
|
1116
|
+
size: bytes.length,
|
|
1117
|
+
sha256,
|
|
1118
|
+
chunkAt: (offset, length) => bytes.subarray(offset, offset + length),
|
|
1119
|
+
}, octal, scope);
|
|
1120
|
+
}
|
|
1121
|
+
/** Uploads byte streams with bounded memory on Node/Bun. Unknown-length
|
|
1122
|
+
* inputs stage once in a private local temporary file; known local files
|
|
1123
|
+
* and Blobs avoid that extra disk copy. Paths are local file paths. */
|
|
1124
|
+
async writeStream(path, input, writeOptions = {}) {
|
|
1125
|
+
const { mode, onProgress, ...options } = writeOptions;
|
|
1126
|
+
if (input instanceof Uint8Array) {
|
|
1127
|
+
const result = await this.write(path, input, {
|
|
1128
|
+
...options,
|
|
1129
|
+
...(mode === undefined ? {} : { mode }),
|
|
1130
|
+
});
|
|
1131
|
+
onProgress?.({ bytesSent: result.size });
|
|
1132
|
+
return result;
|
|
1133
|
+
}
|
|
1134
|
+
const scope = requestScope(options);
|
|
1135
|
+
scope.check();
|
|
1136
|
+
if (!globalThis.process
|
|
1137
|
+
?.versions?.node)
|
|
1138
|
+
throw new RuntimeError({
|
|
1139
|
+
message: "Streamed file uploads require Node or Bun for incremental hashing and private local staging. Use files.write with byte data in a browser.",
|
|
1140
|
+
code: "unsupported_runtime",
|
|
1141
|
+
status: 0,
|
|
1142
|
+
});
|
|
1143
|
+
const { prepareFileSource } = await import("./file-source.js");
|
|
1144
|
+
const source = await prepareFileSource(input, { ...scope, signal: scope.request.signal });
|
|
1145
|
+
let written;
|
|
1146
|
+
try {
|
|
1147
|
+
if (source.size <= CHUNK) {
|
|
1148
|
+
written = await this.write(path, await scope.wait(source.chunkAt(0, source.size)), {
|
|
1149
|
+
...scope.request,
|
|
1150
|
+
...(mode === undefined ? {} : { mode }),
|
|
1151
|
+
});
|
|
1152
|
+
onProgress?.({ bytesSent: written.size });
|
|
1153
|
+
}
|
|
1154
|
+
else {
|
|
1155
|
+
const octal = mode === undefined ? undefined : (mode & 0o777).toString(8).padStart(3, "0");
|
|
1156
|
+
written = await this.#writeUpload(path, source, octal, scope, onProgress);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
catch (primary) {
|
|
1160
|
+
try {
|
|
1161
|
+
await source.close();
|
|
1162
|
+
}
|
|
1163
|
+
catch (cleanupError) {
|
|
1164
|
+
if (primary === undefined)
|
|
1165
|
+
throw cleanupError;
|
|
1166
|
+
if (primary instanceof Error && Object.isExtensible(primary)) {
|
|
1167
|
+
try {
|
|
1168
|
+
Object.defineProperty(primary, "cleanupError", {
|
|
1169
|
+
value: cleanupError,
|
|
1170
|
+
configurable: true,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
catch {
|
|
1174
|
+
/* Keep the original upload failure. */
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
throw primary;
|
|
1179
|
+
}
|
|
1180
|
+
// A failed close after a good upload is the call's failure.
|
|
1181
|
+
await source.close();
|
|
1182
|
+
return written;
|
|
1183
|
+
}
|
|
1184
|
+
/** One implementation of atomic, checked chunk uploads for bytes and streams. */
|
|
1185
|
+
async #writeUpload(path, source, octal, scope, onProgress) {
|
|
1186
|
+
const sha256 = source.sha256;
|
|
852
1187
|
// A guest journals mutations across operations, not per URL. One logical
|
|
853
1188
|
// write therefore owns separate stable keys for each mutation phase.
|
|
854
|
-
const uploadKey =
|
|
1189
|
+
const uploadKey = scope.request.idempotencyKey ?? crypto.randomUUID();
|
|
855
1190
|
const phaseKeys = new Map();
|
|
856
1191
|
for (const phase of ["begin", "legacy-begin", "commit", "chmod", "abort"])
|
|
857
1192
|
phaseKeys.set(phase, await sha256Hex(JSON.stringify(["files.write", uploadKey, phase])));
|
|
858
1193
|
const phaseOptions = (phase) => ({
|
|
859
|
-
...
|
|
1194
|
+
...scope.request,
|
|
860
1195
|
idempotencyKey: phaseKeys.get(phase),
|
|
861
1196
|
});
|
|
862
1197
|
let begin;
|
|
1198
|
+
scope.check();
|
|
863
1199
|
try {
|
|
864
1200
|
begin = await this.t.json({
|
|
865
1201
|
method: "POST",
|
|
866
1202
|
path: this.#path("/uploads"),
|
|
867
|
-
body: { path, size:
|
|
1203
|
+
body: { path, size: source.size, sha256, ...(octal ? { mode: octal } : {}) },
|
|
868
1204
|
...phaseOptions("begin"),
|
|
869
1205
|
});
|
|
870
1206
|
}
|
|
@@ -873,114 +1209,293 @@ export class Files {
|
|
|
873
1209
|
throw error;
|
|
874
1210
|
// The API aborted the unsupported transfer before accepting chunks.
|
|
875
1211
|
// A different body needs a different key; retain deterministic retries.
|
|
1212
|
+
scope.check();
|
|
876
1213
|
begin = await this.t.json({
|
|
877
1214
|
method: "POST",
|
|
878
1215
|
path: this.#path("/uploads"),
|
|
879
|
-
body: { path, size:
|
|
1216
|
+
body: { path, size: source.size, sha256 },
|
|
880
1217
|
...phaseOptions("legacy-begin"),
|
|
881
1218
|
});
|
|
882
1219
|
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1220
|
+
// A malformed response does not prove ownership of any transfer to abort.
|
|
1221
|
+
if (typeof begin.uploadId !== "string" || begin.uploadId.length === 0)
|
|
1222
|
+
throw new ConnectionError({
|
|
1223
|
+
message: "The file upload returned no transfer identifier.",
|
|
1224
|
+
code: "connection_error",
|
|
1225
|
+
status: 0,
|
|
1226
|
+
});
|
|
1227
|
+
const commit = () => {
|
|
1228
|
+
scope.check();
|
|
1229
|
+
return this.t.json({
|
|
1230
|
+
method: "POST",
|
|
1231
|
+
path: this.#path(`/uploads/${enc(begin.uploadId)}:commit`),
|
|
1232
|
+
body: {},
|
|
1233
|
+
...phaseOptions("commit"),
|
|
1234
|
+
});
|
|
1235
|
+
};
|
|
889
1236
|
let committed = false;
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
1237
|
+
let nextOffset = 0;
|
|
1238
|
+
const stopChunks = new AbortController();
|
|
1239
|
+
const chunkSignal = scope.request.signal
|
|
1240
|
+
? AbortSignal.any([scope.request.signal, stopChunks.signal])
|
|
1241
|
+
: stopChunks.signal;
|
|
1242
|
+
const chunkScope = requestScope({ ...scope.request, signal: chunkSignal });
|
|
1243
|
+
let bytesSent = 0;
|
|
1244
|
+
let failed = false;
|
|
1245
|
+
let primaryFailure;
|
|
1246
|
+
const worker = async () => {
|
|
893
1247
|
try {
|
|
894
|
-
|
|
895
|
-
|
|
1248
|
+
while (nextOffset < source.size) {
|
|
1249
|
+
scope.check();
|
|
1250
|
+
if (stopChunks.signal.aborted)
|
|
1251
|
+
return;
|
|
1252
|
+
const offset = nextOffset;
|
|
1253
|
+
nextOffset += begin.chunkBytes;
|
|
1254
|
+
const offered = source.chunkAt(offset, Math.min(begin.chunkBytes, source.size - offset));
|
|
1255
|
+
const chunk = offered instanceof Promise ? await chunkScope.wait(offered) : offered;
|
|
1256
|
+
chunkScope.check();
|
|
1257
|
+
if (chunk.length !== Math.min(begin.chunkBytes, source.size - offset))
|
|
1258
|
+
throw new ConnectionError({
|
|
1259
|
+
message: "The local upload source returned an incomplete chunk.",
|
|
1260
|
+
code: "source_changed",
|
|
1261
|
+
status: 0,
|
|
1262
|
+
});
|
|
1263
|
+
const receipt = await this.t.json({
|
|
1264
|
+
method: "PUT",
|
|
1265
|
+
path: this.#path(`/uploads/${enc(begin.uploadId)}`),
|
|
1266
|
+
query: { offset },
|
|
1267
|
+
bytes: chunk,
|
|
1268
|
+
...scope.request,
|
|
1269
|
+
signal: chunkSignal,
|
|
1270
|
+
// Offsets and byte hashes make chunk replay safe without mutation keys.
|
|
1271
|
+
idempotencyKey: undefined,
|
|
1272
|
+
});
|
|
1273
|
+
// Parallel receipts report cumulative accepted bytes, not this offset.
|
|
1274
|
+
if (!Number.isSafeInteger(receipt.received) ||
|
|
1275
|
+
receipt.received < chunk.length ||
|
|
1276
|
+
receipt.received > source.size)
|
|
1277
|
+
throw new ConnectionError({
|
|
1278
|
+
message: "The file upload returned invalid byte progress.",
|
|
1279
|
+
code: "connection_error",
|
|
1280
|
+
status: 0,
|
|
1281
|
+
});
|
|
1282
|
+
bytesSent += chunk.length;
|
|
1283
|
+
onProgress?.({ bytesSent });
|
|
1284
|
+
}
|
|
896
1285
|
}
|
|
897
1286
|
catch (error) {
|
|
898
|
-
if (!
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
offsets.push(offset);
|
|
905
|
-
let next = 0;
|
|
906
|
-
const worker = async () => {
|
|
907
|
-
while (next < offsets.length) {
|
|
908
|
-
const offset = offsets[next++];
|
|
909
|
-
await this.t.bytes({
|
|
910
|
-
method: "PUT",
|
|
911
|
-
path: this.#path(`/uploads/${enc(begin.uploadId)}`),
|
|
912
|
-
query: { offset },
|
|
913
|
-
bytes: bytes.subarray(offset, offset + begin.chunkBytes),
|
|
914
|
-
});
|
|
1287
|
+
if (!failed) {
|
|
1288
|
+
failed = true;
|
|
1289
|
+
primaryFailure = error;
|
|
1290
|
+
stopChunks.abort(error);
|
|
1291
|
+
}
|
|
1292
|
+
throw error;
|
|
915
1293
|
}
|
|
916
1294
|
};
|
|
917
1295
|
try {
|
|
1296
|
+
if (!Number.isSafeInteger(begin.chunkBytes) ||
|
|
1297
|
+
begin.chunkBytes <= 0 ||
|
|
1298
|
+
begin.chunkBytes > CHUNK)
|
|
1299
|
+
throw new ConnectionError({
|
|
1300
|
+
message: "The file upload returned an invalid chunk size.",
|
|
1301
|
+
code: "connection_error",
|
|
1302
|
+
status: 0,
|
|
1303
|
+
});
|
|
1304
|
+
if (begin.replayed) {
|
|
1305
|
+
// A completed commit removed the transfer. Ask its journal before
|
|
1306
|
+
// resending chunks; an incomplete transfer alone is safe to continue.
|
|
1307
|
+
try {
|
|
1308
|
+
await commit();
|
|
1309
|
+
committed = true;
|
|
1310
|
+
onProgress?.({ bytesSent: source.size });
|
|
1311
|
+
}
|
|
1312
|
+
catch (error) {
|
|
1313
|
+
if (!(error instanceof RuntimeError) || error.code !== "upload_incomplete")
|
|
1314
|
+
throw error;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
918
1317
|
if (!committed) {
|
|
919
|
-
|
|
1318
|
+
const workers = Array.from({ length: Math.min(PARALLEL, Math.ceil(source.size / begin.chunkBytes)) }, worker);
|
|
1319
|
+
// Drain every owned in-flight chunk before aborting its transfer.
|
|
1320
|
+
// Promise.all's early rejection could otherwise leave writes racing cleanup.
|
|
1321
|
+
await Promise.allSettled(workers);
|
|
1322
|
+
if (failed)
|
|
1323
|
+
throw primaryFailure;
|
|
920
1324
|
await commit();
|
|
921
1325
|
}
|
|
922
1326
|
// An older sandbox image ignores the mode in the upload; set it after.
|
|
923
|
-
if (octal && begin.mode !== octal)
|
|
1327
|
+
if (octal && begin.mode !== octal) {
|
|
1328
|
+
scope.check();
|
|
924
1329
|
await this.t.json({
|
|
925
1330
|
method: "POST",
|
|
926
1331
|
path: this.#path("/files:chmod"),
|
|
927
1332
|
body: { path, mode: octal },
|
|
928
1333
|
...phaseOptions("chmod"),
|
|
929
1334
|
});
|
|
1335
|
+
}
|
|
930
1336
|
}
|
|
931
1337
|
catch (error) {
|
|
932
|
-
|
|
933
|
-
.json({
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1338
|
+
try {
|
|
1339
|
+
await this.t.json({
|
|
1340
|
+
method: "POST",
|
|
1341
|
+
path: this.#path(`/uploads/${enc(begin.uploadId)}:abort`),
|
|
1342
|
+
body: {},
|
|
1343
|
+
idempotencyKey: phaseKeys.get("abort"),
|
|
1344
|
+
timeoutMs: 5000,
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
catch (cleanupError) {
|
|
1348
|
+
// Keep the original failure; retain cleanup evidence when possible.
|
|
1349
|
+
if (error instanceof Error && Object.isExtensible(error)) {
|
|
1350
|
+
try {
|
|
1351
|
+
Object.defineProperty(error, error.cause === undefined ? "cause" : "cleanupError", {
|
|
1352
|
+
value: cleanupError,
|
|
1353
|
+
configurable: true,
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
catch {
|
|
1357
|
+
/* A frozen property cannot replace the primary failure. */
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
940
1361
|
throw error;
|
|
941
1362
|
}
|
|
942
|
-
return { path, size:
|
|
1363
|
+
return { path, size: source.size };
|
|
943
1364
|
}
|
|
944
1365
|
/** Directory entries; `depth` goes deeper, `glob` filters (e.g. "**\/*.py"). */
|
|
945
1366
|
async list(path = "/workspace", options = {}) {
|
|
1367
|
+
const { signal, timeoutMs, idempotencyKey, ...query } = options;
|
|
946
1368
|
return (await this.t.json({
|
|
947
1369
|
method: "GET",
|
|
948
1370
|
path: this.#path("/files/list"),
|
|
949
|
-
query: { path, ...
|
|
1371
|
+
query: { path, ...query },
|
|
1372
|
+
signal,
|
|
1373
|
+
timeoutMs,
|
|
1374
|
+
idempotencyKey,
|
|
950
1375
|
})).data;
|
|
951
1376
|
}
|
|
952
1377
|
async glob(pattern, root = "/workspace") {
|
|
953
1378
|
return this.list(root, { glob: pattern });
|
|
954
1379
|
}
|
|
955
|
-
async stat(path) {
|
|
956
|
-
return this.t.json({
|
|
1380
|
+
async stat(path, options = {}) {
|
|
1381
|
+
return this.t.json({
|
|
1382
|
+
method: "GET",
|
|
1383
|
+
path: this.#path("/files/stat"),
|
|
1384
|
+
query: { path },
|
|
1385
|
+
...options,
|
|
1386
|
+
});
|
|
957
1387
|
}
|
|
958
|
-
async exists(path) {
|
|
959
|
-
return (await this.stat(path)).exists;
|
|
1388
|
+
async exists(path, options = {}) {
|
|
1389
|
+
return (await this.stat(path, options)).exists;
|
|
960
1390
|
}
|
|
961
1391
|
async mkdir(path, options = {}) {
|
|
1392
|
+
const { parents, ...request } = options;
|
|
962
1393
|
await this.t.json({
|
|
963
1394
|
method: "POST",
|
|
964
1395
|
path: this.#path("/files:mkdir"),
|
|
965
|
-
body: { path,
|
|
1396
|
+
body: { path, parents },
|
|
1397
|
+
...request,
|
|
966
1398
|
});
|
|
967
1399
|
}
|
|
968
1400
|
async remove(path, options = {}) {
|
|
1401
|
+
const { recursive, ...request } = options;
|
|
969
1402
|
return (await this.t.json({
|
|
970
1403
|
method: "POST",
|
|
971
1404
|
path: this.#path("/files:remove"),
|
|
972
|
-
body: { path,
|
|
1405
|
+
body: { path, recursive },
|
|
1406
|
+
...request,
|
|
973
1407
|
})).removed;
|
|
974
1408
|
}
|
|
975
1409
|
async rename(from, to, options = {}) {
|
|
1410
|
+
const { overwrite, ...request } = options;
|
|
976
1411
|
await this.t.json({
|
|
977
1412
|
method: "POST",
|
|
978
1413
|
path: this.#path("/files:rename"),
|
|
979
|
-
body: {
|
|
1414
|
+
body: {
|
|
1415
|
+
from,
|
|
1416
|
+
to,
|
|
1417
|
+
...(overwrite === undefined ? {} : { overwrite }),
|
|
1418
|
+
},
|
|
1419
|
+
...request,
|
|
980
1420
|
});
|
|
981
1421
|
}
|
|
1422
|
+
/** Packs a directory as archive bytes; `gzip: false` returns plain tar.
|
|
1423
|
+
* Exclusions are relative paths, not globs. `user: root` selects guest root. */
|
|
1424
|
+
async archive(path, options = {}) {
|
|
1425
|
+
const { gzip = true, exclude, user, ...request } = options;
|
|
1426
|
+
return this.t.fileBytes({
|
|
1427
|
+
method: "GET",
|
|
1428
|
+
path: this.#path("/files/archive"),
|
|
1429
|
+
query: { path, gzip, ...(exclude ? { exclude } : {}), ...(user ? { user } : {}) },
|
|
1430
|
+
accept: gzip ? "application/gzip" : "application/x-tar",
|
|
1431
|
+
...request,
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
/** Unpacks archive bytes in a directory with the sandbox user's rights by
|
|
1435
|
+
* default; `user: root` explicitly selects root inside this guest. */
|
|
1436
|
+
async unarchive(path, bytes, options = {}) {
|
|
1437
|
+
const { gzip = bytes[0] === 0x1f && bytes[1] === 0x8b, user, ...request } = options;
|
|
1438
|
+
const scope = requestScope(request);
|
|
1439
|
+
scope.check();
|
|
1440
|
+
if (bytes.length <= CHUNK) {
|
|
1441
|
+
await this.t.bytes({
|
|
1442
|
+
method: "PUT",
|
|
1443
|
+
path: this.#path("/files/archive"),
|
|
1444
|
+
query: { path, ...(user ? { user } : {}) },
|
|
1445
|
+
bytes,
|
|
1446
|
+
...scope.request,
|
|
1447
|
+
});
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
const key = request.idempotencyKey ?? crypto.randomUUID();
|
|
1451
|
+
const phase = async (name) => ({
|
|
1452
|
+
...scope.request,
|
|
1453
|
+
idempotencyKey: await sha256Hex(JSON.stringify(["files.unarchive", key, name])),
|
|
1454
|
+
});
|
|
1455
|
+
const begin = await this.t.json({
|
|
1456
|
+
method: "POST",
|
|
1457
|
+
path: this.#path("/files/archive/uploads"),
|
|
1458
|
+
body: { path, gzip, ...(user ? { user } : {}) },
|
|
1459
|
+
...(await phase("begin")),
|
|
1460
|
+
});
|
|
1461
|
+
const upload = (suffix = "") => this.#path(`/files/archive/uploads/${enc(begin.uploadId)}${suffix}`);
|
|
1462
|
+
try {
|
|
1463
|
+
if (!Number.isSafeInteger(begin.chunkBytes) || begin.chunkBytes <= 0)
|
|
1464
|
+
throw new ConnectionError({
|
|
1465
|
+
message: "The archive upload returned an invalid chunk size.",
|
|
1466
|
+
code: "connection_error",
|
|
1467
|
+
status: 0,
|
|
1468
|
+
});
|
|
1469
|
+
for (let offset = 0; offset < bytes.length; offset += begin.chunkBytes) {
|
|
1470
|
+
scope.check();
|
|
1471
|
+
await this.t.bytes({
|
|
1472
|
+
method: "PUT",
|
|
1473
|
+
path: upload(),
|
|
1474
|
+
query: { offset },
|
|
1475
|
+
bytes: bytes.subarray(offset, offset + begin.chunkBytes),
|
|
1476
|
+
...(await phase(`chunk:${offset}`)),
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
scope.check();
|
|
1480
|
+
await this.t.json({
|
|
1481
|
+
method: "POST",
|
|
1482
|
+
path: upload(":commit"),
|
|
1483
|
+
body: {},
|
|
1484
|
+
...(await phase("commit")),
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
catch (error) {
|
|
1488
|
+
// Cleanup must still run when the caller's signal/deadline has expired.
|
|
1489
|
+
const idempotencyKey = await sha256Hex(JSON.stringify(["files.unarchive", key, "abort"]));
|
|
1490
|
+
await this.t
|
|
1491
|
+
.json({ method: "POST", path: upload(":abort"), body: {}, idempotencyKey })
|
|
1492
|
+
.catch(() => undefined);
|
|
1493
|
+
throw error;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
982
1496
|
/** Copies a local file or directory into the sandbox. A directory travels as
|
|
983
|
-
* one gzipped tar and
|
|
1497
|
+
* one gzipped tar to the API's folder routes, and the sandbox's own `tar`
|
|
1498
|
+
* unpacks it as it arrives, making the folder. */
|
|
984
1499
|
async upload(localPath, remotePath) {
|
|
985
1500
|
const { stat, readFile } = await import("node:fs/promises");
|
|
986
1501
|
const info = await stat(localPath);
|
|
@@ -1000,41 +1515,77 @@ export class Files {
|
|
|
1000
1515
|
}
|
|
1001
1516
|
const { packDirectory } = await import("./tar.js");
|
|
1002
1517
|
const archive = await packDirectory(localPath);
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1518
|
+
if (archive.length <= CHUNK) {
|
|
1519
|
+
await this.t.bytes({
|
|
1520
|
+
method: "PUT",
|
|
1521
|
+
path: this.#path("/files/archive"),
|
|
1522
|
+
query: { path: remotePath },
|
|
1523
|
+
bytes: archive,
|
|
1524
|
+
});
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
// Larger: parts in order, each unpacked as it arrives; a part resent
|
|
1528
|
+
// after a lost answer is not written twice.
|
|
1529
|
+
const begin = await this.t.json({
|
|
1530
|
+
method: "POST",
|
|
1531
|
+
path: this.#path("/files/archive/uploads"),
|
|
1532
|
+
body: { path: remotePath, gzip: true },
|
|
1533
|
+
});
|
|
1534
|
+
const upload = (suffix = "") => this.#path(`/files/archive/uploads/${enc(begin.uploadId)}${suffix}`);
|
|
1535
|
+
try {
|
|
1536
|
+
for (let offset = 0; offset < archive.length; offset += begin.chunkBytes)
|
|
1537
|
+
await this.t.bytes({
|
|
1538
|
+
method: "PUT",
|
|
1539
|
+
path: upload(),
|
|
1540
|
+
query: { offset },
|
|
1541
|
+
bytes: archive.subarray(offset, offset + begin.chunkBytes),
|
|
1542
|
+
});
|
|
1543
|
+
await this.t.json({ method: "POST", path: upload(":commit"), body: {} });
|
|
1544
|
+
}
|
|
1545
|
+
catch (error) {
|
|
1546
|
+
await this.t
|
|
1547
|
+
.json({ method: "POST", path: upload(":abort"), body: {} })
|
|
1548
|
+
.catch(() => undefined);
|
|
1549
|
+
throw error;
|
|
1550
|
+
}
|
|
1017
1551
|
}
|
|
1018
1552
|
/** Streams a file to disk through a partial file beside the target, renamed
|
|
1019
1553
|
* into place only once every byte has arrived: a failed copy never leaves
|
|
1020
1554
|
* a short file under the name asked for. A short stream is tried again,
|
|
1021
1555
|
* twice, as a short `read` is. */
|
|
1022
|
-
async #streamTo(remotePath, localPath) {
|
|
1556
|
+
async #streamTo(remotePath, localPath, options = {}) {
|
|
1557
|
+
const scope = requestScope(options);
|
|
1023
1558
|
const fs = await import("node:fs/promises");
|
|
1024
1559
|
const partial = `${localPath}.runtime-partial-${crypto.randomUUID().slice(0, 8)}`;
|
|
1025
1560
|
for (let attempt = 0;; attempt++) {
|
|
1026
1561
|
try {
|
|
1027
|
-
|
|
1562
|
+
scope.check();
|
|
1563
|
+
const stream = await this.readStream(remotePath, scope.request);
|
|
1028
1564
|
// The handle is closed before anything else touches the file, so a
|
|
1029
1565
|
// failed copy's partial file is really gone when the error returns.
|
|
1030
|
-
const file = await fs.open(partial, "w");
|
|
1031
1566
|
try {
|
|
1032
|
-
|
|
1033
|
-
|
|
1567
|
+
const file = await fs.open(partial, "w");
|
|
1568
|
+
try {
|
|
1569
|
+
for await (const chunk of stream) {
|
|
1570
|
+
let written = 0;
|
|
1571
|
+
while (written < chunk.length) {
|
|
1572
|
+
scope.check();
|
|
1573
|
+
const result = await file.write(chunk.subarray(written));
|
|
1574
|
+
if (result.bytesWritten === 0)
|
|
1575
|
+
throw new Error("The local file accepted no bytes.");
|
|
1576
|
+
written += result.bytesWritten;
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
finally {
|
|
1581
|
+
await file.close();
|
|
1582
|
+
}
|
|
1034
1583
|
}
|
|
1035
1584
|
finally {
|
|
1036
|
-
|
|
1585
|
+
// Opening or writing the local file can fail before consumption.
|
|
1586
|
+
await stream.cancel().catch(() => undefined);
|
|
1037
1587
|
}
|
|
1588
|
+
scope.check();
|
|
1038
1589
|
await fs.rename(partial, localPath);
|
|
1039
1590
|
return;
|
|
1040
1591
|
}
|
|
@@ -1048,11 +1599,14 @@ export class Files {
|
|
|
1048
1599
|
}
|
|
1049
1600
|
}
|
|
1050
1601
|
/** Copies a file or directory out of the sandbox. A file streams to disk,
|
|
1051
|
-
* any size, checked against its length
|
|
1052
|
-
|
|
1602
|
+
* any size, checked against its length; a directory comes as one tar from
|
|
1603
|
+
* the API's folder route. */
|
|
1604
|
+
async download(remotePath, localPath, options = {}) {
|
|
1605
|
+
const scope = requestScope(options);
|
|
1606
|
+
scope.check();
|
|
1053
1607
|
const fs = await import("node:fs/promises");
|
|
1054
1608
|
const paths = await import("node:path");
|
|
1055
|
-
const entry = await this.stat(remotePath);
|
|
1609
|
+
const entry = await this.stat(remotePath, scope.request);
|
|
1056
1610
|
if (!entry.exists)
|
|
1057
1611
|
throw new RuntimeError({
|
|
1058
1612
|
message: `${remotePath} does not exist.`,
|
|
@@ -1061,27 +1615,34 @@ export class Files {
|
|
|
1061
1615
|
});
|
|
1062
1616
|
if (entry.type !== "directory") {
|
|
1063
1617
|
await fs.mkdir(paths.dirname(localPath), { recursive: true });
|
|
1064
|
-
await this.#streamTo(remotePath, localPath);
|
|
1618
|
+
await this.#streamTo(remotePath, localPath, scope.request);
|
|
1065
1619
|
return;
|
|
1066
1620
|
}
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
const
|
|
1070
|
-
|
|
1071
|
-
|
|
1621
|
+
// The sandbox's own `tar` packs it as it streams, and it is unpacked as
|
|
1622
|
+
// it arrives; one cut short is refused and nothing is put in place.
|
|
1623
|
+
const archive = await this.t.fileStream({
|
|
1624
|
+
method: "GET",
|
|
1625
|
+
path: this.#path("/files/archive"),
|
|
1626
|
+
query: { path: remotePath, gzip: true },
|
|
1627
|
+
accept: "application/gzip",
|
|
1628
|
+
...scope.request,
|
|
1629
|
+
});
|
|
1630
|
+
const { unpackStream } = await import("./tar.js");
|
|
1072
1631
|
try {
|
|
1073
|
-
|
|
1074
|
-
|
|
1632
|
+
await unpackStream(archive, localPath, {
|
|
1633
|
+
signal: scope.request.signal,
|
|
1634
|
+
});
|
|
1075
1635
|
}
|
|
1076
1636
|
finally {
|
|
1077
|
-
await
|
|
1637
|
+
await archive.cancel().catch(() => undefined);
|
|
1078
1638
|
}
|
|
1079
1639
|
}
|
|
1080
1640
|
}
|
|
1081
|
-
export function sandboxPage(t, body, query) {
|
|
1641
|
+
export function sandboxPage(t, body, query, options = {}) {
|
|
1082
1642
|
return new Page(body.data.map((info) => new Sandbox(t, info)), body.nextCursor, async (cursor) => sandboxPage(t, await t.json({
|
|
1083
1643
|
method: "GET",
|
|
1084
1644
|
path: "/v1/sandboxes",
|
|
1085
1645
|
query: Object.assign({}, query, { cursor }),
|
|
1086
|
-
|
|
1646
|
+
...options,
|
|
1647
|
+
}), query, options));
|
|
1087
1648
|
}
|