withruntime 0.5.0 → 0.6.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 (56) hide show
  1. package/DAYTONA.md +25 -25
  2. package/E2B.md +1 -1
  3. package/LICENSE +55 -0
  4. package/README.md +3 -2
  5. package/dist/cli-extras.d.ts +24 -0
  6. package/dist/cli-extras.js +205 -0
  7. package/dist/cli.d.ts +20 -0
  8. package/dist/cli.js +430 -37
  9. package/dist/daytona/context.d.ts +5 -0
  10. package/dist/daytona/context.js +3 -0
  11. package/dist/daytona/filesystem.js +2 -2
  12. package/dist/daytona/process.js +3 -1
  13. package/dist/e2b/commands.js +6 -2
  14. package/dist/e2b/filesystem.d.ts +26 -1
  15. package/dist/e2b/filesystem.js +30 -2
  16. package/dist/e2b/index.d.ts +1 -1
  17. package/dist/e2b/index.js +1 -1
  18. package/dist/errors.js +2 -0
  19. package/dist/identity.d.ts +27 -0
  20. package/dist/identity.js +56 -0
  21. package/dist/index.d.ts +14 -0
  22. package/dist/index.js +7 -0
  23. package/dist/mcp.js +16 -3
  24. package/dist/network-cli.d.ts +15 -0
  25. package/dist/network-cli.js +323 -0
  26. package/dist/products/desktop.d.ts +41 -4
  27. package/dist/products/desktop.js +73 -3
  28. package/dist/products/index.d.ts +12 -0
  29. package/dist/products/index.js +12 -0
  30. package/dist/products/interpreter.d.ts +5 -3
  31. package/dist/products/interpreter.js +2 -1
  32. package/dist/products/mcp.d.ts +79 -0
  33. package/dist/products/mcp.js +37 -0
  34. package/dist/products/mounts.d.ts +49 -0
  35. package/dist/products/mounts.js +21 -0
  36. package/dist/products/network-products.d.ts +167 -0
  37. package/dist/products/network-products.js +82 -0
  38. package/dist/products/network.d.ts +1 -1
  39. package/dist/products/sso.d.ts +44 -0
  40. package/dist/products/sso.js +8 -0
  41. package/dist/products/switching.d.ts +7 -0
  42. package/dist/products/volumes.d.ts +68 -5
  43. package/dist/products/volumes.js +52 -4
  44. package/dist/products/watch.d.ts +91 -0
  45. package/dist/products/watch.js +135 -0
  46. package/dist/sandbox.d.ts +32 -2
  47. package/dist/sandbox.js +93 -16
  48. package/dist/snapshots.d.ts +11 -1
  49. package/dist/ssh.js +13 -1
  50. package/dist/transport.d.ts +1 -1
  51. package/dist/transport.js +4 -1
  52. package/dist/tunnel.d.ts +4 -1
  53. package/dist/tunnel.js +8 -3
  54. package/dist/wireguard.d.ts +7 -0
  55. package/dist/wireguard.js +13 -0
  56. package/package.json +7 -3
@@ -1,8 +1,10 @@
1
1
  import { Page } from "../page.js";
2
+ const enc = encodeURIComponent;
2
3
  /** Persistent disks: create once, attach when creating a sandbox with
3
- * `volumes: [{ volumeId: v.id, path: "/data" }]`. Not backed up off its host. */
4
+ * `volumes: [{ volumeId: v.id, path: "/data" }]`. Backed up off their host
5
+ * daily unless that is turned off; restore one with `create({ fromBackup })`. */
4
6
  export function volumes(t) {
5
- const get = (id, options) => t.json({ method: "GET", path: `/v1/volumes/${encodeURIComponent(id)}`, ...options });
7
+ const get = (id, options) => t.json({ method: "GET", path: `/v1/volumes/${enc(id)}`, ...options });
6
8
  const list = async (query = {}, options) => {
7
9
  const page = await t.json({
8
10
  method: "GET",
@@ -12,16 +14,62 @@ export function volumes(t) {
12
14
  });
13
15
  return new Page(page.data, page.nextCursor, (cursor) => list({ ...query, cursor }, options));
14
16
  };
17
+ const backups = async (query = {}, options) => {
18
+ const page = await t.json({
19
+ method: "GET",
20
+ path: "/v1/volume-backups",
21
+ query,
22
+ ...options,
23
+ });
24
+ return new Page(page.data, page.nextCursor, (cursor) => backups({ ...query, cursor }, options));
25
+ };
15
26
  return {
16
- /** Create and wait (up to 10 s) until it is ready. */
27
+ /** Create and wait (up to 10 s) until it is ready. With `fromBackup`, a
28
+ * restore; it is ready once the backup is downloaded and checked. */
17
29
  create: (input, options) => t.json({ method: "POST", path: "/v1/volumes", body: input, wait: 10, ...options }),
18
30
  get,
19
31
  list,
20
32
  delete: (id, options) => t.json({
21
33
  method: "POST",
22
- path: `/v1/volumes/${encodeURIComponent(id)}:delete`,
34
+ path: `/v1/volumes/${enc(id)}:delete`,
23
35
  body: {},
24
36
  ...options,
25
37
  }),
38
+ /** Back a volume up now, off its host, and wait (up to a minute, or
39
+ * `wait` seconds) for it to be ready. */
40
+ backup: (id, input = {}, options = {}) => {
41
+ const { wait = 60, ...rest } = options;
42
+ return t.json({
43
+ method: "POST",
44
+ path: `/v1/volumes/${enc(id)}:backup`,
45
+ body: input,
46
+ wait,
47
+ ...rest,
48
+ });
49
+ },
50
+ /** Daily backups on or off, and how many days each is kept. */
51
+ setBackupPolicy: (id, input, options) => t.json({
52
+ method: "POST",
53
+ path: `/v1/volumes/${enc(id)}:backup-policy`,
54
+ body: input,
55
+ ...options,
56
+ }),
57
+ /** Backups, newest first; `{ volumeId }` for one volume's. */
58
+ backups,
59
+ getBackup: (id, options) => t.json({ method: "GET", path: `/v1/volume-backups/${enc(id)}`, ...options }),
60
+ deleteBackup: (id, options) => t.json({
61
+ method: "POST",
62
+ path: `/v1/volume-backups/${enc(id)}:delete`,
63
+ body: {},
64
+ ...options,
65
+ }),
66
+ /** A new volume from a backup: `create({ fromBackup: id })`. */
67
+ restore: (backupId, input = {}, options) => t.json({
68
+ method: "POST",
69
+ path: "/v1/volumes",
70
+ body: { ...input, fromBackup: backupId },
71
+ wait: 10,
72
+ ...options,
73
+ }),
26
74
  };
27
75
  }
@@ -0,0 +1,91 @@
1
+ import type { RequestOptions, Transport } from "../transport.js";
2
+ export type FileEventType = "create" | "write" | "remove" | "rename" | "chmod";
3
+ export type FileEvent = {
4
+ type: FileEventType;
5
+ /** Absolute path in the sandbox. */
6
+ path: string;
7
+ isDir: boolean;
8
+ /** rename: where it was. */
9
+ oldPath?: string;
10
+ /** write: how many writes were folded into this one event. */
11
+ count?: number;
12
+ };
13
+ export type WatchNotice = {
14
+ k: "overflow";
15
+ dropped: number | null;
16
+ reason: "kernel" | "rate";
17
+ } | {
18
+ k: "limit";
19
+ watches: number;
20
+ } | {
21
+ k: "lost";
22
+ bytes: number;
23
+ };
24
+ export type WatchOptions = Omit<RequestOptions, "timeoutMs"> & {
25
+ recursive?: boolean;
26
+ /** Only these event types; all five by default. */
27
+ events?: FileEventType[];
28
+ /** Globs relative to the path: only matching paths are reported. */
29
+ include?: string[];
30
+ /** Globs relative to the path. An excluded directory is not watched at all. */
31
+ exclude?: string[];
32
+ /** Events are gathered this long and delivered together. Default 100. */
33
+ batchMs?: number;
34
+ /** The watch ends by itself after running this long. Default 1 hour. */
35
+ timeoutMs?: number;
36
+ maxWatches?: number;
37
+ /** A batch at a time, rather than one call per event. */
38
+ onBatch?: (events: FileEvent[]) => void | Promise<void>;
39
+ /** Events were lost (a flood past the rate cap, the kernel's queue, or a
40
+ * reader that fell more than 1 MiB behind): rescan what you care about. */
41
+ onNotice?: (notice: WatchNotice) => void;
42
+ /** The watch stopped: "stopped", "timeout", "root-removed", or "paused"
43
+ * (the sandbox paused; call `resume()` once it runs again: nothing is lost). */
44
+ onExit?: (reason: string) => void;
45
+ };
46
+ export type WatchInfo = {
47
+ id: string;
48
+ path: string;
49
+ processId: string;
50
+ state: string;
51
+ startedAt: number;
52
+ };
53
+ /** A running watch. Events arrive through the callbacks until `stop()`. */
54
+ export declare class WatchHandle {
55
+ #private;
56
+ private readonly t;
57
+ private readonly base;
58
+ readonly id: string;
59
+ readonly path: string;
60
+ private readonly options;
61
+ private readonly onEvent?;
62
+ cursor: number;
63
+ constructor(t: Transport, base: string, id: string, path: string, cursor: number, options: WatchOptions, onEvent?: ((event: FileEvent) => void | Promise<void>) | undefined);
64
+ /** Settles when the watch stops delivering (stopped, ended or paused). */
65
+ get done(): Promise<void>;
66
+ /** Start delivering again from where it left off, after a pause. */
67
+ resume(): void;
68
+ /** Stop the watch in the sandbox, and stop delivering. */
69
+ stop(options?: RequestOptions): Promise<void>;
70
+ /** @internal */
71
+ begin(): this;
72
+ }
73
+ /** `sbx.files.watch(path, onEvent, options)`: E2B's `watchDir`, with
74
+ * filters, batches, and a watch that survives a pause without losing events. */
75
+ export declare function watchDirectory(t: Transport, sandboxId: string, path: string, onEvent?: (event: FileEvent) => void | Promise<void>, options?: WatchOptions): Promise<WatchHandle>;
76
+ export declare function sandboxWatches(t: Transport, sandboxId: string): {
77
+ list: (options?: RequestOptions) => Promise<WatchInfo[]>;
78
+ /** Events after a cursor, without a stream: for polling. */
79
+ read: (id: string, cursor?: number, input?: {
80
+ waitMs?: number;
81
+ }, options?: RequestOptions) => Promise<{
82
+ events: FileEvent[];
83
+ notices: Record<string, unknown>[];
84
+ nextCursor: number;
85
+ lostBytes: number;
86
+ ended: boolean;
87
+ }>;
88
+ stop: (id: string, options?: RequestOptions) => Promise<{
89
+ stopped: boolean;
90
+ }>;
91
+ };
@@ -0,0 +1,135 @@
1
+ /** A running watch. Events arrive through the callbacks until `stop()`. */
2
+ export class WatchHandle {
3
+ t;
4
+ base;
5
+ id;
6
+ path;
7
+ options;
8
+ onEvent;
9
+ cursor;
10
+ #stopped = false;
11
+ #following;
12
+ #abort = new AbortController();
13
+ constructor(t, base, id, path, cursor, options, onEvent) {
14
+ this.t = t;
15
+ this.base = base;
16
+ this.id = id;
17
+ this.path = path;
18
+ this.options = options;
19
+ this.onEvent = onEvent;
20
+ this.cursor = cursor;
21
+ }
22
+ /** Settles when the watch stops delivering (stopped, ended or paused). */
23
+ get done() {
24
+ return this.#following ?? Promise.resolve();
25
+ }
26
+ /** Start delivering again from where it left off, after a pause. */
27
+ resume() {
28
+ if (this.#stopped)
29
+ throw new Error("This watch was stopped");
30
+ this.#abort = new AbortController();
31
+ this.begin();
32
+ }
33
+ /** Stop the watch in the sandbox, and stop delivering. */
34
+ async stop(options) {
35
+ if (this.#stopped)
36
+ return;
37
+ this.#stopped = true;
38
+ this.#abort.abort();
39
+ await this.t
40
+ .json({ method: "DELETE", path: `${this.base}/${encodeURIComponent(this.id)}`, ...options })
41
+ .catch(() => undefined);
42
+ await this.#following?.catch(() => undefined);
43
+ this.options.onExit?.("stopped");
44
+ }
45
+ async #follow() {
46
+ for (;;) {
47
+ let next = "exit";
48
+ try {
49
+ for await (const event of this.t.events({
50
+ method: "GET",
51
+ path: `${this.base}/${encodeURIComponent(this.id)}/events`,
52
+ query: { cursor: this.cursor, follow: true },
53
+ signal: this.#abort.signal,
54
+ timeoutMs: 150_000,
55
+ })) {
56
+ this.cursor = event.cursor ?? this.cursor;
57
+ if (event.k === "events") {
58
+ if (this.options.onBatch)
59
+ await this.options.onBatch(event.events);
60
+ if (this.onEvent)
61
+ for (const item of event.events)
62
+ await this.onEvent(item);
63
+ }
64
+ else if (event.k === "continue")
65
+ next = "continue";
66
+ else if (event.k === "end" || event.k === "paused") {
67
+ if (!this.#stopped)
68
+ this.options.onExit?.(event.k === "paused" ? "paused" : event.reason);
69
+ return;
70
+ }
71
+ else if (event.k === "failure")
72
+ throw new Error(`${event.code}: ${event.message}`);
73
+ else
74
+ this.options.onNotice?.(event);
75
+ }
76
+ }
77
+ catch (error) {
78
+ if (this.#stopped || this.#abort.signal.aborted)
79
+ return;
80
+ throw error;
81
+ }
82
+ if (next !== "continue" || this.#stopped)
83
+ return;
84
+ }
85
+ }
86
+ /** @internal */
87
+ begin() {
88
+ this.#following = this.#follow();
89
+ // An error surfaces through `done`; nothing is left unhandled meanwhile.
90
+ this.#following.catch(() => undefined);
91
+ return this;
92
+ }
93
+ }
94
+ /** `sbx.files.watch(path, onEvent, options)`: E2B's `watchDir`, with
95
+ * filters, batches, and a watch that survives a pause without losing events. */
96
+ export function watchDirectory(t, sandboxId, path, onEvent, options = {}) {
97
+ const base = `/v1/sandboxes/${encodeURIComponent(sandboxId)}/files/watches`;
98
+ const { recursive, events, include, exclude, batchMs, timeoutMs, maxWatches, idempotencyKey, signal, } = options;
99
+ return t
100
+ .json({
101
+ method: "POST",
102
+ path: base,
103
+ body: {
104
+ path,
105
+ ...(recursive !== undefined ? { recursive } : {}),
106
+ ...(events ? { events } : {}),
107
+ ...(include ? { include } : {}),
108
+ ...(exclude ? { exclude } : {}),
109
+ ...(batchMs !== undefined ? { batchMs } : {}),
110
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
111
+ ...(maxWatches !== undefined ? { maxWatches } : {}),
112
+ },
113
+ ...(idempotencyKey ? { idempotencyKey } : {}),
114
+ ...(signal ? { signal } : {}),
115
+ })
116
+ .then((watch) => new WatchHandle(t, base, watch.id, watch.path, watch.cursor ?? 0, options, onEvent).begin());
117
+ }
118
+ export function sandboxWatches(t, sandboxId) {
119
+ const base = `/v1/sandboxes/${encodeURIComponent(sandboxId)}/files/watches`;
120
+ return {
121
+ list: async (options) => (await t.json({ method: "GET", path: base, ...options })).data,
122
+ /** Events after a cursor, without a stream: for polling. */
123
+ read: (id, cursor = 0, input = {}, options) => t.json({
124
+ method: "GET",
125
+ path: `${base}/${encodeURIComponent(id)}/events`,
126
+ query: { cursor, ...(input.waitMs !== undefined ? { waitMs: input.waitMs } : {}) },
127
+ ...options,
128
+ }),
129
+ stop: (id, options) => t.json({
130
+ method: "DELETE",
131
+ path: `${base}/${encodeURIComponent(id)}`,
132
+ ...options,
133
+ }),
134
+ };
135
+ }
package/dist/sandbox.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Page } from "./page.js";
2
+ import { type FileEvent, type WatchHandle, type WatchOptions } from "./products/watch.js";
2
3
  import type { RequestOptions, Transport } from "./transport.js";
3
4
  import type { CommandResult, ExecOptions, FileEntry, OutputEvent, KeepAliveOptions, ProcessInfo, SandboxInfo, SandboxSettings } from "./types.js";
4
5
  import { type SandboxExtensions } from "./products/index.js";
@@ -175,12 +176,41 @@ export declare class Files {
175
176
  private readonly t;
176
177
  private readonly sandboxId;
177
178
  constructor(t: Transport, sandboxId: string);
179
+ /** Watch a directory: `onEvent` gets create, write, remove, rename and
180
+ * chmod events until `handle.stop()`. E2B's `watchDir`, plus filters,
181
+ * batches, and a watch that loses nothing across a pause:
182
+ *
183
+ * const watch = await sbx.files.watch("/workspace", (e) => console.log(e.type, e.path),
184
+ * { recursive: true, exclude: ["node_modules"] });
185
+ * ...
186
+ * await watch.stop();
187
+ */
188
+ watch(path: string, onEvent?: (event: FileEvent) => void | Promise<void>, options?: WatchOptions): Promise<WatchHandle>;
189
+ /** The watches running in the sandbox, and polling reads by cursor. */
190
+ get watches(): {
191
+ list: (options?: RequestOptions) => Promise<import("./products/watch.js").WatchInfo[]>;
192
+ read: (id: string, cursor?: number, input?: {
193
+ waitMs?: number;
194
+ }, options?: RequestOptions) => Promise<{
195
+ events: FileEvent[];
196
+ notices: Record<string, unknown>[];
197
+ nextCursor: number;
198
+ lostBytes: number;
199
+ ended: boolean;
200
+ }>;
201
+ stop: (id: string, options?: RequestOptions) => Promise<{
202
+ stopped: boolean;
203
+ }>;
204
+ };
178
205
  /** A file's bytes, any size. */
179
206
  read(path: string, options?: RequestOptions): Promise<Uint8Array>;
180
207
  readText(path: string, options?: RequestOptions): Promise<string>;
181
208
  /** Writes a file of any size, atomically, making parent directories. Large
182
- * ones go in parallel 1 MiB chunks checked against their SHA-256. */
183
- write(path: string, data: string | Uint8Array, options?: RequestOptions): Promise<{
209
+ * ones go in parallel 1 MiB chunks checked against their SHA-256. `mode`
210
+ * sets its permissions (0o755 for a program); 0o644 when left out. */
211
+ write(path: string, data: string | Uint8Array, writeOptions?: RequestOptions & {
212
+ mode?: number;
213
+ }): Promise<{
184
214
  path: string;
185
215
  size: number;
186
216
  }>;
package/dist/sandbox.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { CommandError, RuntimeError } from "./errors.js";
2
2
  import { Page } from "./page.js";
3
+ import { sandboxWatches, watchDirectory, } from "./products/watch.js";
3
4
  import { sandboxFactories } from "./products/index.js";
4
5
  import { Tunnel } from "./tunnel.js";
5
6
  const CHUNK = 1_048_576;
@@ -274,6 +275,13 @@ export class Sandbox {
274
275
  async snapshot(options = {}) {
275
276
  const { idempotencyKey, signal, timeoutMs, ...body } = options;
276
277
  await this.refresh();
278
+ // Straight after a fork or a wake the sandbox is still `resuming`, and
279
+ // after a pause still `pausing`: wait for where it is going, or a
280
+ // snapshot of it is refused as not paused (user lane, 23 September 2026).
281
+ if (this.state === "resuming" || this.state === "starting")
282
+ await this.waitFor("running");
283
+ else if (this.state === "pausing")
284
+ await this.waitFor("paused");
277
285
  const running = this.state === "running";
278
286
  if (running)
279
287
  await this.pause();
@@ -552,6 +560,22 @@ export class Files {
552
560
  this.sandboxId = sandboxId;
553
561
  }
554
562
  #path = (suffix) => `/v1/sandboxes/${enc(this.sandboxId)}${suffix}`;
563
+ /** Watch a directory: `onEvent` gets create, write, remove, rename and
564
+ * chmod events until `handle.stop()`. E2B's `watchDir`, plus filters,
565
+ * batches, and a watch that loses nothing across a pause:
566
+ *
567
+ * const watch = await sbx.files.watch("/workspace", (e) => console.log(e.type, e.path),
568
+ * { recursive: true, exclude: ["node_modules"] });
569
+ * ...
570
+ * await watch.stop();
571
+ */
572
+ watch(path, onEvent, options) {
573
+ return watchDirectory(this.t, this.sandboxId, path, onEvent, options);
574
+ }
575
+ /** The watches running in the sandbox, and polling reads by cursor. */
576
+ get watches() {
577
+ return sandboxWatches(this.t, this.sandboxId);
578
+ }
555
579
  /** A file's bytes, any size. */
556
580
  async read(path, options = {}) {
557
581
  return this.t.bytes({
@@ -566,8 +590,11 @@ export class Files {
566
590
  return new TextDecoder().decode(await this.read(path, options));
567
591
  }
568
592
  /** Writes a file of any size, atomically, making parent directories. Large
569
- * ones go in parallel 1 MiB chunks checked against their SHA-256. */
570
- async write(path, data, options = {}) {
593
+ * ones go in parallel 1 MiB chunks checked against their SHA-256. `mode`
594
+ * sets its permissions (0o755 for a program); 0o644 when left out. */
595
+ async write(path, data, writeOptions = {}) {
596
+ const { mode, ...options } = writeOptions;
597
+ const octal = mode === undefined ? undefined : (mode & 0o777).toString(8).padStart(3, "0");
571
598
  const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
572
599
  if (bytes.length <= CHUNK) {
573
600
  // bytes(), not send(): the answer is read, so its connection is free
@@ -575,7 +602,7 @@ export class Files {
575
602
  await this.t.bytes({
576
603
  method: "PUT",
577
604
  path: this.#path("/files/content"),
578
- query: { path },
605
+ query: { path, ...(octal ? { mode: octal } : {}) },
579
606
  bytes,
580
607
  ...options,
581
608
  });
@@ -583,12 +610,55 @@ export class Files {
583
610
  }
584
611
  const { createHash } = await import("node:crypto");
585
612
  const sha256 = createHash("sha256").update(bytes).digest("hex");
586
- const begin = await this.t.json({
587
- method: "POST",
588
- path: this.#path("/uploads"),
589
- body: { path, size: bytes.length, sha256 },
613
+ // A guest journals mutations across operations, not per URL. One logical
614
+ // write therefore owns separate stable keys for each mutation phase.
615
+ const uploadKey = options.idempotencyKey ?? crypto.randomUUID();
616
+ const phaseOptions = (phase) => ({
590
617
  ...options,
618
+ idempotencyKey: createHash("sha256")
619
+ .update(JSON.stringify(["files.write", uploadKey, phase]))
620
+ .digest("hex"),
621
+ });
622
+ let begin;
623
+ try {
624
+ begin = await this.t.json({
625
+ method: "POST",
626
+ path: this.#path("/uploads"),
627
+ body: { path, size: bytes.length, sha256, ...(octal ? { mode: octal } : {}) },
628
+ ...phaseOptions("begin"),
629
+ });
630
+ }
631
+ catch (error) {
632
+ if (!octal || !(error instanceof RuntimeError) || error.code !== "guest_upgrade_required")
633
+ throw error;
634
+ // The API aborted the unsupported transfer before accepting chunks.
635
+ // A different body needs a different key; retain deterministic retries.
636
+ begin = await this.t.json({
637
+ method: "POST",
638
+ path: this.#path("/uploads"),
639
+ body: { path, size: bytes.length, sha256 },
640
+ ...phaseOptions("legacy-begin"),
641
+ });
642
+ }
643
+ const commit = () => this.t.json({
644
+ method: "POST",
645
+ path: this.#path(`/uploads/${enc(begin.uploadId)}:commit`),
646
+ body: {},
647
+ ...phaseOptions("commit"),
591
648
  });
649
+ let committed = false;
650
+ if (begin.replayed) {
651
+ // A completed commit removed the transfer. Ask its journal before
652
+ // resending chunks; an incomplete transfer alone is safe to continue.
653
+ try {
654
+ await commit();
655
+ committed = true;
656
+ }
657
+ catch (error) {
658
+ if (!(error instanceof RuntimeError) || error.code !== "upload_incomplete")
659
+ throw error;
660
+ }
661
+ }
592
662
  const offsets = [];
593
663
  for (let offset = 0; offset < bytes.length; offset += begin.chunkBytes)
594
664
  offsets.push(offset);
@@ -605,13 +675,18 @@ export class Files {
605
675
  }
606
676
  };
607
677
  try {
608
- await Promise.all(Array.from({ length: Math.min(PARALLEL, offsets.length) }, worker));
609
- await this.t.json({
610
- method: "POST",
611
- path: this.#path(`/uploads/${enc(begin.uploadId)}:commit`),
612
- body: {},
613
- ...options,
614
- });
678
+ if (!committed) {
679
+ await Promise.all(Array.from({ length: Math.min(PARALLEL, offsets.length) }, worker));
680
+ await commit();
681
+ }
682
+ // An older sandbox image ignores the mode in the upload; set it after.
683
+ if (octal && begin.mode !== octal)
684
+ await this.t.json({
685
+ method: "POST",
686
+ path: this.#path("/files:chmod"),
687
+ body: { path, mode: octal },
688
+ ...phaseOptions("chmod"),
689
+ });
615
690
  }
616
691
  catch (error) {
617
692
  await this.t
@@ -619,6 +694,7 @@ export class Files {
619
694
  method: "POST",
620
695
  path: this.#path(`/uploads/${enc(begin.uploadId)}:abort`),
621
696
  body: {},
697
+ ...phaseOptions("abort"),
622
698
  })
623
699
  .catch(() => undefined);
624
700
  throw error;
@@ -669,7 +745,8 @@ export class Files {
669
745
  const { stat, readFile } = await import("node:fs/promises");
670
746
  const info = await stat(localPath);
671
747
  if (info.isFile()) {
672
- await this.write(remotePath, await readFile(localPath));
748
+ // Its permissions travel with it: an uploaded script stays runnable.
749
+ await this.write(remotePath, await readFile(localPath), { mode: info.mode & 0o777 });
673
750
  return;
674
751
  }
675
752
  const { packDirectory } = await import("./tar.js");
@@ -681,7 +758,7 @@ export class Files {
681
758
  const result = await new Sandbox(this.t, { id: this.sandboxId }).exec([
682
759
  "sh",
683
760
  "-c",
684
- 'mkdir -p "$1" && tar -xzf "$2" -C "$1"; code=$?; rm -f "$2"; exit $code',
761
+ 'mkdir -p "$1" && tar -xpzf "$2" -C "$1"; code=$?; rm -f "$2"; exit $code',
685
762
  "sh",
686
763
  remotePath,
687
764
  staging,
@@ -1,7 +1,8 @@
1
1
  import { Page } from "./page.js";
2
2
  import type { RequestOptions, Transport } from "./transport.js";
3
3
  /** A sandbox's whole machine (files, memory, running processes), kept on its
4
- * host to start new sandboxes from. */
4
+ * host to start new sandboxes from, and copied off the host so it survives
5
+ * losing that host. */
5
6
  export type Snapshot = {
6
7
  id: string;
7
8
  kind: "snapshot";
@@ -19,6 +20,15 @@ export type Snapshot = {
19
20
  /** What it stores, and what storage is metered for: the bytes it alone holds. */
20
21
  storedBytes: number | null;
21
22
  meteredBytes: number | null;
23
+ /** Whether its copy off the host is made and checked. */
24
+ backedUp: boolean;
25
+ /** Where that copy stands. `restoring`: its host was lost and it is being
26
+ * restored onto another; forks answer `snapshot_restoring` until it is. */
27
+ durability: {
28
+ state: "none" | "pending" | "durable" | "failed" | "restoring";
29
+ durableAt: string | null;
30
+ storedBytes: number | null;
31
+ };
22
32
  error: string | null;
23
33
  createdAt: string;
24
34
  readyAt: string | null;
package/dist/ssh.js CHANGED
@@ -265,9 +265,21 @@ export async function portForwardCommand(argv, out, connect, me) {
265
265
  const sbx = await resolveSandbox(await connect(), target);
266
266
  const tunnel = await sbx.tunnel();
267
267
  const forwards = [];
268
+ /* A connection the sandbox refused is closed at once, which a browser or
269
+ curl reports only as an empty reply, so say why here, once per reason. */
270
+ const said = new Set();
271
+ const refused = (remote) => (error) => {
272
+ const line = `Port ${remote}: ${error.message}${error.hint ? ` ${error.hint}` : ""}`;
273
+ if (said.has(line))
274
+ return;
275
+ said.add(line);
276
+ out.error(out.json
277
+ ? JSON.stringify({ port: remote, error: { code: error.code, message: error.message } })
278
+ : line);
279
+ };
268
280
  try {
269
281
  for (const { local, remote } of ports)
270
- forwards.push(await tunnel.forward(remote, { localPort: local, host: address }));
282
+ forwards.push(await tunnel.forward(remote, { localPort: local, host: address, onError: refused(remote) }));
271
283
  }
272
284
  catch (error) {
273
285
  tunnel.close();
@@ -1,5 +1,5 @@
1
1
  import { RuntimeError } from "./errors.js";
2
- export declare const VERSION = "0.5.0";
2
+ export declare const VERSION = "0.6.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
package/dist/transport.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ConnectionError, errorFor, RuntimeError, WAITS_FOR_ROOM } from "./errors.js";
2
2
  import { describeRoute, envFetch, openWebSocket } from "./proxy.js";
3
- export const VERSION = "0.5.0";
3
+ export const VERSION = "0.6.0";
4
4
  export const DEFAULT_BASE_URL = "https://api.withruntime.com";
5
5
  export function apiOrigin(value) {
6
6
  const url = new URL(value);
@@ -289,6 +289,9 @@ export class Transport {
289
289
  yield JSON.parse(buffer);
290
290
  }
291
291
  finally {
292
+ // Returning early from the iterator must close the HTTP stream too.
293
+ // Cleanup must not replace a parse, network or callback error.
294
+ await reader.cancel().catch(() => undefined);
292
295
  reader.releaseLock();
293
296
  }
294
297
  }
package/dist/tunnel.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { RuntimeError } from "./errors.js";
1
2
  /** One TCP connection (or SSH login) inside a tunnel. Set the handlers before
2
3
  * data can arrive, which is as soon as the promise that gave you this resolves. */
3
4
  export declare class TunnelStream {
@@ -49,10 +50,12 @@ export declare class Tunnel {
49
50
  * (the text of an OpenSSH `.pub` file). Speak SSH over the stream. */
50
51
  ssh(publicKey: string): Promise<TunnelStream>;
51
52
  /** Listens on a local port and carries each connection to `port` in the
52
- * sandbox. Node and Bun only. */
53
+ * sandbox. Node and Bun only. A connection the sandbox cannot open, such as
54
+ * one to a port nothing listens on, is closed at once; `onError` says why. */
53
55
  forward(port: number, options?: {
54
56
  localPort?: number;
55
57
  host?: string;
58
+ onError?: (error: RuntimeError) => void;
56
59
  }): Promise<PortForward>;
57
60
  /** Closes the tunnel and every connection in it. */
58
61
  close(): void;
package/dist/tunnel.js CHANGED
@@ -160,7 +160,8 @@ export class Tunnel {
160
160
  return this.#open(`ssh ${publicKey.trim()}`);
161
161
  }
162
162
  /** Listens on a local port and carries each connection to `port` in the
163
- * sandbox. Node and Bun only. */
163
+ * sandbox. Node and Bun only. A connection the sandbox cannot open, such as
164
+ * one to a port nothing listens on, is closed at once; `onError` says why. */
164
165
  async forward(port, options = {}) {
165
166
  const net = await import("node:net");
166
167
  const host = options.host ?? "127.0.0.1";
@@ -183,7 +184,11 @@ export class Tunnel {
183
184
  socket.on("end", () => stream.end());
184
185
  socket.on("close", () => stream.close());
185
186
  socket.resume();
186
- }, () => socket.destroy());
187
+ }, (error) => {
188
+ socket.destroy();
189
+ if (error instanceof RuntimeError)
190
+ options.onError?.(error);
191
+ });
187
192
  });
188
193
  await new Promise((resolve, reject) => {
189
194
  server.once("error", reject);
@@ -217,7 +222,7 @@ export class Tunnel {
217
222
  message: `Nothing is listening on port ${port} in the sandbox.`,
218
223
  code: "port_closed",
219
224
  status: 0,
220
- hint: "Start the server first, and let it listen on 127.0.0.1, ::1 or every address.",
225
+ hint: "Start the server with spawn (`runtime sandbox spawn <id> -- <command>`, sbx.spawn()), which keeps it running: a process started by exec ends with its command. Let it listen on 127.0.0.1, ::1 or every address.",
221
226
  })
222
227
  : error.message === "reserved"
223
228
  ? new RuntimeError({
@@ -0,0 +1,7 @@
1
+ /** A WireGuard key pair, as `wg genkey | wg pubkey` makes: X25519, each half
2
+ * 32 bytes of base64. Made on the machine that will use it, so the private
3
+ * key is never sent anywhere. */
4
+ export declare function generateWireGuardKeyPair(): {
5
+ privateKey: string;
6
+ publicKey: string;
7
+ };