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.
- package/BLAXEL.md +4 -4
- package/DAYTONA.md +13 -7
- package/E2B.md +12 -8
- package/README.md +15 -7
- package/README.src.md +15 -7
- package/VERCEL.md +13 -3
- package/compatibility-lock.json +84 -8
- package/dist/blaxel/errors.js +1 -1
- package/dist/blaxel/lookup.d.ts +3 -2
- package/dist/blaxel/lookup.js +3 -2
- package/dist/blaxel/pagination.d.ts +2 -2
- package/dist/blaxel/pagination.js +45 -17
- package/dist/blaxel/preview.d.ts +2 -1
- package/dist/blaxel/preview.js +15 -13
- package/dist/blaxel/process.js +56 -10
- package/dist/blaxel/sandbox.d.ts +2 -0
- package/dist/blaxel/sandbox.js +2 -0
- package/dist/blaxel/snapshot.js +3 -5
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +189 -54
- package/dist/client.d.ts +29 -3
- package/dist/client.js +40 -11
- package/dist/cloudflare/identity.d.ts +7 -0
- package/dist/cloudflare/identity.js +13 -0
- package/dist/cloudflare/index.d.ts +9 -0
- package/dist/cloudflare/index.js +47 -9
- package/dist/cloudflare/worker.d.ts +4 -0
- package/dist/cloudflare/worker.js +166 -16
- package/dist/codesandbox/guest-service.js +57 -20
- package/dist/codesandbox/index.js +42 -8
- package/dist/compat/core.d.ts +9 -8
- package/dist/compat/core.js +60 -21
- package/dist/compat/files.js +22 -4
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +107 -38
- package/dist/daytona/daytona.js +11 -4
- package/dist/daytona/filesystem.d.ts +17 -4
- package/dist/daytona/filesystem.js +121 -38
- package/dist/daytona/index.d.ts +1 -1
- package/dist/daytona/sandbox.d.ts +1 -1
- package/dist/daytona/sandbox.js +16 -7
- package/dist/e2b/client.js +1 -1
- package/dist/e2b/commands.d.ts +30 -8
- package/dist/e2b/commands.js +142 -54
- package/dist/e2b/index.d.ts +1 -0
- package/dist/e2b/index.js +1 -0
- package/dist/e2b/pty.d.ts +24 -0
- package/dist/e2b/pty.js +80 -0
- package/dist/e2b/sandbox.d.ts +2 -1
- package/dist/e2b/sandbox.js +49 -15
- package/dist/file-source.d.ts +16 -0
- package/dist/file-source.js +162 -0
- package/dist/freestyle/index.d.ts +9 -1
- package/dist/freestyle/index.js +38 -5
- package/dist/index.d.ts +2 -1
- package/dist/login.js +16 -10
- package/dist/modal/index.js +2 -1
- package/dist/network-cli.js +111 -43
- package/dist/openai-agents/index.js +9 -45
- package/dist/page.d.ts +1 -1
- package/dist/page.js +2 -2
- package/dist/products/index.d.ts +12 -0
- package/dist/products/index.js +10 -0
- package/dist/products/observability.d.ts +44 -0
- package/dist/products/observability.js +6 -0
- package/dist/products/previews.d.ts +8 -2
- package/dist/products/previews.js +11 -3
- package/dist/products/volumes.d.ts +42 -0
- package/dist/products/volumes.js +36 -0
- package/dist/products/watch.js +61 -8
- package/dist/proxy.js +3 -1
- package/dist/runloop/index.d.ts +13 -27
- package/dist/runloop/index.js +149 -52
- package/dist/sandbox.d.ts +37 -8
- package/dist/sandbox.js +432 -68
- package/dist/snapshots.d.ts +9 -4
- package/dist/sprites/index.js +69 -33
- package/dist/tar.d.ts +3 -1
- package/dist/tar.js +140 -14
- package/dist/transport.d.ts +4 -7
- package/dist/transport.js +205 -122
- package/dist/vercel/command.d.ts +1 -1
- package/dist/vercel/command.js +3 -2
- package/dist/vercel/errors.d.ts +1 -1
- package/dist/vercel/errors.js +7 -1
- package/dist/vercel/sandbox.d.ts +8 -8
- package/dist/vercel/sandbox.js +154 -59
- package/dist/vercel/snapshot.d.ts +3 -2
- package/dist/vercel/snapshot.js +9 -5
- 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
|
-
|
|
558
|
+
captured = snapshot;
|
|
511
559
|
}
|
|
512
|
-
|
|
513
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
...
|
|
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 =
|
|
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
|
-
...
|
|
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:
|
|
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:
|
|
1216
|
+
body: { path, size: source.size, sha256 },
|
|
1039
1217
|
...phaseOptions("legacy-begin"),
|
|
1040
1218
|
});
|
|
1041
1219
|
}
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
-
|
|
1054
|
-
|
|
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 (!
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1092
|
-
.json({
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1646
|
+
...options,
|
|
1647
|
+
}), query, options));
|
|
1284
1648
|
}
|