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.
Files changed (132) hide show
  1. package/BLAXEL.md +4 -4
  2. package/COMPATIBILITY.md +145 -0
  3. package/DAYTONA.md +13 -7
  4. package/E2B.md +34 -20
  5. package/README.md +22 -11
  6. package/README.src.md +22 -11
  7. package/VERCEL.md +13 -3
  8. package/compatibility-lock.json +357 -0
  9. package/dist/ai-harness/index.js +2 -2
  10. package/dist/api-defaults.d.ts +17 -0
  11. package/dist/api-defaults.js +22 -0
  12. package/dist/blaxel/errors.js +1 -1
  13. package/dist/blaxel/lookup.d.ts +3 -2
  14. package/dist/blaxel/lookup.js +3 -2
  15. package/dist/blaxel/pagination.d.ts +2 -2
  16. package/dist/blaxel/pagination.js +45 -17
  17. package/dist/blaxel/preview.d.ts +3 -2
  18. package/dist/blaxel/preview.js +16 -14
  19. package/dist/blaxel/process.d.ts +1 -1
  20. package/dist/blaxel/process.js +66 -11
  21. package/dist/blaxel/sandbox.d.ts +2 -0
  22. package/dist/blaxel/sandbox.js +2 -0
  23. package/dist/blaxel/snapshot.js +3 -5
  24. package/dist/cli-words.d.ts +27 -0
  25. package/dist/cli-words.js +135 -0
  26. package/dist/cli.d.ts +2 -0
  27. package/dist/cli.js +351 -85
  28. package/dist/client.d.ts +33 -4
  29. package/dist/client.js +47 -12
  30. package/dist/cloudflare/identity.d.ts +7 -0
  31. package/dist/cloudflare/identity.js +13 -0
  32. package/dist/cloudflare/index.d.ts +303 -0
  33. package/dist/cloudflare/index.js +592 -0
  34. package/dist/cloudflare/worker.d.ts +419 -0
  35. package/dist/cloudflare/worker.js +447 -0
  36. package/dist/codesandbox/guest-service.d.ts +31 -0
  37. package/dist/codesandbox/guest-service.js +490 -0
  38. package/dist/codesandbox/index.d.ts +386 -0
  39. package/dist/codesandbox/index.js +1347 -0
  40. package/dist/compat/core.d.ts +40 -0
  41. package/dist/compat/core.js +238 -0
  42. package/dist/compat/files.d.ts +26 -0
  43. package/dist/compat/files.js +133 -0
  44. package/dist/compat/shell.d.ts +4 -0
  45. package/dist/compat/shell.js +165 -0
  46. package/dist/credentials.d.ts +6 -1
  47. package/dist/credentials.js +107 -38
  48. package/dist/daytona/context.js +2 -1
  49. package/dist/daytona/daytona.js +11 -4
  50. package/dist/daytona/filesystem.d.ts +17 -4
  51. package/dist/daytona/filesystem.js +121 -38
  52. package/dist/daytona/index.d.ts +1 -1
  53. package/dist/daytona/process.d.ts +1 -1
  54. package/dist/daytona/process.js +27 -4
  55. package/dist/daytona/sandbox.d.ts +1 -1
  56. package/dist/daytona/sandbox.js +16 -7
  57. package/dist/e2b/client.js +1 -1
  58. package/dist/e2b/code-interpreter.d.ts +4 -5
  59. package/dist/e2b/code-interpreter.js +54 -56
  60. package/dist/e2b/commands.d.ts +35 -12
  61. package/dist/e2b/commands.js +169 -73
  62. package/dist/e2b/errors.js +1 -1
  63. package/dist/e2b/filesystem.d.ts +8 -6
  64. package/dist/e2b/filesystem.js +127 -28
  65. package/dist/e2b/index.d.ts +1 -0
  66. package/dist/e2b/index.js +1 -0
  67. package/dist/e2b/pty.d.ts +24 -0
  68. package/dist/e2b/pty.js +80 -0
  69. package/dist/e2b/sandbox.d.ts +6 -4
  70. package/dist/e2b/sandbox.js +59 -25
  71. package/dist/errors.d.ts +8 -0
  72. package/dist/errors.js +15 -2
  73. package/dist/file-source.d.ts +16 -0
  74. package/dist/file-source.js +162 -0
  75. package/dist/freestyle/index.d.ts +309 -0
  76. package/dist/freestyle/index.js +343 -0
  77. package/dist/index.d.ts +3 -1
  78. package/dist/login.js +16 -10
  79. package/dist/mcp.js +2 -2
  80. package/dist/modal/index.d.ts +205 -0
  81. package/dist/modal/index.js +486 -0
  82. package/dist/network-cli.js +112 -44
  83. package/dist/openai-agents/index.js +18 -71
  84. package/dist/page.d.ts +1 -1
  85. package/dist/page.js +2 -2
  86. package/dist/products/audit.d.ts +3 -0
  87. package/dist/products/index.d.ts +14 -0
  88. package/dist/products/index.js +12 -0
  89. package/dist/products/interpreter.d.ts +11 -4
  90. package/dist/products/interpreter.js +33 -9
  91. package/dist/products/jobs.js +3 -2
  92. package/dist/products/limits.d.ts +10 -0
  93. package/dist/products/observability.d.ts +44 -0
  94. package/dist/products/observability.js +6 -0
  95. package/dist/products/previews.d.ts +18 -4
  96. package/dist/products/previews.js +14 -5
  97. package/dist/products/tailscale.d.ts +57 -0
  98. package/dist/products/tailscale.js +17 -0
  99. package/dist/products/volumes.d.ts +42 -0
  100. package/dist/products/volumes.js +36 -0
  101. package/dist/products/watch.d.ts +25 -4
  102. package/dist/products/watch.js +132 -18
  103. package/dist/proxy.js +3 -1
  104. package/dist/runloop/index.d.ts +326 -0
  105. package/dist/runloop/index.js +535 -0
  106. package/dist/runloop/pagination.d.ts +46 -0
  107. package/dist/runloop/pagination.js +82 -0
  108. package/dist/runloop/snapshots.d.ts +52 -0
  109. package/dist/runloop/snapshots.js +244 -0
  110. package/dist/sandbox.d.ts +76 -16
  111. package/dist/sandbox.js +696 -135
  112. package/dist/snapshots.d.ts +20 -5
  113. package/dist/snapshots.js +11 -1
  114. package/dist/sprites/index.d.ts +251 -0
  115. package/dist/sprites/index.js +588 -0
  116. package/dist/tar.d.ts +10 -1
  117. package/dist/tar.js +311 -35
  118. package/dist/tools/index.js +2 -1
  119. package/dist/transport.d.ts +23 -8
  120. package/dist/transport.js +247 -127
  121. package/dist/types.d.ts +52 -3
  122. package/dist/vercel/command.d.ts +1 -1
  123. package/dist/vercel/command.js +15 -3
  124. package/dist/vercel/errors.d.ts +2 -2
  125. package/dist/vercel/errors.js +19 -2
  126. package/dist/vercel/filesystem.d.ts +21 -18
  127. package/dist/vercel/filesystem.js +95 -48
  128. package/dist/vercel/sandbox.d.ts +8 -8
  129. package/dist/vercel/sandbox.js +159 -64
  130. package/dist/vercel/snapshot.d.ts +3 -2
  131. package/dist/vercel/snapshot.js +9 -5
  132. 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.#info = await this.#t.json({
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.#info = await this.#t.json({
165
+ this.#keep(await this.#t.json({
106
166
  method: "GET",
107
167
  path: `/v1/sandboxes/${enc(this.id)}`,
108
- query: { waitFor: state, timeoutSeconds: options.timeoutSeconds ?? 60 },
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: { ...commandBody(command, { timeoutMs: 86_400_000, ...options }), stream: true },
272
+ body: {
273
+ ...commandBody(command, { timeoutMs: STREAMED_EXEC_TIMEOUT_MS, ...options }),
274
+ stream: true,
275
+ },
210
276
  ...pick(options),
211
- timeoutMs: options.timeoutMs === undefined ? 86_400_000 : options.timeoutMs + 60_000,
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 ?? 600));
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
- await this.refresh();
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
- if (running)
399
- await this.pause();
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
- return await this.#t.json({
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
- ...pick({
407
- ...(idempotencyKey ? { idempotencyKey } : {}),
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
- finally {
414
- if (running)
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.#info = await this.#t.json({
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 + utf8(event.data).byteLength);
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 are tracked for you, so a retried write is never typed twice. */
675
- async write(data, options = {}) {
676
- const bytes = utf8(data);
677
- let sent = 0;
678
- do {
679
- const reply = await this.t.json({
680
- method: "POST",
681
- path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(this.id)}:write`,
682
- body: {
683
- base64: base64(bytes.subarray(sent)),
684
- offset: this.#inputOffset,
685
- ...(options.eof ? { eof: true } : {}),
686
- },
687
- });
688
- sent += reply.offset - this.#inputOffset;
689
- this.#inputOffset = reply.offset;
690
- } while (sent < bytes.length);
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
- ...options,
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 = options.idempotencyKey ?? crypto.randomUUID();
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
- ...options,
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: bytes.length, sha256, ...(octal ? { mode: octal } : {}) },
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: bytes.length, sha256 },
1216
+ body: { path, size: source.size, sha256 },
880
1217
  ...phaseOptions("legacy-begin"),
881
1218
  });
882
1219
  }
883
- const commit = () => this.t.json({
884
- method: "POST",
885
- path: this.#path(`/uploads/${enc(begin.uploadId)}:commit`),
886
- body: {},
887
- ...phaseOptions("commit"),
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
- if (begin.replayed) {
891
- // A completed commit removed the transfer. Ask its journal before
892
- // resending chunks; an incomplete transfer alone is safe to continue.
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
- await commit();
895
- committed = true;
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 (!(error instanceof RuntimeError) || error.code !== "upload_incomplete")
899
- throw error;
900
- }
901
- }
902
- const offsets = [];
903
- for (let offset = 0; offset < bytes.length; offset += begin.chunkBytes)
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
- await Promise.all(Array.from({ length: Math.min(PARALLEL, offsets.length) }, worker));
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
- await this.t
933
- .json({
934
- method: "POST",
935
- path: this.#path(`/uploads/${enc(begin.uploadId)}:abort`),
936
- body: {},
937
- ...phaseOptions("abort"),
938
- })
939
- .catch(() => undefined);
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: bytes.length };
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, ...options },
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({ method: "GET", path: this.#path("/files/stat"), query: { path } });
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, ...options },
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, ...options },
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: { from, to, ...options },
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 is unpacked in place. */
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
- // Large writes go only to /workspace (the server refuses others), so the
1004
- // archive is staged there and removed after unpacking.
1005
- const staging = `/workspace/.runtime-upload-${crypto.randomUUID()}.tar.gz`;
1006
- await this.write(staging, archive);
1007
- const result = await new Sandbox(this.t, { id: this.sandboxId }).exec([
1008
- "sh",
1009
- "-c",
1010
- 'mkdir -p "$1" && tar -xpzf "$2" -C "$1"; code=$?; rm -f "$2"; exit $code',
1011
- "sh",
1012
- remotePath,
1013
- staging,
1014
- ]);
1015
- if (result.exitCode !== 0)
1016
- throw new CommandError(result);
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
- const stream = await this.readStream(remotePath);
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
- for await (const chunk of stream)
1033
- await file.write(chunk);
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
- await file.close();
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
- async download(remotePath, localPath) {
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
- const staging = `/tmp/.runtime-download-${crypto.randomUUID()}.tar.gz`;
1068
- const sandbox = new Sandbox(this.t, { id: this.sandboxId });
1069
- const packed = await sandbox.exec(["tar", "-czf", staging, "-C", remotePath, "."]);
1070
- if (packed.exitCode !== 0)
1071
- throw new CommandError(packed);
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
- const { unpackArchive } = await import("./tar.js");
1074
- await unpackArchive(await this.read(staging), localPath);
1632
+ await unpackStream(archive, localPath, {
1633
+ signal: scope.request.signal,
1634
+ });
1075
1635
  }
1076
1636
  finally {
1077
- await this.remove(staging).catch(() => undefined);
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
- }), query));
1646
+ ...options,
1647
+ }), query, options));
1087
1648
  }