withruntime 0.6.0 → 0.7.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 (47) hide show
  1. package/E2B.md +32 -32
  2. package/README.md +8 -5
  3. package/dist/ai/index.d.ts +21 -5
  4. package/dist/ai/index.js +14 -21
  5. package/dist/ai-harness/index.d.ts +194 -0
  6. package/dist/ai-harness/index.js +517 -0
  7. package/dist/billing-cli.d.ts +21 -0
  8. package/dist/billing-cli.js +158 -0
  9. package/dist/cli-extras.d.ts +15 -0
  10. package/dist/cli-extras.js +60 -4
  11. package/dist/cli-name.d.ts +6 -0
  12. package/dist/cli-name.js +8 -0
  13. package/dist/cli.d.ts +17 -1
  14. package/dist/cli.js +537 -130
  15. package/dist/client.d.ts +35 -6
  16. package/dist/client.js +23 -0
  17. package/dist/credentials.js +1 -2
  18. package/dist/daytona/filesystem.d.ts +2 -0
  19. package/dist/daytona/filesystem.js +9 -7
  20. package/dist/e2b/filesystem.js +3 -2
  21. package/dist/e2b/sandbox.d.ts +9 -6
  22. package/dist/e2b/sandbox.js +31 -7
  23. package/dist/errors.d.ts +5 -0
  24. package/dist/errors.js +7 -0
  25. package/dist/index.d.ts +8 -6
  26. package/dist/index.js +1 -0
  27. package/dist/keys.js +1 -1
  28. package/dist/login.d.ts +2 -6
  29. package/dist/login.js +3 -8
  30. package/dist/network-cli.d.ts +1 -1
  31. package/dist/network-cli.js +64 -2
  32. package/dist/products/billing.d.ts +35 -0
  33. package/dist/products/billing.js +13 -0
  34. package/dist/products/index.d.ts +4 -1
  35. package/dist/products/index.js +4 -1
  36. package/dist/products/network-products.d.ts +45 -0
  37. package/dist/products/network-products.js +27 -0
  38. package/dist/products/previews.d.ts +1 -2
  39. package/dist/products/secrets.d.ts +14 -0
  40. package/dist/sandbox.d.ts +17 -3
  41. package/dist/sandbox.js +233 -62
  42. package/dist/transport.d.ts +17 -1
  43. package/dist/transport.js +124 -7
  44. package/dist/types.d.ts +0 -2
  45. package/dist/wallet.d.ts +30 -0
  46. package/dist/wallet.js +48 -0
  47. package/package.json +44 -17
@@ -165,3 +165,48 @@ export declare function tunnel(t: Transport): {
165
165
  }, options?: RequestOptions) => Promise<TunnelPeerCreated>;
166
166
  removePeer: (peerId: string, options?: RequestOptions) => Promise<Tunnel>;
167
167
  };
168
+ /** The proxy your sandboxes' outbound connections go through. */
169
+ export type UpstreamProxy = {
170
+ /** http://host:port or https://host:port. */
171
+ url: string;
172
+ /** The secret sent as Proxy-Authorization. */
173
+ secret?: string;
174
+ /** The destinations that go through it; every destination when absent. */
175
+ hosts?: string[];
176
+ updatedAt: string;
177
+ };
178
+ export type SetUpstreamProxy = {
179
+ /** http://host:port or https://host:port. An https proxy's certificate must
180
+ * verify against the public roots. */
181
+ url: string;
182
+ /** One of your secrets, sent as the Proxy-Authorization header's whole
183
+ * value (such as `Basic dXNlcjpwYXNz`). Its hosts must name the proxy's host. */
184
+ secret?: string;
185
+ /** Only connections to these hosts (or `*.domain`) go through the proxy;
186
+ * every connection when absent. 1 to 16. */
187
+ hosts?: string[];
188
+ };
189
+ /** `runtime.network`: the network settings of the whole account.
190
+ *
191
+ * await runtime.network.upstreamProxy.set({ url: "http://proxy.example.com:3128" });
192
+ *
193
+ * `upstreamProxy`: after Runtime's own rules allow a connection, the host
194
+ * reaches it with CONNECT through your proxy, so your proxy's controls and
195
+ * logs apply. A proxy that refuses or cannot be reached fails the connection
196
+ * (upstream-proxy-failed); nothing goes around it. Paid accounts only. */
197
+ export declare function network(t: Transport): {
198
+ upstreamProxy: {
199
+ /** Send outbound connections through your proxy, replacing any set before. */
200
+ set: (input: SetUpstreamProxy, options?: RequestOptions) => Promise<UpstreamProxy & {
201
+ enforced: boolean;
202
+ }>;
203
+ /** The proxy in use. Throws `not_found` (404) when none is set. */
204
+ get: (options?: RequestOptions) => Promise<UpstreamProxy>;
205
+ /** Connections leave directly again, within seconds. Throws `not_found`
206
+ * (404) when none is set. */
207
+ remove: (options?: RequestOptions) => Promise<{
208
+ deleted: true;
209
+ enforced: boolean;
210
+ }>;
211
+ };
212
+ };
@@ -80,3 +80,30 @@ export function tunnel(t) {
80
80
  removePeer: (peerId, options) => t.json({ method: "DELETE", path: `/v1/tunnel/peers/${enc(peerId)}`, ...options }),
81
81
  };
82
82
  }
83
+ /** `runtime.network`: the network settings of the whole account.
84
+ *
85
+ * await runtime.network.upstreamProxy.set({ url: "http://proxy.example.com:3128" });
86
+ *
87
+ * `upstreamProxy`: after Runtime's own rules allow a connection, the host
88
+ * reaches it with CONNECT through your proxy, so your proxy's controls and
89
+ * logs apply. A proxy that refuses or cannot be reached fails the connection
90
+ * (upstream-proxy-failed); nothing goes around it. Paid accounts only. */
91
+ export function network(t) {
92
+ const path = "/v1/network/upstream-proxy";
93
+ return {
94
+ upstreamProxy: {
95
+ /** Send outbound connections through your proxy, replacing any set before. */
96
+ set: (input, options) => t.json({
97
+ method: "PUT",
98
+ path,
99
+ body: input,
100
+ ...options,
101
+ }),
102
+ /** The proxy in use. Throws `not_found` (404) when none is set. */
103
+ get: (options) => t.json({ method: "GET", path, ...options }),
104
+ /** Connections leave directly again, within seconds. Throws `not_found`
105
+ * (404) when none is set. */
106
+ remove: (options) => t.json({ method: "DELETE", path, ...options }),
107
+ },
108
+ };
109
+ }
@@ -18,8 +18,7 @@ export type Preview = {
18
18
  /** Turned off by Runtime after a report. */
19
19
  disabled: boolean;
20
20
  createdAt: string;
21
- /** On create only: how to share the address, and the referral link that
22
- * pays you both when someone you share it with signs up. */
21
+ /** On create only: how to share the address, in one line. */
23
22
  hint?: string;
24
23
  };
25
24
  export type CreatePreview = {
@@ -1,4 +1,13 @@
1
1
  import type { RequestOptions, Transport } from "../transport.js";
2
+ /** Which requests to a secret's hosts carry it: `methods` (every method when
3
+ * absent) and `paths`, each exact (`/v1/chat/completions`) or a prefix ending
4
+ * in `/*` (`/repos/acme/*`), written canonically: starting with `/`, no `.` or
5
+ * `..` segments, no `;` or `\`, no encoded slash. A request whose path could be
6
+ * read two ways gets no secret that has rules. Paid accounts only. */
7
+ export type SecretRule = {
8
+ methods?: string[];
9
+ paths: string[];
10
+ };
2
11
  /** A secret your sandboxes use without seeing it. Every sandbox of your
3
12
  * organization has an environment variable of the secret's name holding
4
13
  * `placeholder`; the egress proxy puts the value into HTTPS requests to
@@ -9,6 +18,7 @@ export type Secret = {
9
18
  hosts: string[];
10
19
  header?: string;
11
20
  format?: string;
21
+ rules?: SecretRule[];
12
22
  placeholder: string;
13
23
  valueBytes: number;
14
24
  createdAt: string;
@@ -23,6 +33,10 @@ export type SetSecret = {
23
33
  header?: string;
24
34
  /** With `header`: its value, `{value}` where the secret goes. Default `{value}`. */
25
35
  format?: string;
36
+ /** Only requests a rule allows carry the value. Paid accounts only. 1 to 16.
37
+ * Absent: every request to the hosts; replacing a secret without rules
38
+ * clears them. */
39
+ rules?: SecretRule[];
26
40
  };
27
41
  export declare function secrets(t: Transport): {
28
42
  /** Store or replace a secret. Replacing keeps its placeholder. */
package/dist/sandbox.d.ts CHANGED
@@ -30,7 +30,9 @@ export declare class Sandbox implements AsyncDisposable {
30
30
  * far), never an error; `check: true` throws CommandError on a non-zero exit. */
31
31
  exec(command: string | readonly string[], options?: ExecOptions): Promise<CommandResult>;
32
32
  /** The command's events as they happen: start, stdout, stderr, exit. Resumes
33
- * by itself when the server ends a long stream, so it never drops output. */
33
+ * by itself when the server ends a long stream or the connection drops after
34
+ * the command started, and yields `truncated` for any output it did not
35
+ * receive, so lost output never passes as whole. */
34
36
  execStream(command: string | readonly string[], options?: ExecOptions): AsyncGenerator<OutputEvent>;
35
37
  /** Starts a background process (a server, a watcher, a REPL) and returns at
36
38
  * once. `stdin: "pipe"` keeps its input open for write(); `pty` gives it a
@@ -202,8 +204,19 @@ export declare class Files {
202
204
  stopped: boolean;
203
205
  }>;
204
206
  };
205
- /** A file's bytes, any size. */
207
+ /** A file's bytes, any size, checked against the length (and for a small
208
+ * file the SHA-256) the API sends first: a read that arrives short is read
209
+ * again, then throws `download_incomplete`, never returns short. For a file
210
+ * too big to hold in memory use `readStream` or `download`. */
206
211
  read(path: string, options?: RequestOptions): Promise<Uint8Array>;
212
+ /** A file's bytes as they arrive, for files of any size without holding
213
+ * them in memory (Daytona's `downloadFileStream`). The stream errors with
214
+ * `download_incomplete` if it ends short of the file's length.
215
+ *
216
+ * const stream = await sbx.files.readStream("/workspace/big.tar");
217
+ * await stream.pipeTo(Writable.toWeb(fs.createWriteStream("big.tar")));
218
+ */
219
+ readStream(path: string, options?: RequestOptions): Promise<ReadableStream<Uint8Array>>;
207
220
  readText(path: string, options?: RequestOptions): Promise<string>;
208
221
  /** Writes a file of any size, atomically, making parent directories. Large
209
222
  * ones go in parallel 1 MiB chunks checked against their SHA-256. `mode`
@@ -241,7 +254,8 @@ export declare class Files {
241
254
  /** Copies a local file or directory into the sandbox. A directory travels as
242
255
  * one gzipped tar and is unpacked in place. */
243
256
  upload(localPath: string, remotePath: string): Promise<void>;
244
- /** Copies a file or directory out of the sandbox. */
257
+ /** Copies a file or directory out of the sandbox. A file streams to disk,
258
+ * any size, checked against its length. */
245
259
  download(remotePath: string, localPath: string): Promise<void>;
246
260
  }
247
261
  export declare function sandboxPage(t: Transport, body: {
package/dist/sandbox.js CHANGED
@@ -1,4 +1,4 @@
1
- import { CommandError, RuntimeError } from "./errors.js";
1
+ import { CommandError, ConnectionError, NotFoundError, RuntimeError } from "./errors.js";
2
2
  import { Page } from "./page.js";
3
3
  import { sandboxWatches, watchDirectory, } from "./products/watch.js";
4
4
  import { sandboxFactories } from "./products/index.js";
@@ -83,17 +83,22 @@ export class Sandbox {
83
83
  let exit;
84
84
  let processId;
85
85
  let dropped = false;
86
- for await (const event of this.execStream(command, options)) {
87
- if (event.type === "start")
88
- processId = event.processId;
89
- else if (event.type === "stdout" || event.type === "stderr") {
90
- out[event.type] += event.data;
91
- (event.type === "stdout" ? options.onStdout : options.onStderr)?.(event.data);
86
+ try {
87
+ for await (const event of this.execStream(command, options)) {
88
+ if (event.type === "start")
89
+ processId = event.processId;
90
+ else if (event.type === "stdout" || event.type === "stderr") {
91
+ out[event.type] += event.data;
92
+ (event.type === "stdout" ? options.onStdout : options.onStderr)?.(event.data);
93
+ }
94
+ else if (event.type === "truncated")
95
+ dropped = true;
96
+ else if (event.type === "exit")
97
+ exit = event;
92
98
  }
93
- else if (event.type === "truncated")
94
- dropped = true;
95
- else if (event.type === "exit")
96
- exit = event;
99
+ }
100
+ catch (error) {
101
+ throw await this.#unfinished(error, processId, options.signal);
97
102
  }
98
103
  result = {
99
104
  exitCode: exit?.exitCode ?? null,
@@ -108,11 +113,49 @@ export class Sandbox {
108
113
  throw new CommandError(result);
109
114
  return result;
110
115
  }
116
+ /** An exec that ended before its command did. Cancelled through `signal`,
117
+ * its command is sent SIGTERM, as Ctrl-C would stop it at a terminal; cut
118
+ * off, it is left running. Either way the error names the process, so the
119
+ * caller can read or stop what is still there. */
120
+ async #unfinished(error, processId, signal) {
121
+ if (!processId)
122
+ return error;
123
+ const id = `${this.id} ${processId}`;
124
+ if (signal?.aborted) {
125
+ const stopped = await new Process(this.#t, this.id, { id: processId })
126
+ .kill("SIGTERM")
127
+ .then(() => true, () => false);
128
+ return new RuntimeError({
129
+ message: stopped
130
+ ? `Cancelled; process ${processId} was sent SIGTERM.`
131
+ : `Cancelled, but process ${processId} may still be running: stopping it failed.`,
132
+ code: "cancelled",
133
+ status: 0,
134
+ hint: stopped
135
+ ? `See what it printed: runtime sandbox logs ${id}`
136
+ : `Stop it: runtime sandbox kill ${id}, or sandbox.processes.get("${processId}") and kill().`,
137
+ details: { sandboxId: this.id, processId, stopped },
138
+ cause: error,
139
+ });
140
+ }
141
+ if (error instanceof RuntimeError && !(error instanceof ConnectionError))
142
+ return error;
143
+ return new ConnectionError({
144
+ message: `Lost the output of process ${processId}; the command may still be running.`,
145
+ code: "connection_error",
146
+ status: 0,
147
+ hint: `Follow it: runtime sandbox logs ${id} -f (sandbox.processes.get("${processId}")), or stop it: runtime sandbox kill ${id}.`,
148
+ details: { sandboxId: this.id, processId },
149
+ cause: error,
150
+ });
151
+ }
111
152
  /** The command's events as they happen: start, stdout, stderr, exit. Resumes
112
- * by itself when the server ends a long stream, so it never drops output. */
153
+ * by itself when the server ends a long stream or the connection drops after
154
+ * the command started, and yields `truncated` for any output it did not
155
+ * receive, so lost output never passes as whole. */
113
156
  async *execStream(command, options = {}) {
114
157
  let processId;
115
- let cursor = 0;
158
+ const read = new OutputCursor();
116
159
  const events = this.#t.events({
117
160
  method: "POST",
118
161
  path: `/v1/sandboxes/${enc(this.id)}:exec`,
@@ -120,26 +163,36 @@ export class Sandbox {
120
163
  ...pick(options),
121
164
  timeoutMs: options.timeoutMs === undefined ? 86_400_000 : options.timeoutMs + 60_000,
122
165
  });
123
- for await (const event of events) {
124
- if (event.type === "start")
125
- processId = event.processId;
126
- if (event.type === "stdout" || event.type === "stderr")
127
- cursor = event.offset + Buffer.byteLength(event.data);
128
- if (event.type === "continue") {
129
- cursor = event.cursor;
130
- yield* this.processes.follow(event.processId, { cursor, signal: options.signal });
131
- return;
166
+ try {
167
+ for await (const event of events) {
168
+ if (event.type === "start")
169
+ processId = event.processId;
170
+ if (event.type === "continue") {
171
+ yield* this.processes.follow(event.processId, {
172
+ cursor: event.cursor,
173
+ signal: options.signal,
174
+ });
175
+ return;
176
+ }
177
+ yield* read.pass(event);
178
+ if (event.type === "exit")
179
+ return;
132
180
  }
133
- if (event.type === "error")
134
- throw new RuntimeError({
135
- message: event.error.message,
136
- code: event.error.code,
137
- status: 0,
138
- ...(event.error.requestId ? { requestId: event.error.requestId } : {}),
139
- });
140
- yield event;
141
181
  }
142
- void processId;
182
+ catch (error) {
183
+ if (options.signal?.aborted || !processId || !cutOff(error))
184
+ throw error;
185
+ }
186
+ // The connection closed after the command started: its output is kept on
187
+ // the sandbox, so follow it from where this stream stopped.
188
+ if (!processId)
189
+ throw new ConnectionError({
190
+ message: "The exec stream closed before the command started.",
191
+ code: "connection_error",
192
+ status: 0,
193
+ hint: "Run it again; to be sure it runs once, pass the same idempotencyKey.",
194
+ });
195
+ yield* this.processes.follow(processId, { cursor: read.cursor, signal: options.signal });
143
196
  }
144
197
  /** Starts a background process (a server, a watcher, a REPL) and returns at
145
198
  * once. `stdin: "pipe"` keeps its input open for write(); `pty` gives it a
@@ -207,7 +260,19 @@ export class Sandbox {
207
260
  /** Carries on a paused sandbox; `timeoutSeconds` is its new lease. */
208
261
  async wake(options = {}) {
209
262
  const { timeoutSeconds, ...rest } = options;
210
- return this.#lifecycle("wake", rest, timeoutSeconds === undefined ? {} : { timeoutSeconds });
263
+ try {
264
+ return await this.#lifecycle("wake", rest, timeoutSeconds === undefined ? {} : { timeoutSeconds });
265
+ }
266
+ catch (error) {
267
+ // Waking one that is already awake is done, not a mistake. With a new
268
+ // lease asked for, the refusal stands: it was not given.
269
+ if (!(error instanceof RuntimeError) || error.code !== "not_paused" || timeoutSeconds)
270
+ throw error;
271
+ await this.refresh(pick(rest));
272
+ if (this.state !== "running" && this.state !== "starting")
273
+ throw error;
274
+ return this;
275
+ }
211
276
  }
212
277
  /** More time before the lease ends (at most an hour ahead of now). */
213
278
  async extend(seconds, options = {}) {
@@ -347,6 +412,42 @@ export class Sandbox {
347
412
  * process's output buffer behind, which the server marks with a "truncated"
348
413
  * event. That event does not say which stream lost bytes, so both flags carry
349
414
  * it; without one, both are false, as a synchronous result's are. */
415
+ /** Where a reader of a process's output is, in bytes of stdout and stderr
416
+ * together. Output it did not receive, named by the server or not, becomes a
417
+ * `truncated` event: the next chunk starting past the cursor means the bytes
418
+ * between were lost. A server error event becomes a thrown error. */
419
+ class OutputCursor {
420
+ cursor;
421
+ constructor(cursor = 0) {
422
+ this.cursor = cursor;
423
+ }
424
+ *pass(event) {
425
+ if (event.type === "error")
426
+ throw new RuntimeError({
427
+ message: event.error.message,
428
+ code: event.error.code,
429
+ status: 0,
430
+ ...(event.error.requestId ? { requestId: event.error.requestId } : {}),
431
+ });
432
+ if (event.type === "truncated")
433
+ this.cursor = Math.max(this.cursor, event.resumeAt);
434
+ if (event.type === "stdout" || event.type === "stderr") {
435
+ if (event.offset > this.cursor)
436
+ yield {
437
+ type: "truncated",
438
+ droppedBytes: event.offset - this.cursor,
439
+ resumeAt: event.offset,
440
+ };
441
+ this.cursor = Math.max(this.cursor, event.offset + Buffer.byteLength(event.data));
442
+ }
443
+ yield event;
444
+ }
445
+ }
446
+ /** A stream cut off by the network rather than refused by Runtime: worth
447
+ * reconnecting to from the cursor. */
448
+ function cutOff(error) {
449
+ return !(error instanceof RuntimeError) || error instanceof ConnectionError;
450
+ }
350
451
  function truncation(dropped) {
351
452
  return { stdoutTruncated: dropped, stderrTruncated: dropped };
352
453
  }
@@ -379,35 +480,44 @@ export class Processes {
379
480
  }
380
481
  /** Output events from `cursor` until the process exits, across stream slices. */
381
482
  async *follow(processId, options = {}) {
382
- let cursor = options.cursor ?? 0;
483
+ const read = new OutputCursor(options.cursor ?? 0);
484
+ // Reconnections in a row that brought nothing: a stream that keeps
485
+ // closing without output or an exit is given up, not followed forever.
486
+ let idle = 0;
383
487
  for (;;) {
488
+ const before = read.cursor;
384
489
  let resumed = false;
385
- for await (const event of this.t.events({
386
- method: "GET",
387
- path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(processId)}/output`,
388
- query: { cursor, follow: true },
389
- timeoutMs: 180_000,
390
- ...(options.signal ? { signal: options.signal } : {}),
391
- })) {
392
- if (event.type === "continue") {
393
- cursor = event.cursor;
394
- resumed = true;
395
- break;
490
+ try {
491
+ for await (const event of this.t.events({
492
+ method: "GET",
493
+ path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(processId)}/output`,
494
+ query: { cursor: read.cursor, follow: true },
495
+ timeoutMs: 180_000,
496
+ ...(options.signal ? { signal: options.signal } : {}),
497
+ })) {
498
+ if (event.type === "continue") {
499
+ read.cursor = Math.max(read.cursor, event.cursor);
500
+ resumed = true;
501
+ break;
502
+ }
503
+ yield* read.pass(event);
504
+ if (event.type === "exit")
505
+ return;
396
506
  }
397
- if (event.type === "stdout" || event.type === "stderr")
398
- cursor = event.offset + Buffer.byteLength(event.data);
399
- if (event.type === "error")
400
- throw new RuntimeError({
401
- message: event.error.message,
402
- code: event.error.code,
403
- status: 0,
404
- });
405
- yield event;
406
- if (event.type === "exit")
407
- return;
408
507
  }
409
- if (!resumed)
410
- return;
508
+ catch (error) {
509
+ if (options.signal?.aborted || !cutOff(error))
510
+ throw error;
511
+ }
512
+ idle = resumed || read.cursor > before ? 0 : idle + 1;
513
+ if (idle > 3)
514
+ throw new ConnectionError({
515
+ message: `The output stream of process ${processId} keeps closing before it ends.`,
516
+ code: "connection_error",
517
+ status: 0,
518
+ hint: `Read what it printed so far: runtime sandbox logs ${this.sandboxId} ${processId}`,
519
+ details: { sandboxId: this.sandboxId, processId },
520
+ });
411
521
  }
412
522
  }
413
523
  }
@@ -576,9 +686,28 @@ export class Files {
576
686
  get watches() {
577
687
  return sandboxWatches(this.t, this.sandboxId);
578
688
  }
579
- /** A file's bytes, any size. */
689
+ /** A file's bytes, any size, checked against the length (and for a small
690
+ * file the SHA-256) the API sends first: a read that arrives short is read
691
+ * again, then throws `download_incomplete`, never returns short. For a file
692
+ * too big to hold in memory use `readStream` or `download`. */
580
693
  async read(path, options = {}) {
581
- return this.t.bytes({
694
+ return this.t.fileBytes({
695
+ method: "GET",
696
+ path: this.#path("/files/content"),
697
+ query: { path },
698
+ accept: "application/octet-stream",
699
+ ...options,
700
+ });
701
+ }
702
+ /** A file's bytes as they arrive, for files of any size without holding
703
+ * them in memory (Daytona's `downloadFileStream`). The stream errors with
704
+ * `download_incomplete` if it ends short of the file's length.
705
+ *
706
+ * const stream = await sbx.files.readStream("/workspace/big.tar");
707
+ * await stream.pipeTo(Writable.toWeb(fs.createWriteStream("big.tar")));
708
+ */
709
+ async readStream(path, options = {}) {
710
+ return this.t.fileStream({
582
711
  method: "GET",
583
712
  path: this.#path("/files/content"),
584
713
  query: { path },
@@ -746,7 +875,16 @@ export class Files {
746
875
  const info = await stat(localPath);
747
876
  if (info.isFile()) {
748
877
  // Its permissions travel with it: an uploaded script stays runnable.
749
- await this.write(remotePath, await readFile(localPath), { mode: info.mode & 0o777 });
878
+ const data = await readFile(localPath);
879
+ const mode = info.mode & 0o777;
880
+ await this.write(remotePath, data, { mode }).catch(async (error) => {
881
+ // A missing parent is made, as a directory upload makes its own.
882
+ const parent = remotePath.slice(0, remotePath.lastIndexOf("/"));
883
+ if (!(error instanceof NotFoundError) || !parent)
884
+ throw error;
885
+ await this.mkdir(parent, { parents: true });
886
+ await this.write(remotePath, data, { mode });
887
+ });
750
888
  return;
751
889
  }
752
890
  const { packDirectory } = await import("./tar.js");
@@ -766,7 +904,40 @@ export class Files {
766
904
  if (result.exitCode !== 0)
767
905
  throw new CommandError(result);
768
906
  }
769
- /** Copies a file or directory out of the sandbox. */
907
+ /** Streams a file to disk through a partial file beside the target, renamed
908
+ * into place only once every byte has arrived: a failed copy never leaves
909
+ * a short file under the name asked for. A short stream is tried again,
910
+ * twice, as a short `read` is. */
911
+ async #streamTo(remotePath, localPath) {
912
+ const fs = await import("node:fs/promises");
913
+ const partial = `${localPath}.runtime-partial-${crypto.randomUUID().slice(0, 8)}`;
914
+ for (let attempt = 0;; attempt++) {
915
+ try {
916
+ const stream = await this.readStream(remotePath);
917
+ // The handle is closed before anything else touches the file, so a
918
+ // failed copy's partial file is really gone when the error returns.
919
+ const file = await fs.open(partial, "w");
920
+ try {
921
+ for await (const chunk of stream)
922
+ await file.write(chunk);
923
+ }
924
+ finally {
925
+ await file.close();
926
+ }
927
+ await fs.rename(partial, localPath);
928
+ return;
929
+ }
930
+ catch (error) {
931
+ await fs.rm(partial, { force: true });
932
+ if (attempt >= 2 ||
933
+ !(error instanceof RuntimeError) ||
934
+ error.code !== "download_incomplete")
935
+ throw error;
936
+ }
937
+ }
938
+ }
939
+ /** Copies a file or directory out of the sandbox. A file streams to disk,
940
+ * any size, checked against its length. */
770
941
  async download(remotePath, localPath) {
771
942
  const fs = await import("node:fs/promises");
772
943
  const paths = await import("node:path");
@@ -779,7 +950,7 @@ export class Files {
779
950
  });
780
951
  if (entry.type !== "directory") {
781
952
  await fs.mkdir(paths.dirname(localPath), { recursive: true });
782
- await fs.writeFile(localPath, await this.read(remotePath));
953
+ await this.#streamTo(remotePath, localPath);
783
954
  return;
784
955
  }
785
956
  const staging = `/tmp/.runtime-download-${crypto.randomUUID()}.tar.gz`;
@@ -1,5 +1,5 @@
1
1
  import { RuntimeError } from "./errors.js";
2
- export declare const VERSION = "0.6.0";
2
+ export declare const VERSION = "0.7.0";
3
3
  export declare const DEFAULT_BASE_URL = "https://api.withruntime.com";
4
4
  export type RequestOptions = {
5
5
  /** Sent as Idempotency-Key. Omit it: the SDK makes one per call and keeps
@@ -30,6 +30,9 @@ export type Call = RequestOptions & {
30
30
  * full trial, a full quota, a full region. Then the refusal is thrown as
31
31
  * it came. Only a create sets it; 0 or absent fails at once. */
32
32
  waitForCapacityMs?: number;
33
+ /** Called each time a call waits for room, with the refusal it is waiting
34
+ * out and how long it will sleep before sending the call again. */
35
+ onCapacityWait?: (refusal: RuntimeError, waitMs: number) => void;
33
36
  };
34
37
  export declare function apiOrigin(value: string): string;
35
38
  export declare function encodeQuery(query: Query | undefined): string;
@@ -66,10 +69,23 @@ export declare class Transport {
66
69
  send(call: Call): Promise<Response>;
67
70
  json<T>(call: Call): Promise<T>;
68
71
  bytes(call: Call): Promise<Uint8Array>;
72
+ /** A file's bytes, read whole and checked: the API says a body's length
73
+ * (x-content-length) and, when small, its SHA-256 (x-content-sha256) before
74
+ * sending it, and a body that falls short or differs is read again, twice,
75
+ * then refused. Nothing between the API and here reliably turns a stream
76
+ * cut part way into an error: on 25 September 2026 a 50 MB file came back
77
+ * 29 MB long, exit 0. */
78
+ fileBytes(call: Call): Promise<Uint8Array>;
79
+ /** A file's bytes as they arrive, for files too big to hold. The stream
80
+ * errors with `download_incomplete` if it ends short of the length the API
81
+ * promised; it is never retried part way. */
82
+ fileStream(call: Call): Promise<ReadableStream<Uint8Array>>;
69
83
  /** Newline-delimited JSON events, as they arrive. */
70
84
  events<T>(call: Call): AsyncGenerator<T>;
71
85
  /** A WebSocket to an API path, authenticated with the same key. */
72
86
  websocket(path: string, query?: Query): Promise<WebSocket>;
73
87
  }
88
+ /** A download that ended short of (or past) the length the API promised. */
89
+ export declare function incompleteDownload(got: number, expected: number): RuntimeError;
74
90
  /** No key given, none in RUNTIME_API_KEY, and none saved by `runtime login`. */
75
91
  export declare function missingKey(): RuntimeError;