withruntime 0.8.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/COMPATIBILITY.md +145 -0
  2. package/E2B.md +23 -13
  3. package/README.md +8 -5
  4. package/README.src.md +8 -5
  5. package/compatibility-lock.json +281 -0
  6. package/dist/ai-harness/index.js +2 -2
  7. package/dist/api-defaults.d.ts +17 -0
  8. package/dist/api-defaults.js +22 -0
  9. package/dist/blaxel/preview.d.ts +1 -1
  10. package/dist/blaxel/preview.js +1 -1
  11. package/dist/blaxel/process.d.ts +1 -1
  12. package/dist/blaxel/process.js +11 -2
  13. package/dist/cli-words.d.ts +27 -0
  14. package/dist/cli-words.js +135 -0
  15. package/dist/cli.js +162 -31
  16. package/dist/client.d.ts +4 -1
  17. package/dist/client.js +8 -2
  18. package/dist/cloudflare/index.d.ts +294 -0
  19. package/dist/cloudflare/index.js +554 -0
  20. package/dist/cloudflare/worker.d.ts +415 -0
  21. package/dist/cloudflare/worker.js +297 -0
  22. package/dist/codesandbox/guest-service.d.ts +31 -0
  23. package/dist/codesandbox/guest-service.js +453 -0
  24. package/dist/codesandbox/index.d.ts +386 -0
  25. package/dist/codesandbox/index.js +1313 -0
  26. package/dist/compat/core.d.ts +39 -0
  27. package/dist/compat/core.js +199 -0
  28. package/dist/compat/files.d.ts +26 -0
  29. package/dist/compat/files.js +115 -0
  30. package/dist/compat/shell.d.ts +4 -0
  31. package/dist/compat/shell.js +165 -0
  32. package/dist/daytona/context.js +2 -1
  33. package/dist/daytona/process.d.ts +1 -1
  34. package/dist/daytona/process.js +27 -4
  35. package/dist/e2b/code-interpreter.d.ts +4 -5
  36. package/dist/e2b/code-interpreter.js +54 -56
  37. package/dist/e2b/commands.d.ts +5 -4
  38. package/dist/e2b/commands.js +37 -29
  39. package/dist/e2b/errors.js +1 -1
  40. package/dist/e2b/filesystem.d.ts +8 -6
  41. package/dist/e2b/filesystem.js +127 -28
  42. package/dist/e2b/sandbox.d.ts +4 -3
  43. package/dist/e2b/sandbox.js +10 -10
  44. package/dist/errors.d.ts +8 -0
  45. package/dist/errors.js +15 -2
  46. package/dist/freestyle/index.d.ts +301 -0
  47. package/dist/freestyle/index.js +310 -0
  48. package/dist/index.d.ts +1 -0
  49. package/dist/mcp.js +2 -2
  50. package/dist/modal/index.d.ts +205 -0
  51. package/dist/modal/index.js +485 -0
  52. package/dist/network-cli.js +1 -1
  53. package/dist/openai-agents/index.js +9 -26
  54. package/dist/products/audit.d.ts +3 -0
  55. package/dist/products/index.d.ts +2 -0
  56. package/dist/products/index.js +2 -0
  57. package/dist/products/interpreter.d.ts +11 -4
  58. package/dist/products/interpreter.js +33 -9
  59. package/dist/products/jobs.js +3 -2
  60. package/dist/products/limits.d.ts +10 -0
  61. package/dist/products/previews.d.ts +10 -2
  62. package/dist/products/previews.js +3 -2
  63. package/dist/products/tailscale.d.ts +57 -0
  64. package/dist/products/tailscale.js +17 -0
  65. package/dist/products/watch.d.ts +25 -4
  66. package/dist/products/watch.js +75 -14
  67. package/dist/runloop/index.d.ts +340 -0
  68. package/dist/runloop/index.js +438 -0
  69. package/dist/runloop/pagination.d.ts +46 -0
  70. package/dist/runloop/pagination.js +82 -0
  71. package/dist/runloop/snapshots.d.ts +52 -0
  72. package/dist/runloop/snapshots.js +244 -0
  73. package/dist/sandbox.d.ts +40 -9
  74. package/dist/sandbox.js +273 -76
  75. package/dist/snapshots.d.ts +11 -1
  76. package/dist/snapshots.js +11 -1
  77. package/dist/sprites/index.d.ts +251 -0
  78. package/dist/sprites/index.js +552 -0
  79. package/dist/tar.d.ts +8 -1
  80. package/dist/tar.js +185 -35
  81. package/dist/tools/index.js +2 -1
  82. package/dist/transport.d.ts +20 -2
  83. package/dist/transport.js +48 -11
  84. package/dist/types.d.ts +52 -3
  85. package/dist/vercel/command.js +12 -1
  86. package/dist/vercel/errors.d.ts +2 -2
  87. package/dist/vercel/errors.js +12 -1
  88. package/dist/vercel/filesystem.d.ts +21 -18
  89. package/dist/vercel/filesystem.js +95 -48
  90. package/dist/vercel/sandbox.js +5 -5
  91. package/package.json +45 -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,15 @@ const CHUNK = 1_048_576;
12
13
  * connections at once. */
13
14
  const PARALLEL = 8;
14
15
  const enc = (id) => encodeURIComponent(id);
16
+ /** DELETE /v1/sandboxes/{id}, for `sandbox.delete()` and
17
+ * `runtime.sandboxes.delete(id)`. */
18
+ export function deleteSandbox(t, id, options = {}) {
19
+ return t.json({
20
+ method: "DELETE",
21
+ path: `/v1/sandboxes/${enc(id)}`,
22
+ ...options,
23
+ });
24
+ }
15
25
  /* Web APIs only, so the sandbox's commands, processes and files work in a
16
26
  browser with a session token (Sandbox.fromSession): no Buffer, no
17
27
  node:crypto. */
@@ -85,6 +95,13 @@ export class Sandbox {
85
95
  });
86
96
  return new Sandbox(transport, { id: session.sandboxId });
87
97
  }
98
+ /** The API's latest answer. `start`, the report of an image's start
99
+ * command, is the create's alone: the API keeps no record of it, so a
100
+ * later read of this sandbox keeps it here. */
101
+ #keep(info) {
102
+ const start = this.#info?.start;
103
+ this.#info = info.start === undefined && start !== undefined ? { ...info, start } : info;
104
+ }
88
105
  /** What the API last said about this sandbox. `refresh()` asks again. */
89
106
  get info() {
90
107
  return this.#info;
@@ -93,21 +110,24 @@ export class Sandbox {
93
110
  return this.#info.state;
94
111
  }
95
112
  async refresh(options = {}) {
96
- this.#info = await this.#t.json({
113
+ this.#keep(await this.#t.json({
97
114
  method: "GET",
98
115
  path: `/v1/sandboxes/${enc(this.id)}`,
99
116
  ...options,
100
- });
117
+ }));
101
118
  return this;
102
119
  }
103
120
  /** Waits (server-side, no polling) until the sandbox reaches `state`. */
104
121
  async waitFor(state, options = {}) {
105
- this.#info = await this.#t.json({
122
+ this.#keep(await this.#t.json({
106
123
  method: "GET",
107
124
  path: `/v1/sandboxes/${enc(this.id)}`,
108
- query: { waitFor: state, timeoutSeconds: options.timeoutSeconds ?? 60 },
125
+ query: {
126
+ waitFor: state,
127
+ timeoutSeconds: options.timeoutSeconds ?? WAIT_FOR_TIMEOUT_SECONDS,
128
+ },
109
129
  ...options,
110
- });
130
+ }));
111
131
  return this;
112
132
  }
113
133
  /** Runs a command and returns its exit code and output. A string runs under
@@ -136,7 +156,7 @@ export class Sandbox {
136
156
  processId = event.processId;
137
157
  else if (event.type === "stdout" || event.type === "stderr") {
138
158
  out[event.type] += event.data;
139
- (event.type === "stdout" ? options.onStdout : options.onStderr)?.(event.data);
159
+ await (event.type === "stdout" ? options.onStdout : options.onStderr)?.(event.data);
140
160
  }
141
161
  else if (event.type === "truncated")
142
162
  dropped = true;
@@ -206,9 +226,12 @@ export class Sandbox {
206
226
  const events = this.#t.events({
207
227
  method: "POST",
208
228
  path: `/v1/sandboxes/${enc(this.id)}:exec`,
209
- body: { ...commandBody(command, { timeoutMs: 86_400_000, ...options }), stream: true },
229
+ body: {
230
+ ...commandBody(command, { timeoutMs: STREAMED_EXEC_TIMEOUT_MS, ...options }),
231
+ stream: true,
232
+ },
210
233
  ...pick(options),
211
- timeoutMs: options.timeoutMs === undefined ? 86_400_000 : options.timeoutMs + 60_000,
234
+ timeoutMs: options.timeoutMs === undefined ? STREAMED_EXEC_TIMEOUT_MS : options.timeoutMs + 60_000,
212
235
  });
213
236
  try {
214
237
  for await (const event of events) {
@@ -245,7 +268,7 @@ export class Sandbox {
245
268
  * once. `stdin: "pipe"` keeps its input open for write(); `pty` gives it a
246
269
  * terminal. */
247
270
  async spawn(command, options = {}) {
248
- const { stdin, pty, ...rest } = options;
271
+ const { stdin, pty, outputEncoding, ...rest } = options;
249
272
  const info = await this.#t.json({
250
273
  method: "POST",
251
274
  path: `/v1/sandboxes/${enc(this.id)}/processes`,
@@ -253,6 +276,7 @@ export class Sandbox {
253
276
  ...commandBody(command, rest),
254
277
  ...(stdin === "pipe" ? { stdinMode: "pipe" } : stdin === undefined ? {} : { stdin }),
255
278
  ...(pty ? { pty } : {}),
279
+ ...(outputEncoding ? { outputEncoding } : {}),
256
280
  },
257
281
  ...pick(rest),
258
282
  });
@@ -304,6 +328,14 @@ export class Sandbox {
304
328
  async pause(options = {}) {
305
329
  return this.#lifecycle("pause", options);
306
330
  }
331
+ /** Deletes it for good: stops it if it runs or is paused, deletes its disk
332
+ * and paused memory, revokes its previews and ports, and removes it from
333
+ * lists. Its snapshots, usage and audit entries stay. Deleting it again
334
+ * answers the same. */
335
+ async delete(options = {}) {
336
+ this.#keepAlive?.();
337
+ return deleteSandbox(this.#t, this.id, options);
338
+ }
307
339
  /** Carries on a paused sandbox; `timeoutSeconds` is its new lease. */
308
340
  async wake(options = {}) {
309
341
  const { timeoutSeconds, ...rest } = options;
@@ -331,6 +363,26 @@ export class Sandbox {
331
363
  async update(settings, options = {}) {
332
364
  return this.#lifecycle("update", { ...options, wait: false }, { ...settings });
333
365
  }
366
+ /** Moves it to another image (id, name, name:tag or name@version), keeping
367
+ * its id, /workspace (its home: dotfiles, pip --user and npm -g installs),
368
+ * volumes, environment, name and previews. Its processes restart, and
369
+ * everything else on its old disk (sudo installs, apt packages, /etc) is
370
+ * lost, which `keep: "workspace"` says you know; snapshot it first to keep
371
+ * everything. A running sandbox is paused first. A switch that fails is
372
+ * undone (`switch_undone`), the sandbox on its old image with nothing lost.
373
+ * Charged as a wake. */
374
+ async switchImage(image, options) {
375
+ const { keep, ...rest } = options;
376
+ this.#keep(await this.#t.json({
377
+ method: "POST",
378
+ path: `/v1/sandboxes/${enc(this.id)}:switch-image`,
379
+ body: { image, keep },
380
+ // A pause, two boots and the copy between them.
381
+ wait: 120,
382
+ ...pick(rest),
383
+ }));
384
+ return this;
385
+ }
334
386
  /** Keeps a running sandbox's lease ahead of now, in the background, until
335
387
  * stop() or the returned function ends it: every `everySeconds` (60) it
336
388
  * extends the lease so that `marginSeconds` (600) remain, never more than
@@ -341,7 +393,7 @@ export class Sandbox {
341
393
  keepAlive(options = {}) {
342
394
  this.#keepAlive?.();
343
395
  const every = Math.max(10, options.everySeconds ?? 60) * 1000;
344
- const margin = Math.min(3600, Math.max(60, options.marginSeconds ?? 600));
396
+ const margin = Math.min(3600, Math.max(60, options.marginSeconds ?? KEEP_ALIVE_MARGIN_SECONDS));
345
397
  let ended = false;
346
398
  let timer;
347
399
  const end = () => {
@@ -386,29 +438,76 @@ export class Sandbox {
386
438
  * the moment it takes, then woken; a paused one stays paused. */
387
439
  async snapshot(options = {}) {
388
440
  const { idempotencyKey, signal, timeoutMs, ...body } = options;
389
- await this.refresh();
441
+ if (body.mode !== undefined && body.mode !== "memory" && body.mode !== "disk")
442
+ throw new TypeError("Snapshot mode must be memory or disk.");
443
+ signal?.throwIfAborted();
444
+ const until = performance.now() + Math.min(timeoutMs && timeoutMs > 0 ? timeoutMs : 60_000, 60_000);
445
+ const request = () => {
446
+ signal?.throwIfAborted();
447
+ const left = Math.ceil(until - performance.now());
448
+ if (left <= 0)
449
+ throw new RuntimeError({
450
+ code: "snapshot_timeout",
451
+ status: 0,
452
+ message: "Snapshot capture ran past its deadline.",
453
+ });
454
+ return { ...(signal ? { signal } : {}), timeoutMs: left };
455
+ };
456
+ await this.refresh(request());
390
457
  // Straight after a fork or a wake the sandbox is still `resuming`, and
391
458
  // after a pause still `pausing`: wait for where it is going, or a
392
459
  // snapshot of it is refused as not paused (user lane, 23 September 2026).
393
460
  if (this.state === "resuming" || this.state === "starting")
394
- await this.waitFor("running");
461
+ await this.waitFor("running", request());
395
462
  else if (this.state === "pausing")
396
- await this.waitFor("paused");
463
+ await this.waitFor("paused", request());
397
464
  const running = this.state === "running";
398
- if (running)
399
- await this.pause();
465
+ // Check before entering cleanup: an abort during refresh must not pause or wake.
466
+ const pauseOptions = running ? request() : undefined;
400
467
  try {
401
- return await this.#t.json({
468
+ if (running)
469
+ await this.pause(pauseOptions);
470
+ let snapshot = await this.#t.json({
402
471
  method: "POST",
403
472
  path: `/v1/sandboxes/${enc(this.id)}:snapshot`,
404
473
  body,
405
474
  wait: 10,
406
- ...pick({
407
- ...(idempotencyKey ? { idempotencyKey } : {}),
408
- ...(signal ? { signal } : {}),
409
- ...(timeoutMs ? { timeoutMs } : {}),
410
- }),
475
+ ...(idempotencyKey ? { idempotencyKey } : {}),
476
+ ...request(),
411
477
  });
478
+ // Prefer: wait is bounded on the server. A capture still in progress
479
+ // must keep its source paused, or the worker refuses it after we wake.
480
+ while (snapshot.state === "capturing") {
481
+ signal?.throwIfAborted();
482
+ if (performance.now() >= until)
483
+ throw new RuntimeError({
484
+ code: "snapshot_timeout",
485
+ status: 0,
486
+ message: "Snapshot capture did not finish within one minute.",
487
+ details: { snapshotId: snapshot.id },
488
+ });
489
+ await new Promise((resolve) => setTimeout(resolve, 200));
490
+ snapshot = await this.#t.json({
491
+ method: "GET",
492
+ path: `/v1/snapshots/${enc(snapshot.id)}`,
493
+ ...request(),
494
+ });
495
+ }
496
+ if (snapshot.state !== "ready")
497
+ throw new RuntimeError({
498
+ code: "snapshot_failed",
499
+ status: 409,
500
+ message: snapshot.error ?? `Snapshot capture ended in state ${snapshot.state}.`,
501
+ details: { snapshotId: snapshot.id },
502
+ });
503
+ if (body.mode === "disk" && snapshot.mode !== "disk")
504
+ throw new RuntimeError({
505
+ code: "snapshot_mode_mismatch",
506
+ status: 409,
507
+ message: "The server did not confirm a disk-only snapshot.",
508
+ details: { snapshotId: snapshot.id },
509
+ });
510
+ return snapshot;
412
511
  }
413
512
  finally {
414
513
  if (running)
@@ -436,13 +535,13 @@ export class Sandbox {
436
535
  return this.#lifecycle("restart", options);
437
536
  }
438
537
  async #lifecycle(verb, options, body = {}) {
439
- this.#info = await this.#t.json({
538
+ this.#keep(await this.#t.json({
440
539
  method: "POST",
441
540
  path: `/v1/sandboxes/${enc(this.id)}:${verb}`,
442
541
  body,
443
542
  wait: options.wait === false ? 0 : 60,
444
543
  ...pick(options),
445
- });
544
+ }));
446
545
  return this;
447
546
  }
448
547
  async [Symbol.asyncDispose]() {
@@ -481,11 +580,27 @@ class OutputCursor {
481
580
  droppedBytes: event.offset - this.cursor,
482
581
  resumeAt: event.offset,
483
582
  };
484
- this.cursor = Math.max(this.cursor, event.offset + utf8(event.data).byteLength);
583
+ this.cursor = Math.max(this.cursor, event.offset +
584
+ (event.base64 === undefined
585
+ ? utf8(event.data).byteLength
586
+ : processBytes(event.base64).length));
485
587
  }
486
588
  yield event;
487
589
  }
488
590
  }
591
+ function processBytes(encoded) {
592
+ try {
593
+ return atob(encoded);
594
+ }
595
+ catch (cause) {
596
+ throw new RuntimeError({
597
+ code: "invalid_process_output",
598
+ status: 502,
599
+ message: "The process returned invalid base64 output.",
600
+ cause,
601
+ });
602
+ }
603
+ }
489
604
  /** A stream cut off by the network rather than refused by Runtime: worth
490
605
  * reconnecting to from the cursor. */
491
606
  function cutOff(error) {
@@ -637,6 +752,7 @@ export class Process {
637
752
  sandboxId;
638
753
  info;
639
754
  #inputOffset;
755
+ #inputWrites = Promise.resolve();
640
756
  constructor(t, sandboxId, info) {
641
757
  this.t = t;
642
758
  this.sandboxId = sandboxId;
@@ -650,6 +766,34 @@ export class Process {
650
766
  output(options = {}) {
651
767
  return new Processes(this.t, this.sandboxId).follow(this.id, options);
652
768
  }
769
+ /** Lossless output for a process spawned with outputEncoding: "base64".
770
+ * Refuses text-only output rather than inventing bytes that were discarded. */
771
+ async *outputBytes(options = {}) {
772
+ if (this.info.outputEncoding !== "base64")
773
+ throw new RuntimeError({
774
+ code: "binary_output_unavailable",
775
+ status: 409,
776
+ message: "Spawn the process with outputEncoding: base64 to read lossless bytes.",
777
+ });
778
+ for await (const event of this.output(options)) {
779
+ if (event.type === "stdout" || event.type === "stderr") {
780
+ if (event.base64 === undefined)
781
+ throw new RuntimeError({
782
+ code: "binary_output_unavailable",
783
+ status: 502,
784
+ message: "The process returned text without its original bytes.",
785
+ });
786
+ const raw = processBytes(event.base64);
787
+ yield {
788
+ type: event.type,
789
+ offset: event.offset,
790
+ data: Uint8Array.from(raw, (c) => c.charCodeAt(0)),
791
+ };
792
+ }
793
+ else
794
+ yield event;
795
+ }
796
+ }
653
797
  /** Waits for the process to end and returns its result. */
654
798
  async wait(options = {}) {
655
799
  const out = { stdout: "", stderr: "" };
@@ -672,22 +816,37 @@ export class Process {
672
816
  };
673
817
  }
674
818
  /** 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);
819
+ write(data, options = {}) {
820
+ // Copy before queuing: callers may reuse their buffer while a previous
821
+ // write waits for the guest to drain its pipe.
822
+ const bytes = new Uint8Array(utf8(data));
823
+ const eof = options.eof === true;
824
+ const write = this.#inputWrites.then(async () => {
825
+ let sent = 0;
826
+ do {
827
+ const chunk = bytes.subarray(sent, sent + 1_048_576);
828
+ const reply = await this.t.json({
829
+ method: "POST",
830
+ path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(this.id)}:write`,
831
+ body: {
832
+ base64: base64(chunk),
833
+ offset: this.#inputOffset,
834
+ ...(eof && sent + chunk.length === bytes.length ? { eof: true } : {}),
835
+ },
836
+ });
837
+ const accepted = reply.offset - this.#inputOffset;
838
+ if (!Number.isSafeInteger(reply.offset) || accepted < 0 || accepted > chunk.length)
839
+ throw new ConnectionError({
840
+ message: "The process returned an invalid input offset.",
841
+ code: "connection_error",
842
+ status: 0,
843
+ });
844
+ sent += accepted;
845
+ this.#inputOffset = reply.offset;
846
+ } while (sent < bytes.length);
847
+ });
848
+ this.#inputWrites = write.catch(() => undefined);
849
+ return write;
691
850
  }
692
851
  async kill(signal = "SIGTERM") {
693
852
  await this.t.json({
@@ -943,44 +1102,64 @@ export class Files {
943
1102
  }
944
1103
  /** Directory entries; `depth` goes deeper, `glob` filters (e.g. "**\/*.py"). */
945
1104
  async list(path = "/workspace", options = {}) {
1105
+ const { signal, timeoutMs, idempotencyKey, ...query } = options;
946
1106
  return (await this.t.json({
947
1107
  method: "GET",
948
1108
  path: this.#path("/files/list"),
949
- query: { path, ...options },
1109
+ query: { path, ...query },
1110
+ signal,
1111
+ timeoutMs,
1112
+ idempotencyKey,
950
1113
  })).data;
951
1114
  }
952
1115
  async glob(pattern, root = "/workspace") {
953
1116
  return this.list(root, { glob: pattern });
954
1117
  }
955
- async stat(path) {
956
- return this.t.json({ method: "GET", path: this.#path("/files/stat"), query: { path } });
1118
+ async stat(path, options = {}) {
1119
+ return this.t.json({
1120
+ method: "GET",
1121
+ path: this.#path("/files/stat"),
1122
+ query: { path },
1123
+ ...options,
1124
+ });
957
1125
  }
958
- async exists(path) {
959
- return (await this.stat(path)).exists;
1126
+ async exists(path, options = {}) {
1127
+ return (await this.stat(path, options)).exists;
960
1128
  }
961
1129
  async mkdir(path, options = {}) {
1130
+ const { parents, ...request } = options;
962
1131
  await this.t.json({
963
1132
  method: "POST",
964
1133
  path: this.#path("/files:mkdir"),
965
- body: { path, ...options },
1134
+ body: { path, parents },
1135
+ ...request,
966
1136
  });
967
1137
  }
968
1138
  async remove(path, options = {}) {
1139
+ const { recursive, ...request } = options;
969
1140
  return (await this.t.json({
970
1141
  method: "POST",
971
1142
  path: this.#path("/files:remove"),
972
- body: { path, ...options },
1143
+ body: { path, recursive },
1144
+ ...request,
973
1145
  })).removed;
974
1146
  }
975
1147
  async rename(from, to, options = {}) {
1148
+ const { overwrite, ...request } = options;
976
1149
  await this.t.json({
977
1150
  method: "POST",
978
1151
  path: this.#path("/files:rename"),
979
- body: { from, to, ...options },
1152
+ body: {
1153
+ from,
1154
+ to,
1155
+ ...(overwrite === undefined ? {} : { overwrite }),
1156
+ },
1157
+ ...request,
980
1158
  });
981
1159
  }
982
1160
  /** Copies a local file or directory into the sandbox. A directory travels as
983
- * one gzipped tar and is unpacked in place. */
1161
+ * one gzipped tar to the API's folder routes, and the sandbox's own `tar`
1162
+ * unpacks it as it arrives, making the folder. */
984
1163
  async upload(localPath, remotePath) {
985
1164
  const { stat, readFile } = await import("node:fs/promises");
986
1165
  const info = await stat(localPath);
@@ -1000,20 +1179,39 @@ export class Files {
1000
1179
  }
1001
1180
  const { packDirectory } = await import("./tar.js");
1002
1181
  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);
1182
+ if (archive.length <= CHUNK) {
1183
+ await this.t.bytes({
1184
+ method: "PUT",
1185
+ path: this.#path("/files/archive"),
1186
+ query: { path: remotePath },
1187
+ bytes: archive,
1188
+ });
1189
+ return;
1190
+ }
1191
+ // Larger: parts in order, each unpacked as it arrives; a part resent
1192
+ // after a lost answer is not written twice.
1193
+ const begin = await this.t.json({
1194
+ method: "POST",
1195
+ path: this.#path("/files/archive/uploads"),
1196
+ body: { path: remotePath, gzip: true },
1197
+ });
1198
+ const upload = (suffix = "") => this.#path(`/files/archive/uploads/${enc(begin.uploadId)}${suffix}`);
1199
+ try {
1200
+ for (let offset = 0; offset < archive.length; offset += begin.chunkBytes)
1201
+ await this.t.bytes({
1202
+ method: "PUT",
1203
+ path: upload(),
1204
+ query: { offset },
1205
+ bytes: archive.subarray(offset, offset + begin.chunkBytes),
1206
+ });
1207
+ await this.t.json({ method: "POST", path: upload(":commit"), body: {} });
1208
+ }
1209
+ catch (error) {
1210
+ await this.t
1211
+ .json({ method: "POST", path: upload(":abort"), body: {} })
1212
+ .catch(() => undefined);
1213
+ throw error;
1214
+ }
1017
1215
  }
1018
1216
  /** Streams a file to disk through a partial file beside the target, renamed
1019
1217
  * into place only once every byte has arrived: a failed copy never leaves
@@ -1048,7 +1246,8 @@ export class Files {
1048
1246
  }
1049
1247
  }
1050
1248
  /** Copies a file or directory out of the sandbox. A file streams to disk,
1051
- * any size, checked against its length. */
1249
+ * any size, checked against its length; a directory comes as one tar from
1250
+ * the API's folder route. */
1052
1251
  async download(remotePath, localPath) {
1053
1252
  const fs = await import("node:fs/promises");
1054
1253
  const paths = await import("node:path");
@@ -1064,18 +1263,16 @@ export class Files {
1064
1263
  await this.#streamTo(remotePath, localPath);
1065
1264
  return;
1066
1265
  }
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);
1072
- try {
1073
- const { unpackArchive } = await import("./tar.js");
1074
- await unpackArchive(await this.read(staging), localPath);
1075
- }
1076
- finally {
1077
- await this.remove(staging).catch(() => undefined);
1078
- }
1266
+ // The sandbox's own `tar` packs it as it streams, and it is unpacked as
1267
+ // it arrives; one cut short is refused and nothing is put in place.
1268
+ const archive = await this.t.fileStream({
1269
+ method: "GET",
1270
+ path: this.#path("/files/archive"),
1271
+ query: { path: remotePath, gzip: true },
1272
+ accept: "application/gzip",
1273
+ });
1274
+ const { unpackStream } = await import("./tar.js");
1275
+ await unpackStream(archive, localPath);
1079
1276
  }
1080
1277
  }
1081
1278
  export function sandboxPage(t, body, query) {
@@ -6,6 +6,8 @@ import type { RequestOptions, Transport } from "./transport.js";
6
6
  export type Snapshot = {
7
7
  id: string;
8
8
  kind: "snapshot";
9
+ /** Absent on older servers means memory. */
10
+ mode?: "memory" | "disk";
9
11
  status: string;
10
12
  state: "capturing" | "ready" | "failed" | "deleting" | "deleted";
11
13
  name: string | null;
@@ -36,6 +38,8 @@ export type Snapshot = {
36
38
  [key: string]: unknown;
37
39
  };
38
40
  export type SnapshotOptions = {
41
+ /** Save files for a fresh boot, or the whole machine (default). */
42
+ mode?: "memory" | "disk";
39
43
  name?: string;
40
44
  labels?: Record<string, string>;
41
45
  retentionDays?: number;
@@ -54,7 +58,13 @@ export declare class Snapshots {
54
58
  name?: string;
55
59
  state?: "capturing" | "ready" | "failed" | "deleting";
56
60
  limit?: number;
57
- }): Promise<Page<Snapshot>>;
61
+ }, options?: RequestOptions): Promise<Page<Snapshot>>;
62
+ /** Replace supplied labels or name; omitted fields are preserved. Null clears the name. */
63
+ update(id: string, input: {
64
+ name?: string | null;
65
+ labels?: Record<string, string>;
66
+ ifLabels?: Record<string, string>;
67
+ }, options?: RequestOptions): Promise<Snapshot>;
58
68
  delete(id: string, options?: RequestOptions): Promise<void>;
59
69
  extend(id: string, retentionDays: number, options?: RequestOptions): Promise<Snapshot>;
60
70
  }
package/dist/snapshots.js CHANGED
@@ -19,7 +19,7 @@ export class Snapshots {
19
19
  get(id, options = {}) {
20
20
  return this.t.json({ method: "GET", path: `/v1/snapshots/${enc(id)}`, ...options });
21
21
  }
22
- async list(filter = {}) {
22
+ async list(filter = {}, options = {}) {
23
23
  const query = {
24
24
  sandboxId: filter.sandboxId,
25
25
  name: filter.name,
@@ -31,11 +31,21 @@ export class Snapshots {
31
31
  method: "GET",
32
32
  path: "/v1/snapshots",
33
33
  query: { ...query, cursor },
34
+ ...options,
34
35
  });
35
36
  return new Page(body.data, body.nextCursor, (next) => fetchPage(next));
36
37
  };
37
38
  return fetchPage();
38
39
  }
40
+ /** Replace supplied labels or name; omitted fields are preserved. Null clears the name. */
41
+ update(id, input, options = {}) {
42
+ return this.t.json({
43
+ method: "POST",
44
+ path: `/v1/snapshots/${enc(id)}:update`,
45
+ body: input,
46
+ ...options,
47
+ });
48
+ }
39
49
  async delete(id, options = {}) {
40
50
  await this.t.json({
41
51
  method: "POST",