withruntime 0.9.0 → 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 (90) hide show
  1. package/BLAXEL.md +4 -4
  2. package/DAYTONA.md +13 -7
  3. package/E2B.md +12 -8
  4. package/README.md +15 -7
  5. package/README.src.md +15 -7
  6. package/VERCEL.md +13 -3
  7. package/compatibility-lock.json +84 -8
  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 +189 -54
  21. package/dist/client.d.ts +29 -3
  22. package/dist/client.js +40 -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/credentials.d.ts +6 -1
  35. package/dist/credentials.js +107 -38
  36. package/dist/daytona/daytona.js +11 -4
  37. package/dist/daytona/filesystem.d.ts +17 -4
  38. package/dist/daytona/filesystem.js +121 -38
  39. package/dist/daytona/index.d.ts +1 -1
  40. package/dist/daytona/sandbox.d.ts +1 -1
  41. package/dist/daytona/sandbox.js +16 -7
  42. package/dist/e2b/client.js +1 -1
  43. package/dist/e2b/commands.d.ts +30 -8
  44. package/dist/e2b/commands.js +142 -54
  45. package/dist/e2b/index.d.ts +1 -0
  46. package/dist/e2b/index.js +1 -0
  47. package/dist/e2b/pty.d.ts +24 -0
  48. package/dist/e2b/pty.js +80 -0
  49. package/dist/e2b/sandbox.d.ts +2 -1
  50. package/dist/e2b/sandbox.js +49 -15
  51. package/dist/file-source.d.ts +16 -0
  52. package/dist/file-source.js +162 -0
  53. package/dist/freestyle/index.d.ts +9 -1
  54. package/dist/freestyle/index.js +38 -5
  55. package/dist/index.d.ts +2 -1
  56. package/dist/login.js +16 -10
  57. package/dist/modal/index.js +2 -1
  58. package/dist/network-cli.js +111 -43
  59. package/dist/openai-agents/index.js +9 -45
  60. package/dist/page.d.ts +1 -1
  61. package/dist/page.js +2 -2
  62. package/dist/products/index.d.ts +12 -0
  63. package/dist/products/index.js +10 -0
  64. package/dist/products/observability.d.ts +44 -0
  65. package/dist/products/observability.js +6 -0
  66. package/dist/products/previews.d.ts +8 -2
  67. package/dist/products/previews.js +11 -3
  68. package/dist/products/volumes.d.ts +42 -0
  69. package/dist/products/volumes.js +36 -0
  70. package/dist/products/watch.js +61 -8
  71. package/dist/proxy.js +3 -1
  72. package/dist/runloop/index.d.ts +13 -27
  73. package/dist/runloop/index.js +149 -52
  74. package/dist/sandbox.d.ts +37 -8
  75. package/dist/sandbox.js +432 -68
  76. package/dist/snapshots.d.ts +9 -4
  77. package/dist/sprites/index.js +69 -33
  78. package/dist/tar.d.ts +3 -1
  79. package/dist/tar.js +140 -14
  80. package/dist/transport.d.ts +4 -7
  81. package/dist/transport.js +205 -122
  82. package/dist/vercel/command.d.ts +1 -1
  83. package/dist/vercel/command.js +3 -2
  84. package/dist/vercel/errors.d.ts +1 -1
  85. package/dist/vercel/errors.js +7 -1
  86. package/dist/vercel/sandbox.d.ts +8 -8
  87. package/dist/vercel/sandbox.js +154 -59
  88. package/dist/vercel/snapshot.d.ts +3 -2
  89. package/dist/vercel/snapshot.js +9 -5
  90. 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
  }
@@ -464,6 +508,9 @@ export class Sandbox {
464
508
  const running = this.state === "running";
465
509
  // Check before entering cleanup: an abort during refresh must not pause or wake.
466
510
  const pauseOptions = running ? request() : undefined;
511
+ let captured;
512
+ let primary;
513
+ let failed = false;
467
514
  try {
468
515
  if (running)
469
516
  await this.pause(pauseOptions);
@@ -475,6 +522,7 @@ export class Sandbox {
475
522
  ...(idempotencyKey ? { idempotencyKey } : {}),
476
523
  ...request(),
477
524
  });
525
+ captured = snapshot;
478
526
  // Prefer: wait is bounded on the server. A capture still in progress
479
527
  // must keep its source paused, or the worker refuses it after we wake.
480
528
  while (snapshot.state === "capturing") {
@@ -507,12 +555,39 @@ export class Sandbox {
507
555
  message: "The server did not confirm a disk-only snapshot.",
508
556
  details: { snapshotId: snapshot.id },
509
557
  });
510
- return snapshot;
558
+ captured = snapshot;
511
559
  }
512
- finally {
513
- if (running)
560
+ catch (error) {
561
+ failed = true;
562
+ primary = error;
563
+ }
564
+ if (running) {
565
+ try {
514
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
+ }
515
587
  }
588
+ if (failed)
589
+ throw primary;
590
+ return captured;
516
591
  }
517
592
  async fork(options = {}) {
518
593
  const { idempotencyKey, signal, timeoutMs, ...body } = options;
@@ -690,16 +765,18 @@ export class Processes {
690
765
  this.t = t;
691
766
  this.sandboxId = sandboxId;
692
767
  }
693
- async list() {
768
+ async list(options = {}) {
694
769
  return (await this.t.json({
695
770
  method: "GET",
696
771
  path: `/v1/sandboxes/${enc(this.sandboxId)}/processes`,
772
+ ...options,
697
773
  })).data;
698
774
  }
699
- async get(processId) {
775
+ async get(processId, options = {}) {
700
776
  const info = await this.t.json({
701
777
  method: "GET",
702
778
  path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(processId)}`,
779
+ ...options,
703
780
  });
704
781
  return new Process(this.t, this.sandboxId, info);
705
782
  }
@@ -815,16 +892,27 @@ export class Process {
815
892
  processId: this.id,
816
893
  };
817
894
  }
818
- /** Sends input. Offsets are tracked for you, so a retried write is never typed twice. */
895
+ /** Sends input. Offsets protect transport retries; signal and timeoutMs cover
896
+ * the whole logical write, including its queue and backpressure. */
819
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
+ }));
820
904
  // Copy before queuing: callers may reuse their buffer while a previous
821
905
  // write waits for the guest to drain its pipe.
822
906
  const bytes = new Uint8Array(utf8(data));
823
907
  const eof = options.eof === true;
908
+ const { eof: _eof, ...requestOptions } = options;
909
+ const scope = requestScope(requestOptions);
824
910
  const write = this.#inputWrites.then(async () => {
825
911
  let sent = 0;
912
+ let chunkSize = CHUNK;
826
913
  do {
827
- const chunk = bytes.subarray(sent, sent + 1_048_576);
914
+ scope.check();
915
+ const chunk = bytes.subarray(sent, sent + chunkSize);
828
916
  const reply = await this.t.json({
829
917
  method: "POST",
830
918
  path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(this.id)}:write`,
@@ -833,6 +921,7 @@ export class Process {
833
921
  offset: this.#inputOffset,
834
922
  ...(eof && sent + chunk.length === bytes.length ? { eof: true } : {}),
835
923
  },
924
+ ...scope.request,
836
925
  });
837
926
  const accepted = reply.offset - this.#inputOffset;
838
927
  if (!Number.isSafeInteger(reply.offset) || accepted < 0 || accepted > chunk.length)
@@ -843,23 +932,36 @@ export class Process {
843
932
  });
844
933
  sent += accepted;
845
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
+ }
846
946
  } while (sent < bytes.length);
847
947
  });
848
948
  this.#inputWrites = write.catch(() => undefined);
849
- return write;
949
+ return scope.wait(write);
850
950
  }
851
- async kill(signal = "SIGTERM") {
951
+ async kill(signal = "SIGTERM", options = {}) {
852
952
  await this.t.json({
853
953
  method: "POST",
854
954
  path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(this.id)}:signal`,
855
955
  body: { signal },
956
+ ...options,
856
957
  });
857
958
  }
858
- async resize(cols, rows) {
959
+ async resize(cols, rows, options = {}) {
859
960
  await this.t.json({
860
961
  method: "POST",
861
962
  path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(this.id)}:resize`,
862
963
  body: { cols, rows },
964
+ ...options,
863
965
  });
864
966
  }
865
967
  async refresh() {
@@ -993,6 +1095,8 @@ export class Files {
993
1095
  * sets its permissions (0o755 for a program); 0o644 when left out. */
994
1096
  async write(path, data, writeOptions = {}) {
995
1097
  const { mode, ...options } = writeOptions;
1098
+ const scope = requestScope(options);
1099
+ scope.check();
996
1100
  const octal = mode === undefined ? undefined : (mode & 0o777).toString(8).padStart(3, "0");
997
1101
  const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
998
1102
  if (bytes.length <= CHUNK) {
@@ -1003,27 +1107,100 @@ export class Files {
1003
1107
  path: this.#path("/files/content"),
1004
1108
  query: { path, ...(octal ? { mode: octal } : {}) },
1005
1109
  bytes,
1006
- ...options,
1110
+ ...scope.request,
1007
1111
  });
1008
1112
  return { path, size: bytes.length };
1009
1113
  }
1010
- 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;
1011
1187
  // A guest journals mutations across operations, not per URL. One logical
1012
1188
  // write therefore owns separate stable keys for each mutation phase.
1013
- const uploadKey = options.idempotencyKey ?? crypto.randomUUID();
1189
+ const uploadKey = scope.request.idempotencyKey ?? crypto.randomUUID();
1014
1190
  const phaseKeys = new Map();
1015
1191
  for (const phase of ["begin", "legacy-begin", "commit", "chmod", "abort"])
1016
1192
  phaseKeys.set(phase, await sha256Hex(JSON.stringify(["files.write", uploadKey, phase])));
1017
1193
  const phaseOptions = (phase) => ({
1018
- ...options,
1194
+ ...scope.request,
1019
1195
  idempotencyKey: phaseKeys.get(phase),
1020
1196
  });
1021
1197
  let begin;
1198
+ scope.check();
1022
1199
  try {
1023
1200
  begin = await this.t.json({
1024
1201
  method: "POST",
1025
1202
  path: this.#path("/uploads"),
1026
- body: { path, size: bytes.length, sha256, ...(octal ? { mode: octal } : {}) },
1203
+ body: { path, size: source.size, sha256, ...(octal ? { mode: octal } : {}) },
1027
1204
  ...phaseOptions("begin"),
1028
1205
  });
1029
1206
  }
@@ -1032,73 +1209,158 @@ export class Files {
1032
1209
  throw error;
1033
1210
  // The API aborted the unsupported transfer before accepting chunks.
1034
1211
  // A different body needs a different key; retain deterministic retries.
1212
+ scope.check();
1035
1213
  begin = await this.t.json({
1036
1214
  method: "POST",
1037
1215
  path: this.#path("/uploads"),
1038
- body: { path, size: bytes.length, sha256 },
1216
+ body: { path, size: source.size, sha256 },
1039
1217
  ...phaseOptions("legacy-begin"),
1040
1218
  });
1041
1219
  }
1042
- const commit = () => this.t.json({
1043
- method: "POST",
1044
- path: this.#path(`/uploads/${enc(begin.uploadId)}:commit`),
1045
- body: {},
1046
- ...phaseOptions("commit"),
1047
- });
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
+ };
1048
1236
  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.
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 () => {
1052
1247
  try {
1053
- await commit();
1054
- 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
+ }
1055
1285
  }
1056
1286
  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
- });
1287
+ if (!failed) {
1288
+ failed = true;
1289
+ primaryFailure = error;
1290
+ stopChunks.abort(error);
1291
+ }
1292
+ throw error;
1074
1293
  }
1075
1294
  };
1076
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
+ }
1077
1317
  if (!committed) {
1078
- 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;
1079
1324
  await commit();
1080
1325
  }
1081
1326
  // An older sandbox image ignores the mode in the upload; set it after.
1082
- if (octal && begin.mode !== octal)
1327
+ if (octal && begin.mode !== octal) {
1328
+ scope.check();
1083
1329
  await this.t.json({
1084
1330
  method: "POST",
1085
1331
  path: this.#path("/files:chmod"),
1086
1332
  body: { path, mode: octal },
1087
1333
  ...phaseOptions("chmod"),
1088
1334
  });
1335
+ }
1089
1336
  }
1090
1337
  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);
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
+ }
1099
1361
  throw error;
1100
1362
  }
1101
- return { path, size: bytes.length };
1363
+ return { path, size: source.size };
1102
1364
  }
1103
1365
  /** Directory entries; `depth` goes deeper, `glob` filters (e.g. "**\/*.py"). */
1104
1366
  async list(path = "/workspace", options = {}) {
@@ -1157,6 +1419,80 @@ export class Files {
1157
1419
  ...request,
1158
1420
  });
1159
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
+ }
1160
1496
  /** Copies a local file or directory into the sandbox. A directory travels as
1161
1497
  * one gzipped tar to the API's folder routes, and the sandbox's own `tar`
1162
1498
  * unpacks it as it arrives, making the folder. */
@@ -1217,22 +1553,39 @@ export class Files {
1217
1553
  * into place only once every byte has arrived: a failed copy never leaves
1218
1554
  * a short file under the name asked for. A short stream is tried again,
1219
1555
  * twice, as a short `read` is. */
1220
- async #streamTo(remotePath, localPath) {
1556
+ async #streamTo(remotePath, localPath, options = {}) {
1557
+ const scope = requestScope(options);
1221
1558
  const fs = await import("node:fs/promises");
1222
1559
  const partial = `${localPath}.runtime-partial-${crypto.randomUUID().slice(0, 8)}`;
1223
1560
  for (let attempt = 0;; attempt++) {
1224
1561
  try {
1225
- const stream = await this.readStream(remotePath);
1562
+ scope.check();
1563
+ const stream = await this.readStream(remotePath, scope.request);
1226
1564
  // The handle is closed before anything else touches the file, so a
1227
1565
  // failed copy's partial file is really gone when the error returns.
1228
- const file = await fs.open(partial, "w");
1229
1566
  try {
1230
- for await (const chunk of stream)
1231
- 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
+ }
1232
1583
  }
1233
1584
  finally {
1234
- await file.close();
1585
+ // Opening or writing the local file can fail before consumption.
1586
+ await stream.cancel().catch(() => undefined);
1235
1587
  }
1588
+ scope.check();
1236
1589
  await fs.rename(partial, localPath);
1237
1590
  return;
1238
1591
  }
@@ -1248,10 +1601,12 @@ export class Files {
1248
1601
  /** Copies a file or directory out of the sandbox. A file streams to disk,
1249
1602
  * any size, checked against its length; a directory comes as one tar from
1250
1603
  * the API's folder route. */
1251
- async download(remotePath, localPath) {
1604
+ async download(remotePath, localPath, options = {}) {
1605
+ const scope = requestScope(options);
1606
+ scope.check();
1252
1607
  const fs = await import("node:fs/promises");
1253
1608
  const paths = await import("node:path");
1254
- const entry = await this.stat(remotePath);
1609
+ const entry = await this.stat(remotePath, scope.request);
1255
1610
  if (!entry.exists)
1256
1611
  throw new RuntimeError({
1257
1612
  message: `${remotePath} does not exist.`,
@@ -1260,7 +1615,7 @@ export class Files {
1260
1615
  });
1261
1616
  if (entry.type !== "directory") {
1262
1617
  await fs.mkdir(paths.dirname(localPath), { recursive: true });
1263
- await this.#streamTo(remotePath, localPath);
1618
+ await this.#streamTo(remotePath, localPath, scope.request);
1264
1619
  return;
1265
1620
  }
1266
1621
  // The sandbox's own `tar` packs it as it streams, and it is unpacked as
@@ -1270,15 +1625,24 @@ export class Files {
1270
1625
  path: this.#path("/files/archive"),
1271
1626
  query: { path: remotePath, gzip: true },
1272
1627
  accept: "application/gzip",
1628
+ ...scope.request,
1273
1629
  });
1274
1630
  const { unpackStream } = await import("./tar.js");
1275
- await unpackStream(archive, localPath);
1631
+ try {
1632
+ await unpackStream(archive, localPath, {
1633
+ signal: scope.request.signal,
1634
+ });
1635
+ }
1636
+ finally {
1637
+ await archive.cancel().catch(() => undefined);
1638
+ }
1276
1639
  }
1277
1640
  }
1278
- export function sandboxPage(t, body, query) {
1641
+ export function sandboxPage(t, body, query, options = {}) {
1279
1642
  return new Page(body.data.map((info) => new Sandbox(t, info)), body.nextCursor, async (cursor) => sandboxPage(t, await t.json({
1280
1643
  method: "GET",
1281
1644
  path: "/v1/sandboxes",
1282
1645
  query: Object.assign({}, query, { cursor }),
1283
- }), query));
1646
+ ...options,
1647
+ }), query, options));
1284
1648
  }