withruntime 0.9.0 → 0.11.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 (124) hide show
  1. package/BLAXEL.md +4 -4
  2. package/DAYTONA.md +52 -40
  3. package/E2B.md +65 -62
  4. package/README.md +15 -7
  5. package/README.src.md +15 -7
  6. package/VERCEL.md +34 -22
  7. package/compatibility-lock.json +16 -16
  8. package/dist/blaxel/errors.js +1 -1
  9. package/dist/blaxel/lookup.d.ts +3 -2
  10. package/dist/blaxel/lookup.js +3 -2
  11. package/dist/blaxel/pagination.d.ts +2 -2
  12. package/dist/blaxel/pagination.js +45 -17
  13. package/dist/blaxel/preview.d.ts +2 -1
  14. package/dist/blaxel/preview.js +15 -13
  15. package/dist/blaxel/process.js +56 -10
  16. package/dist/blaxel/sandbox.d.ts +2 -0
  17. package/dist/blaxel/sandbox.js +2 -0
  18. package/dist/blaxel/snapshot.js +3 -5
  19. package/dist/cli.d.ts +2 -0
  20. package/dist/cli.js +262 -56
  21. package/dist/client.d.ts +41 -3
  22. package/dist/client.js +67 -11
  23. package/dist/cloudflare/identity.d.ts +7 -0
  24. package/dist/cloudflare/identity.js +13 -0
  25. package/dist/cloudflare/index.d.ts +9 -0
  26. package/dist/cloudflare/index.js +47 -9
  27. package/dist/cloudflare/worker.d.ts +4 -0
  28. package/dist/cloudflare/worker.js +166 -16
  29. package/dist/codesandbox/guest-service.js +57 -20
  30. package/dist/codesandbox/index.js +42 -8
  31. package/dist/compat/core.d.ts +9 -8
  32. package/dist/compat/core.js +60 -21
  33. package/dist/compat/files.js +22 -4
  34. package/dist/compat/lease.d.ts +36 -0
  35. package/dist/compat/lease.js +87 -0
  36. package/dist/credentials.d.ts +6 -1
  37. package/dist/credentials.js +107 -38
  38. package/dist/daytona/computer-use.d.ts +162 -0
  39. package/dist/daytona/computer-use.js +324 -0
  40. package/dist/daytona/context.d.ts +10 -0
  41. package/dist/daytona/context.js +24 -0
  42. package/dist/daytona/daytona.d.ts +20 -0
  43. package/dist/daytona/daytona.js +134 -20
  44. package/dist/daytona/filesystem.d.ts +17 -4
  45. package/dist/daytona/filesystem.js +129 -38
  46. package/dist/daytona/index.d.ts +3 -27
  47. package/dist/daytona/index.js +2 -7
  48. package/dist/daytona/process.d.ts +63 -6
  49. package/dist/daytona/process.js +224 -23
  50. package/dist/daytona/sandbox.d.ts +20 -6
  51. package/dist/daytona/sandbox.js +55 -32
  52. package/dist/e2b/client.d.ts +5 -0
  53. package/dist/e2b/client.js +6 -2
  54. package/dist/e2b/commands.d.ts +43 -12
  55. package/dist/e2b/commands.js +217 -64
  56. package/dist/e2b/errors.d.ts +7 -0
  57. package/dist/e2b/errors.js +20 -0
  58. package/dist/e2b/filesystem.d.ts +2 -1
  59. package/dist/e2b/filesystem.js +43 -6
  60. package/dist/e2b/index.d.ts +3 -2
  61. package/dist/e2b/index.js +2 -1
  62. package/dist/e2b/pty.d.ts +24 -0
  63. package/dist/e2b/pty.js +83 -0
  64. package/dist/e2b/sandbox.d.ts +17 -6
  65. package/dist/e2b/sandbox.js +239 -43
  66. package/dist/e2b/users.d.ts +30 -0
  67. package/dist/e2b/users.js +185 -0
  68. package/dist/file-source.d.ts +16 -0
  69. package/dist/file-source.js +162 -0
  70. package/dist/freestyle/index.d.ts +9 -1
  71. package/dist/freestyle/index.js +38 -5
  72. package/dist/index.d.ts +3 -1
  73. package/dist/login.js +16 -10
  74. package/dist/mcp.d.ts +2 -1
  75. package/dist/mcp.js +6 -1
  76. package/dist/modal/index.js +2 -1
  77. package/dist/network-cli.js +139 -43
  78. package/dist/openai-agents/index.js +9 -45
  79. package/dist/page.d.ts +1 -1
  80. package/dist/page.js +2 -2
  81. package/dist/products/browser.d.ts +60 -0
  82. package/dist/products/browser.js +37 -0
  83. package/dist/products/desktop.d.ts +2 -2
  84. package/dist/products/desktop.js +2 -2
  85. package/dist/products/images.d.ts +3 -1
  86. package/dist/products/images.js +14 -2
  87. package/dist/products/index.d.ts +14 -0
  88. package/dist/products/index.js +12 -0
  89. package/dist/products/network-products.d.ts +7 -0
  90. package/dist/products/network-products.js +3 -0
  91. package/dist/products/observability.d.ts +44 -0
  92. package/dist/products/observability.js +6 -0
  93. package/dist/products/previews.d.ts +8 -2
  94. package/dist/products/previews.js +11 -3
  95. package/dist/products/private-network.d.ts +22 -0
  96. package/dist/products/private-network.js +10 -0
  97. package/dist/products/volumes.d.ts +42 -0
  98. package/dist/products/volumes.js +36 -0
  99. package/dist/products/watch.js +61 -8
  100. package/dist/proxy.js +3 -1
  101. package/dist/runloop/index.d.ts +13 -27
  102. package/dist/runloop/index.js +149 -52
  103. package/dist/sandbox.d.ts +59 -8
  104. package/dist/sandbox.js +479 -68
  105. package/dist/snapshots.d.ts +9 -4
  106. package/dist/sprites/index.js +69 -33
  107. package/dist/tar.d.ts +3 -1
  108. package/dist/tar.js +140 -14
  109. package/dist/transport.d.ts +4 -7
  110. package/dist/transport.js +205 -122
  111. package/dist/types.d.ts +5 -2
  112. package/dist/vercel/command.d.ts +1 -1
  113. package/dist/vercel/command.js +3 -2
  114. package/dist/vercel/errors.d.ts +1 -1
  115. package/dist/vercel/errors.js +7 -1
  116. package/dist/vercel/index.d.ts +1 -7
  117. package/dist/vercel/index.js +1 -3
  118. package/dist/vercel/sandbox-user.d.ts +70 -0
  119. package/dist/vercel/sandbox-user.js +206 -0
  120. package/dist/vercel/sandbox.d.ts +55 -20
  121. package/dist/vercel/sandbox.js +353 -99
  122. package/dist/vercel/snapshot.d.ts +3 -2
  123. package/dist/vercel/snapshot.js +9 -5
  124. package/package.json +2 -1
package/dist/sandbox.js CHANGED
@@ -13,6 +13,49 @@ const CHUNK = 1_048_576;
13
13
  * connections at once. */
14
14
  const PARALLEL = 8;
15
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
+ }
16
59
  /** DELETE /v1/sandboxes/{id}, for `sandbox.delete()` and
17
60
  * `runtime.sandboxes.delete(id)`. */
18
61
  export function deleteSandbox(t, id, options = {}) {
@@ -268,7 +311,7 @@ export class Sandbox {
268
311
  * once. `stdin: "pipe"` keeps its input open for write(); `pty` gives it a
269
312
  * terminal. */
270
313
  async spawn(command, options = {}) {
271
- const { stdin, pty, outputEncoding, ...rest } = options;
314
+ const { stdin, pty, outputEncoding, request, ...rest } = options;
272
315
  const info = await this.#t.json({
273
316
  method: "POST",
274
317
  path: `/v1/sandboxes/${enc(this.id)}/processes`,
@@ -279,6 +322,7 @@ export class Sandbox {
279
322
  ...(outputEncoding ? { outputEncoding } : {}),
280
323
  },
281
324
  ...pick(rest),
325
+ ...request,
282
326
  });
283
327
  return new Process(this.#t, this.id, info);
284
328
  }
@@ -383,6 +427,27 @@ export class Sandbox {
383
427
  }));
384
428
  return this;
385
429
  }
430
+ /** Gives it more or fewer vCPUs or more or less memory by a restart: its id,
431
+ * whole disk, volumes, environment, name and previews stay, and its
432
+ * programs stop, which `restart: true` says you know (snapshot it first to
433
+ * keep its memory too). A running sandbox is paused, or stopped if it is
434
+ * persistent, and comes back running at the new size on the same server; a
435
+ * paused or stopped one is started. Memory bills on the new size from then.
436
+ * Refused, with nothing changed, when the server has no room
437
+ * (`no_capacity`), above your quota or the trial's 2 vCPU and 4 GiB, or
438
+ * while a snapshot or fork of it is being taken. */
439
+ async resize(size, options) {
440
+ const { restart, ...rest } = options;
441
+ this.#keep(await this.#t.json({
442
+ method: "POST",
443
+ path: `/v1/sandboxes/${enc(this.id)}:resize`,
444
+ body: { ...size, restart },
445
+ // A pause or stop and a cold boot.
446
+ wait: 120,
447
+ ...pick(rest),
448
+ }));
449
+ return this;
450
+ }
386
451
  /** Keeps a running sandbox's lease ahead of now, in the background, until
387
452
  * stop() or the returned function ends it: every `everySeconds` (60) it
388
453
  * extends the lease so that `marginSeconds` (600) remain, never more than
@@ -464,6 +529,9 @@ export class Sandbox {
464
529
  const running = this.state === "running";
465
530
  // Check before entering cleanup: an abort during refresh must not pause or wake.
466
531
  const pauseOptions = running ? request() : undefined;
532
+ let captured;
533
+ let primary;
534
+ let failed = false;
467
535
  try {
468
536
  if (running)
469
537
  await this.pause(pauseOptions);
@@ -475,6 +543,7 @@ export class Sandbox {
475
543
  ...(idempotencyKey ? { idempotencyKey } : {}),
476
544
  ...request(),
477
545
  });
546
+ captured = snapshot;
478
547
  // Prefer: wait is bounded on the server. A capture still in progress
479
548
  // must keep its source paused, or the worker refuses it after we wake.
480
549
  while (snapshot.state === "capturing") {
@@ -507,12 +576,39 @@ export class Sandbox {
507
576
  message: "The server did not confirm a disk-only snapshot.",
508
577
  details: { snapshotId: snapshot.id },
509
578
  });
510
- return snapshot;
579
+ captured = snapshot;
511
580
  }
512
- finally {
513
- if (running)
581
+ catch (error) {
582
+ failed = true;
583
+ primary = error;
584
+ }
585
+ if (running) {
586
+ try {
514
587
  await this.wake();
588
+ }
589
+ catch (wakeError) {
590
+ const recovery = {
591
+ ...(captured ? { snapshotId: captured.id } : {}),
592
+ sourceSandboxId: this.id,
593
+ sourceWakeError: {
594
+ ...(wakeError instanceof RuntimeError ? { code: wakeError.code } : {}),
595
+ message: wakeError instanceof Error ? wakeError.message : String(wakeError),
596
+ },
597
+ };
598
+ const reported = failed ? primary : wakeError;
599
+ if (reported instanceof Error && Object.isExtensible(reported)) {
600
+ if (reported instanceof RuntimeError)
601
+ Object.assign(reported, { details: { ...reported.details, ...recovery } });
602
+ else
603
+ Object.assign(reported, recovery);
604
+ }
605
+ if (!failed)
606
+ throw wakeError;
607
+ }
515
608
  }
609
+ if (failed)
610
+ throw primary;
611
+ return captured;
516
612
  }
517
613
  async fork(options = {}) {
518
614
  const { idempotencyKey, signal, timeoutMs, ...body } = options;
@@ -690,16 +786,18 @@ export class Processes {
690
786
  this.t = t;
691
787
  this.sandboxId = sandboxId;
692
788
  }
693
- async list() {
789
+ async list(options = {}) {
694
790
  return (await this.t.json({
695
791
  method: "GET",
696
792
  path: `/v1/sandboxes/${enc(this.sandboxId)}/processes`,
793
+ ...options,
697
794
  })).data;
698
795
  }
699
- async get(processId) {
796
+ async get(processId, options = {}) {
700
797
  const info = await this.t.json({
701
798
  method: "GET",
702
799
  path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(processId)}`,
800
+ ...options,
703
801
  });
704
802
  return new Process(this.t, this.sandboxId, info);
705
803
  }
@@ -762,6 +860,26 @@ export class Process {
762
860
  get id() {
763
861
  return this.info.id;
764
862
  }
863
+ /** The command line as the sandbox reports it. */
864
+ get command() {
865
+ return this.info.command;
866
+ }
867
+ /** As of the last answer: spawn, `wait()` or `refresh()`. */
868
+ get state() {
869
+ return this.info.state;
870
+ }
871
+ /** null while it runs, and when it was killed or timed out. */
872
+ get exitCode() {
873
+ return this.info.exitCode;
874
+ }
875
+ toJSON() {
876
+ return this.info;
877
+ }
878
+ /** console.log shows what the process is, not the client inside it. */
879
+ [Symbol.for("nodejs.util.inspect.custom")](_depth, options, show) {
880
+ const { id, sandboxId, command, state, exitCode } = this;
881
+ return `Process ${show({ id, sandboxId, command, state, exitCode }, options)}`;
882
+ }
765
883
  /** Every output event from the start (or `cursor`) until exit. */
766
884
  output(options = {}) {
767
885
  return new Processes(this.t, this.sandboxId).follow(this.id, options);
@@ -807,6 +925,12 @@ export class Process {
807
925
  if (event.type === "exit")
808
926
  exit = event;
809
927
  }
928
+ if (exit)
929
+ this.info = {
930
+ ...this.info,
931
+ state: exit.state,
932
+ exitCode: exit.exitCode,
933
+ };
810
934
  return {
811
935
  exitCode: exit?.exitCode ?? null,
812
936
  ...out,
@@ -815,16 +939,27 @@ export class Process {
815
939
  processId: this.id,
816
940
  };
817
941
  }
818
- /** Sends input. Offsets are tracked for you, so a retried write is never typed twice. */
942
+ /** Sends input. Offsets protect transport retries; signal and timeoutMs cover
943
+ * the whole logical write, including its queue and backpressure. */
819
944
  write(data, options = {}) {
945
+ if (options.idempotencyKey !== undefined)
946
+ return Promise.reject(new RuntimeError({
947
+ message: "Process input writes do not accept caller idempotency keys; offsets protect transport retries.",
948
+ code: "unsupported_process_write_option",
949
+ status: 400,
950
+ }));
820
951
  // Copy before queuing: callers may reuse their buffer while a previous
821
952
  // write waits for the guest to drain its pipe.
822
953
  const bytes = new Uint8Array(utf8(data));
823
954
  const eof = options.eof === true;
955
+ const { eof: _eof, ...requestOptions } = options;
956
+ const scope = requestScope(requestOptions);
824
957
  const write = this.#inputWrites.then(async () => {
825
958
  let sent = 0;
959
+ let chunkSize = CHUNK;
826
960
  do {
827
- const chunk = bytes.subarray(sent, sent + 1_048_576);
961
+ scope.check();
962
+ const chunk = bytes.subarray(sent, sent + chunkSize);
828
963
  const reply = await this.t.json({
829
964
  method: "POST",
830
965
  path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(this.id)}:write`,
@@ -833,6 +968,7 @@ export class Process {
833
968
  offset: this.#inputOffset,
834
969
  ...(eof && sent + chunk.length === bytes.length ? { eof: true } : {}),
835
970
  },
971
+ ...scope.request,
836
972
  });
837
973
  const accepted = reply.offset - this.#inputOffset;
838
974
  if (!Number.isSafeInteger(reply.offset) || accepted < 0 || accepted > chunk.length)
@@ -843,23 +979,36 @@ export class Process {
843
979
  });
844
980
  sent += accepted;
845
981
  this.#inputOffset = reply.offset;
982
+ // A slow pipe may take only part of a chunk. Keep later uploads within
983
+ // that observed capacity instead of resending almost a MiB each time.
984
+ // Zero is legitimate backpressure: retry a bounded probe at the same
985
+ // offset without discarding bytes or inventing progress.
986
+ if (accepted < chunk.length) {
987
+ chunkSize = accepted > 0 ? accepted : Math.min(chunkSize, 65_536);
988
+ // A full pipe must yield even if a proxy answers immediately; don't
989
+ // spin requests while the guest has accepted none of this input.
990
+ if (accepted === 0)
991
+ await scope.wait(new Promise((resolve) => setTimeout(resolve, 100)));
992
+ }
846
993
  } while (sent < bytes.length);
847
994
  });
848
995
  this.#inputWrites = write.catch(() => undefined);
849
- return write;
996
+ return scope.wait(write);
850
997
  }
851
- async kill(signal = "SIGTERM") {
998
+ async kill(signal = "SIGTERM", options = {}) {
852
999
  await this.t.json({
853
1000
  method: "POST",
854
1001
  path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(this.id)}:signal`,
855
1002
  body: { signal },
1003
+ ...options,
856
1004
  });
857
1005
  }
858
- async resize(cols, rows) {
1006
+ async resize(cols, rows, options = {}) {
859
1007
  await this.t.json({
860
1008
  method: "POST",
861
1009
  path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(this.id)}:resize`,
862
1010
  body: { cols, rows },
1011
+ ...options,
863
1012
  });
864
1013
  }
865
1014
  async refresh() {
@@ -993,6 +1142,8 @@ export class Files {
993
1142
  * sets its permissions (0o755 for a program); 0o644 when left out. */
994
1143
  async write(path, data, writeOptions = {}) {
995
1144
  const { mode, ...options } = writeOptions;
1145
+ const scope = requestScope(options);
1146
+ scope.check();
996
1147
  const octal = mode === undefined ? undefined : (mode & 0o777).toString(8).padStart(3, "0");
997
1148
  const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
998
1149
  if (bytes.length <= CHUNK) {
@@ -1003,27 +1154,100 @@ export class Files {
1003
1154
  path: this.#path("/files/content"),
1004
1155
  query: { path, ...(octal ? { mode: octal } : {}) },
1005
1156
  bytes,
1006
- ...options,
1157
+ ...scope.request,
1007
1158
  });
1008
1159
  return { path, size: bytes.length };
1009
1160
  }
1010
- const sha256 = await sha256Hex(bytes);
1161
+ const sha256 = await scope.wait(sha256Hex(bytes));
1162
+ return this.#writeUpload(path, {
1163
+ size: bytes.length,
1164
+ sha256,
1165
+ chunkAt: (offset, length) => bytes.subarray(offset, offset + length),
1166
+ }, octal, scope);
1167
+ }
1168
+ /** Uploads byte streams with bounded memory on Node/Bun. Unknown-length
1169
+ * inputs stage once in a private local temporary file; known local files
1170
+ * and Blobs avoid that extra disk copy. Paths are local file paths. */
1171
+ async writeStream(path, input, writeOptions = {}) {
1172
+ const { mode, onProgress, ...options } = writeOptions;
1173
+ if (input instanceof Uint8Array) {
1174
+ const result = await this.write(path, input, {
1175
+ ...options,
1176
+ ...(mode === undefined ? {} : { mode }),
1177
+ });
1178
+ onProgress?.({ bytesSent: result.size });
1179
+ return result;
1180
+ }
1181
+ const scope = requestScope(options);
1182
+ scope.check();
1183
+ if (!globalThis.process
1184
+ ?.versions?.node)
1185
+ throw new RuntimeError({
1186
+ message: "Streamed file uploads require Node or Bun for incremental hashing and private local staging. Use files.write with byte data in a browser.",
1187
+ code: "unsupported_runtime",
1188
+ status: 0,
1189
+ });
1190
+ const { prepareFileSource } = await import("./file-source.js");
1191
+ const source = await prepareFileSource(input, { ...scope, signal: scope.request.signal });
1192
+ let written;
1193
+ try {
1194
+ if (source.size <= CHUNK) {
1195
+ written = await this.write(path, await scope.wait(source.chunkAt(0, source.size)), {
1196
+ ...scope.request,
1197
+ ...(mode === undefined ? {} : { mode }),
1198
+ });
1199
+ onProgress?.({ bytesSent: written.size });
1200
+ }
1201
+ else {
1202
+ const octal = mode === undefined ? undefined : (mode & 0o777).toString(8).padStart(3, "0");
1203
+ written = await this.#writeUpload(path, source, octal, scope, onProgress);
1204
+ }
1205
+ }
1206
+ catch (primary) {
1207
+ try {
1208
+ await source.close();
1209
+ }
1210
+ catch (cleanupError) {
1211
+ if (primary === undefined)
1212
+ throw cleanupError;
1213
+ if (primary instanceof Error && Object.isExtensible(primary)) {
1214
+ try {
1215
+ Object.defineProperty(primary, "cleanupError", {
1216
+ value: cleanupError,
1217
+ configurable: true,
1218
+ });
1219
+ }
1220
+ catch {
1221
+ /* Keep the original upload failure. */
1222
+ }
1223
+ }
1224
+ }
1225
+ throw primary;
1226
+ }
1227
+ // A failed close after a good upload is the call's failure.
1228
+ await source.close();
1229
+ return written;
1230
+ }
1231
+ /** One implementation of atomic, checked chunk uploads for bytes and streams. */
1232
+ async #writeUpload(path, source, octal, scope, onProgress) {
1233
+ const sha256 = source.sha256;
1011
1234
  // A guest journals mutations across operations, not per URL. One logical
1012
1235
  // write therefore owns separate stable keys for each mutation phase.
1013
- const uploadKey = options.idempotencyKey ?? crypto.randomUUID();
1236
+ const uploadKey = scope.request.idempotencyKey ?? crypto.randomUUID();
1014
1237
  const phaseKeys = new Map();
1015
1238
  for (const phase of ["begin", "legacy-begin", "commit", "chmod", "abort"])
1016
1239
  phaseKeys.set(phase, await sha256Hex(JSON.stringify(["files.write", uploadKey, phase])));
1017
1240
  const phaseOptions = (phase) => ({
1018
- ...options,
1241
+ ...scope.request,
1019
1242
  idempotencyKey: phaseKeys.get(phase),
1020
1243
  });
1021
1244
  let begin;
1245
+ scope.check();
1022
1246
  try {
1023
1247
  begin = await this.t.json({
1024
1248
  method: "POST",
1025
1249
  path: this.#path("/uploads"),
1026
- body: { path, size: bytes.length, sha256, ...(octal ? { mode: octal } : {}) },
1250
+ body: { path, size: source.size, sha256, ...(octal ? { mode: octal } : {}) },
1027
1251
  ...phaseOptions("begin"),
1028
1252
  });
1029
1253
  }
@@ -1032,73 +1256,158 @@ export class Files {
1032
1256
  throw error;
1033
1257
  // The API aborted the unsupported transfer before accepting chunks.
1034
1258
  // A different body needs a different key; retain deterministic retries.
1259
+ scope.check();
1035
1260
  begin = await this.t.json({
1036
1261
  method: "POST",
1037
1262
  path: this.#path("/uploads"),
1038
- body: { path, size: bytes.length, sha256 },
1263
+ body: { path, size: source.size, sha256 },
1039
1264
  ...phaseOptions("legacy-begin"),
1040
1265
  });
1041
1266
  }
1042
- const commit = () => this.t.json({
1043
- method: "POST",
1044
- path: this.#path(`/uploads/${enc(begin.uploadId)}:commit`),
1045
- body: {},
1046
- ...phaseOptions("commit"),
1047
- });
1267
+ // A malformed response does not prove ownership of any transfer to abort.
1268
+ if (typeof begin.uploadId !== "string" || begin.uploadId.length === 0)
1269
+ throw new ConnectionError({
1270
+ message: "The file upload returned no transfer identifier.",
1271
+ code: "connection_error",
1272
+ status: 0,
1273
+ });
1274
+ const commit = () => {
1275
+ scope.check();
1276
+ return this.t.json({
1277
+ method: "POST",
1278
+ path: this.#path(`/uploads/${enc(begin.uploadId)}:commit`),
1279
+ body: {},
1280
+ ...phaseOptions("commit"),
1281
+ });
1282
+ };
1048
1283
  let committed = false;
1049
- if (begin.replayed) {
1050
- // A completed commit removed the transfer. Ask its journal before
1051
- // resending chunks; an incomplete transfer alone is safe to continue.
1284
+ let nextOffset = 0;
1285
+ const stopChunks = new AbortController();
1286
+ const chunkSignal = scope.request.signal
1287
+ ? AbortSignal.any([scope.request.signal, stopChunks.signal])
1288
+ : stopChunks.signal;
1289
+ const chunkScope = requestScope({ ...scope.request, signal: chunkSignal });
1290
+ let bytesSent = 0;
1291
+ let failed = false;
1292
+ let primaryFailure;
1293
+ const worker = async () => {
1052
1294
  try {
1053
- await commit();
1054
- committed = true;
1295
+ while (nextOffset < source.size) {
1296
+ scope.check();
1297
+ if (stopChunks.signal.aborted)
1298
+ return;
1299
+ const offset = nextOffset;
1300
+ nextOffset += begin.chunkBytes;
1301
+ const offered = source.chunkAt(offset, Math.min(begin.chunkBytes, source.size - offset));
1302
+ const chunk = offered instanceof Promise ? await chunkScope.wait(offered) : offered;
1303
+ chunkScope.check();
1304
+ if (chunk.length !== Math.min(begin.chunkBytes, source.size - offset))
1305
+ throw new ConnectionError({
1306
+ message: "The local upload source returned an incomplete chunk.",
1307
+ code: "source_changed",
1308
+ status: 0,
1309
+ });
1310
+ const receipt = await this.t.json({
1311
+ method: "PUT",
1312
+ path: this.#path(`/uploads/${enc(begin.uploadId)}`),
1313
+ query: { offset },
1314
+ bytes: chunk,
1315
+ ...scope.request,
1316
+ signal: chunkSignal,
1317
+ // Offsets and byte hashes make chunk replay safe without mutation keys.
1318
+ idempotencyKey: undefined,
1319
+ });
1320
+ // Parallel receipts report cumulative accepted bytes, not this offset.
1321
+ if (!Number.isSafeInteger(receipt.received) ||
1322
+ receipt.received < chunk.length ||
1323
+ receipt.received > source.size)
1324
+ throw new ConnectionError({
1325
+ message: "The file upload returned invalid byte progress.",
1326
+ code: "connection_error",
1327
+ status: 0,
1328
+ });
1329
+ bytesSent += chunk.length;
1330
+ onProgress?.({ bytesSent });
1331
+ }
1055
1332
  }
1056
1333
  catch (error) {
1057
- if (!(error instanceof RuntimeError) || error.code !== "upload_incomplete")
1058
- throw error;
1059
- }
1060
- }
1061
- const offsets = [];
1062
- for (let offset = 0; offset < bytes.length; offset += begin.chunkBytes)
1063
- offsets.push(offset);
1064
- let next = 0;
1065
- const worker = async () => {
1066
- while (next < offsets.length) {
1067
- const offset = offsets[next++];
1068
- await this.t.bytes({
1069
- method: "PUT",
1070
- path: this.#path(`/uploads/${enc(begin.uploadId)}`),
1071
- query: { offset },
1072
- bytes: bytes.subarray(offset, offset + begin.chunkBytes),
1073
- });
1334
+ if (!failed) {
1335
+ failed = true;
1336
+ primaryFailure = error;
1337
+ stopChunks.abort(error);
1338
+ }
1339
+ throw error;
1074
1340
  }
1075
1341
  };
1076
1342
  try {
1343
+ if (!Number.isSafeInteger(begin.chunkBytes) ||
1344
+ begin.chunkBytes <= 0 ||
1345
+ begin.chunkBytes > CHUNK)
1346
+ throw new ConnectionError({
1347
+ message: "The file upload returned an invalid chunk size.",
1348
+ code: "connection_error",
1349
+ status: 0,
1350
+ });
1351
+ if (begin.replayed) {
1352
+ // A completed commit removed the transfer. Ask its journal before
1353
+ // resending chunks; an incomplete transfer alone is safe to continue.
1354
+ try {
1355
+ await commit();
1356
+ committed = true;
1357
+ onProgress?.({ bytesSent: source.size });
1358
+ }
1359
+ catch (error) {
1360
+ if (!(error instanceof RuntimeError) || error.code !== "upload_incomplete")
1361
+ throw error;
1362
+ }
1363
+ }
1077
1364
  if (!committed) {
1078
- await Promise.all(Array.from({ length: Math.min(PARALLEL, offsets.length) }, worker));
1365
+ const workers = Array.from({ length: Math.min(PARALLEL, Math.ceil(source.size / begin.chunkBytes)) }, worker);
1366
+ // Drain every owned in-flight chunk before aborting its transfer.
1367
+ // Promise.all's early rejection could otherwise leave writes racing cleanup.
1368
+ await Promise.allSettled(workers);
1369
+ if (failed)
1370
+ throw primaryFailure;
1079
1371
  await commit();
1080
1372
  }
1081
1373
  // An older sandbox image ignores the mode in the upload; set it after.
1082
- if (octal && begin.mode !== octal)
1374
+ if (octal && begin.mode !== octal) {
1375
+ scope.check();
1083
1376
  await this.t.json({
1084
1377
  method: "POST",
1085
1378
  path: this.#path("/files:chmod"),
1086
1379
  body: { path, mode: octal },
1087
1380
  ...phaseOptions("chmod"),
1088
1381
  });
1382
+ }
1089
1383
  }
1090
1384
  catch (error) {
1091
- await this.t
1092
- .json({
1093
- method: "POST",
1094
- path: this.#path(`/uploads/${enc(begin.uploadId)}:abort`),
1095
- body: {},
1096
- ...phaseOptions("abort"),
1097
- })
1098
- .catch(() => undefined);
1385
+ try {
1386
+ await this.t.json({
1387
+ method: "POST",
1388
+ path: this.#path(`/uploads/${enc(begin.uploadId)}:abort`),
1389
+ body: {},
1390
+ idempotencyKey: phaseKeys.get("abort"),
1391
+ timeoutMs: 5000,
1392
+ });
1393
+ }
1394
+ catch (cleanupError) {
1395
+ // Keep the original failure; retain cleanup evidence when possible.
1396
+ if (error instanceof Error && Object.isExtensible(error)) {
1397
+ try {
1398
+ Object.defineProperty(error, error.cause === undefined ? "cause" : "cleanupError", {
1399
+ value: cleanupError,
1400
+ configurable: true,
1401
+ });
1402
+ }
1403
+ catch {
1404
+ /* A frozen property cannot replace the primary failure. */
1405
+ }
1406
+ }
1407
+ }
1099
1408
  throw error;
1100
1409
  }
1101
- return { path, size: bytes.length };
1410
+ return { path, size: source.size };
1102
1411
  }
1103
1412
  /** Directory entries; `depth` goes deeper, `glob` filters (e.g. "**\/*.py"). */
1104
1413
  async list(path = "/workspace", options = {}) {
@@ -1157,6 +1466,80 @@ export class Files {
1157
1466
  ...request,
1158
1467
  });
1159
1468
  }
1469
+ /** Packs a directory as archive bytes; `gzip: false` returns plain tar.
1470
+ * Exclusions are relative paths, not globs. `user: root` selects guest root. */
1471
+ async archive(path, options = {}) {
1472
+ const { gzip = true, exclude, user, ...request } = options;
1473
+ return this.t.fileBytes({
1474
+ method: "GET",
1475
+ path: this.#path("/files/archive"),
1476
+ query: { path, gzip, ...(exclude ? { exclude } : {}), ...(user ? { user } : {}) },
1477
+ accept: gzip ? "application/gzip" : "application/x-tar",
1478
+ ...request,
1479
+ });
1480
+ }
1481
+ /** Unpacks archive bytes in a directory with the sandbox user's rights by
1482
+ * default; `user: root` explicitly selects root inside this guest. */
1483
+ async unarchive(path, bytes, options = {}) {
1484
+ const { gzip = bytes[0] === 0x1f && bytes[1] === 0x8b, user, ...request } = options;
1485
+ const scope = requestScope(request);
1486
+ scope.check();
1487
+ if (bytes.length <= CHUNK) {
1488
+ await this.t.bytes({
1489
+ method: "PUT",
1490
+ path: this.#path("/files/archive"),
1491
+ query: { path, ...(user ? { user } : {}) },
1492
+ bytes,
1493
+ ...scope.request,
1494
+ });
1495
+ return;
1496
+ }
1497
+ const key = request.idempotencyKey ?? crypto.randomUUID();
1498
+ const phase = async (name) => ({
1499
+ ...scope.request,
1500
+ idempotencyKey: await sha256Hex(JSON.stringify(["files.unarchive", key, name])),
1501
+ });
1502
+ const begin = await this.t.json({
1503
+ method: "POST",
1504
+ path: this.#path("/files/archive/uploads"),
1505
+ body: { path, gzip, ...(user ? { user } : {}) },
1506
+ ...(await phase("begin")),
1507
+ });
1508
+ const upload = (suffix = "") => this.#path(`/files/archive/uploads/${enc(begin.uploadId)}${suffix}`);
1509
+ try {
1510
+ if (!Number.isSafeInteger(begin.chunkBytes) || begin.chunkBytes <= 0)
1511
+ throw new ConnectionError({
1512
+ message: "The archive upload returned an invalid chunk size.",
1513
+ code: "connection_error",
1514
+ status: 0,
1515
+ });
1516
+ for (let offset = 0; offset < bytes.length; offset += begin.chunkBytes) {
1517
+ scope.check();
1518
+ await this.t.bytes({
1519
+ method: "PUT",
1520
+ path: upload(),
1521
+ query: { offset },
1522
+ bytes: bytes.subarray(offset, offset + begin.chunkBytes),
1523
+ ...(await phase(`chunk:${offset}`)),
1524
+ });
1525
+ }
1526
+ scope.check();
1527
+ await this.t.json({
1528
+ method: "POST",
1529
+ path: upload(":commit"),
1530
+ body: {},
1531
+ ...(await phase("commit")),
1532
+ });
1533
+ }
1534
+ catch (error) {
1535
+ // Cleanup must still run when the caller's signal/deadline has expired.
1536
+ const idempotencyKey = await sha256Hex(JSON.stringify(["files.unarchive", key, "abort"]));
1537
+ await this.t
1538
+ .json({ method: "POST", path: upload(":abort"), body: {}, idempotencyKey })
1539
+ .catch(() => undefined);
1540
+ throw error;
1541
+ }
1542
+ }
1160
1543
  /** Copies a local file or directory into the sandbox. A directory travels as
1161
1544
  * one gzipped tar to the API's folder routes, and the sandbox's own `tar`
1162
1545
  * unpacks it as it arrives, making the folder. */
@@ -1217,22 +1600,39 @@ export class Files {
1217
1600
  * into place only once every byte has arrived: a failed copy never leaves
1218
1601
  * a short file under the name asked for. A short stream is tried again,
1219
1602
  * twice, as a short `read` is. */
1220
- async #streamTo(remotePath, localPath) {
1603
+ async #streamTo(remotePath, localPath, options = {}) {
1604
+ const scope = requestScope(options);
1221
1605
  const fs = await import("node:fs/promises");
1222
1606
  const partial = `${localPath}.runtime-partial-${crypto.randomUUID().slice(0, 8)}`;
1223
1607
  for (let attempt = 0;; attempt++) {
1224
1608
  try {
1225
- const stream = await this.readStream(remotePath);
1609
+ scope.check();
1610
+ const stream = await this.readStream(remotePath, scope.request);
1226
1611
  // The handle is closed before anything else touches the file, so a
1227
1612
  // failed copy's partial file is really gone when the error returns.
1228
- const file = await fs.open(partial, "w");
1229
1613
  try {
1230
- for await (const chunk of stream)
1231
- await file.write(chunk);
1614
+ const file = await fs.open(partial, "w");
1615
+ try {
1616
+ for await (const chunk of stream) {
1617
+ let written = 0;
1618
+ while (written < chunk.length) {
1619
+ scope.check();
1620
+ const result = await file.write(chunk.subarray(written));
1621
+ if (result.bytesWritten === 0)
1622
+ throw new Error("The local file accepted no bytes.");
1623
+ written += result.bytesWritten;
1624
+ }
1625
+ }
1626
+ }
1627
+ finally {
1628
+ await file.close();
1629
+ }
1232
1630
  }
1233
1631
  finally {
1234
- await file.close();
1632
+ // Opening or writing the local file can fail before consumption.
1633
+ await stream.cancel().catch(() => undefined);
1235
1634
  }
1635
+ scope.check();
1236
1636
  await fs.rename(partial, localPath);
1237
1637
  return;
1238
1638
  }
@@ -1248,10 +1648,12 @@ export class Files {
1248
1648
  /** Copies a file or directory out of the sandbox. A file streams to disk,
1249
1649
  * any size, checked against its length; a directory comes as one tar from
1250
1650
  * the API's folder route. */
1251
- async download(remotePath, localPath) {
1651
+ async download(remotePath, localPath, options = {}) {
1652
+ const scope = requestScope(options);
1653
+ scope.check();
1252
1654
  const fs = await import("node:fs/promises");
1253
1655
  const paths = await import("node:path");
1254
- const entry = await this.stat(remotePath);
1656
+ const entry = await this.stat(remotePath, scope.request);
1255
1657
  if (!entry.exists)
1256
1658
  throw new RuntimeError({
1257
1659
  message: `${remotePath} does not exist.`,
@@ -1260,7 +1662,7 @@ export class Files {
1260
1662
  });
1261
1663
  if (entry.type !== "directory") {
1262
1664
  await fs.mkdir(paths.dirname(localPath), { recursive: true });
1263
- await this.#streamTo(remotePath, localPath);
1665
+ await this.#streamTo(remotePath, localPath, scope.request);
1264
1666
  return;
1265
1667
  }
1266
1668
  // The sandbox's own `tar` packs it as it streams, and it is unpacked as
@@ -1270,15 +1672,24 @@ export class Files {
1270
1672
  path: this.#path("/files/archive"),
1271
1673
  query: { path: remotePath, gzip: true },
1272
1674
  accept: "application/gzip",
1675
+ ...scope.request,
1273
1676
  });
1274
1677
  const { unpackStream } = await import("./tar.js");
1275
- await unpackStream(archive, localPath);
1678
+ try {
1679
+ await unpackStream(archive, localPath, {
1680
+ signal: scope.request.signal,
1681
+ });
1682
+ }
1683
+ finally {
1684
+ await archive.cancel().catch(() => undefined);
1685
+ }
1276
1686
  }
1277
1687
  }
1278
- export function sandboxPage(t, body, query) {
1688
+ export function sandboxPage(t, body, query, options = {}) {
1279
1689
  return new Page(body.data.map((info) => new Sandbox(t, info)), body.nextCursor, async (cursor) => sandboxPage(t, await t.json({
1280
1690
  method: "GET",
1281
1691
  path: "/v1/sandboxes",
1282
1692
  query: Object.assign({}, query, { cursor }),
1283
- }), query));
1693
+ ...options,
1694
+ }), query, options));
1284
1695
  }