withruntime 0.5.1 → 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 (45) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +3 -2
  3. package/dist/cli-extras.d.ts +24 -0
  4. package/dist/cli-extras.js +205 -0
  5. package/dist/cli.d.ts +20 -0
  6. package/dist/cli.js +401 -28
  7. package/dist/e2b/filesystem.d.ts +26 -1
  8. package/dist/e2b/filesystem.js +30 -2
  9. package/dist/e2b/index.d.ts +1 -1
  10. package/dist/e2b/index.js +1 -1
  11. package/dist/errors.js +2 -0
  12. package/dist/identity.d.ts +27 -0
  13. package/dist/identity.js +56 -0
  14. package/dist/index.d.ts +14 -0
  15. package/dist/index.js +7 -0
  16. package/dist/mcp.js +16 -3
  17. package/dist/network-cli.d.ts +15 -0
  18. package/dist/network-cli.js +323 -0
  19. package/dist/products/desktop.d.ts +41 -4
  20. package/dist/products/desktop.js +73 -3
  21. package/dist/products/index.d.ts +12 -0
  22. package/dist/products/index.js +12 -0
  23. package/dist/products/interpreter.d.ts +5 -3
  24. package/dist/products/interpreter.js +2 -1
  25. package/dist/products/mcp.d.ts +79 -0
  26. package/dist/products/mcp.js +37 -0
  27. package/dist/products/mounts.d.ts +49 -0
  28. package/dist/products/mounts.js +21 -0
  29. package/dist/products/network-products.d.ts +167 -0
  30. package/dist/products/network-products.js +82 -0
  31. package/dist/products/network.d.ts +1 -1
  32. package/dist/products/sso.d.ts +44 -0
  33. package/dist/products/sso.js +8 -0
  34. package/dist/products/volumes.d.ts +68 -5
  35. package/dist/products/volumes.js +52 -4
  36. package/dist/products/watch.d.ts +91 -0
  37. package/dist/products/watch.js +135 -0
  38. package/dist/sandbox.d.ts +32 -2
  39. package/dist/sandbox.js +93 -16
  40. package/dist/snapshots.d.ts +11 -1
  41. package/dist/transport.d.ts +1 -1
  42. package/dist/transport.js +4 -1
  43. package/dist/wireguard.d.ts +7 -0
  44. package/dist/wireguard.js +13 -0
  45. package/package.json +7 -3
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from "node:fs";
3
- import { readFile, writeFile } from "node:fs/promises";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { connectionStore, resolveCredential } from "./credentials.js";
@@ -8,8 +8,10 @@ import { authenticationCommand, me, named } from "./login.js";
8
8
  import { createKey, dailyLimitMicros } from "./keys.js";
9
9
  import { Runtime } from "./client.js";
10
10
  import { RuntimeError } from "./errors.js";
11
+ import { NETWORK_PRODUCTS, networkProductCommand } from "./network-cli.js";
11
12
  import { envFetch } from "./proxy.js";
12
- import { portForwardCommand, sshCommand } from "./ssh.js";
13
+ import { identityToken } from "./identity.js";
14
+ import { portForwardCommand, resolveSandbox, sshCommand } from "./ssh.js";
13
15
  import { VERSION } from "./transport.js";
14
16
  const HELP = `Runtime Cloud CLI ${VERSION}
15
17
 
@@ -30,6 +32,8 @@ Account
30
32
  account add Connect another account (choose it in the browser)
31
33
  audit [--action member.] [--limit 50] [--before <next>]
32
34
  The account's audit log (owner or admin keys)
35
+ sso Single sign-on and SCIM directory sync (owner or
36
+ admin keys); an owner changes it on the website
33
37
  usage Balance, trial time and charges
34
38
  limits Whether this key is read-only, and its daily spending limit
35
39
  secrets set <NAME> --host <host>... [--header <name> [--format '<text {value}>']]
@@ -76,6 +80,10 @@ Products
76
80
  snapshot Saved sandboxes, disk and memory, to start or fork from
77
81
  image Custom images from a Dockerfile, any public or private image, or a package list
78
82
  volume Persistent disks to attach to sandboxes
83
+ domain Your own hostname for a sandbox's port, with HTTPS
84
+ port A public TCP port to a sandbox: databases, game servers
85
+ address A dedicated outbound address, for allow-lists
86
+ tunnel A WireGuard tunnel from your own network into your sandboxes
79
87
  Each has its own help: \`runtime sandbox help\`, \`runtime image help\`...
80
88
 
81
89
  Every command takes --json. Keys: RUNTIME_API_KEY, or the connection from \`runtime login\`.
@@ -83,6 +91,8 @@ Behind a proxy: HTTPS_PROXY and NO_PROXY are read, on Node and Bun alike.
83
91
  `;
84
92
  const SANDBOX_HELP = `runtime sandbox <command>
85
93
 
94
+ Every <id> below can also be the sandbox's name.
95
+
86
96
  run [--keep] [create options] -- <command> A fresh sandbox runs one command, then stops
87
97
  ssh <id|name> [-- <command...>] | ssh config [--install] Log in over SSH, also from VS Code and JetBrains
88
98
  port-forward <id|name> <port|local:remote>... Forward local ports to ports in it
@@ -98,7 +108,7 @@ const SANDBOX_HELP = `runtime sandbox <command>
98
108
  Run and stream output; exits with its code
99
109
  spawn <id> [--pty] -- <command...> Start in the background; prints a process id
100
110
  ps <id> Running and recent processes
101
- logs <id> <processId> [-f] A process's output (follow with -f)
111
+ logs <id> <processId> [-f] A process's output so far; -f follows it to the end
102
112
  kill <id> <processId> [--signal SIGTERM]
103
113
  shell <id> [--command "bash -l"] An interactive terminal
104
114
  cp <src> <dst> Copy files or directories; name a sandbox
@@ -111,19 +121,35 @@ const SANDBOX_HELP = `runtime sandbox <command>
111
121
  [--persistent on|off] [--max-total-cost <usd>|none]
112
122
  Change its settings; persistent keeps it running
113
123
  snapshot <id> [--name n] [--retention days] Keep its whole machine; prints the snapshot id
124
+ mount <id> <s3|r2|gcs>://<bucket>[/prefix] <path> [--secret NAME] [--region r]
125
+ [--endpoint https://...] [--account-id id] [--read-only]
126
+ Your bucket as a directory; the proxy signs, the sandbox never sees the key
127
+ mounts <id> | unmount <id> <path> Its bucket mounts; unmount one
114
128
  fork <id> [--count 3] [--name n] [--keep-snapshot] [--trial|--paid]
115
129
  Copies of it as it is now, running; prints their ids
116
- run-code <id> <file|-> [--lang python|javascript] [--context c] [--out-dir .]
130
+ run-code <id> <file|-> [--lang python|javascript|typescript|r|java|bash|go] [--context c] [--out-dir .]
117
131
  Run a notebook-style cell; charts saved as PNG
132
+ watch <id> <path> [--recursive] [--include <glob>]... [--exclude <glob>]... [--events create,write,remove,rename,chmod]
133
+ Print file changes as they happen, until Ctrl-C
134
+ mcp catalog MCP servers a sandbox can run (licence, settings, hosts)
135
+ mcp <id> start <server>... [--secret SERVER.SETTING=SECRET]... [--env SERVER.NAME=value]...
136
+ [--option SERVER.NAME=value]... [--port 8765] [--replace]
137
+ Start them; prints each URL and the header to send
138
+ mcp <id> [status] | mcp <id> stop Their state and fresh URLs; stop them
118
139
  preview <id> <port> [--public] [--ttl <s>] Share a port at an HTTPS address
119
140
  previews <id> Every shared port
120
141
  unshare <id> <port> Stop sharing a port
121
142
  network <id> [--no-internet|--internet] [--allow <host>]... [--deny <host>]... [--connect <host:port>]...
122
143
  Show its network rules, or replace them
144
+ identity-token --audience <aud> [--lifetime <seconds>]
145
+ Inside a sandbox: an OIDC token naming it, for AWS,
146
+ Google Cloud or your own API. Needs no API key
123
147
  metrics <id> [--range 15m|1h|6h|24h|7d|30d]
124
148
  CPU and memory over time, and the latest reading
125
149
  desktop <id> start | stop | screenshot [file.png] | open <url> | click <x> <y>
126
150
  | type <text> | press <keys> | windows
151
+ desktop <id> record start [--fps 10] [--max-seconds 1800] [--max-mib 512] | record stop <recId>
152
+ | record ls | record fetch <recId> [file.mp4] | record rm <recId>
127
153
 
128
154
  Examples
129
155
  id=$(runtime sandbox create)
@@ -169,6 +195,17 @@ const pairs = (args, name) => Object.fromEntries((args.flags.get(name) ?? []).ma
169
195
  throw usage(`--${name} takes KEY=VALUE.`);
170
196
  return [pair.slice(0, at), pair.slice(at + 1)];
171
197
  }));
198
+ /** What the commands in cli-extras.ts use of this file. */
199
+ const kit = {
200
+ parse: (argv, booleans) => parse(argv, booleans),
201
+ flag,
202
+ has,
203
+ integer,
204
+ pairs,
205
+ need: (value, what) => need(value, what),
206
+ table: (rows) => table(rows),
207
+ usage: (message) => usage(message),
208
+ };
172
209
  /** The product being run, so a mistake points at its own help. */
173
210
  let product = "";
174
211
  function usage(message) {
@@ -206,6 +243,7 @@ const COMMANDS = [
206
243
  "whoami",
207
244
  "usage",
208
245
  "limits",
246
+ "sso",
209
247
  "secrets",
210
248
  "keys",
211
249
  "referrals",
@@ -220,6 +258,10 @@ const COMMANDS = [
220
258
  "snapshot",
221
259
  "image",
222
260
  "volume",
261
+ "domain",
262
+ "port",
263
+ "address",
264
+ "tunnel",
223
265
  "webhooks",
224
266
  "otel",
225
267
  "events",
@@ -240,6 +282,9 @@ const SANDBOX_VERBS = [
240
282
  "wake",
241
283
  "restart",
242
284
  "snapshot",
285
+ "mount",
286
+ "mounts",
287
+ "unmount",
243
288
  "fork",
244
289
  "extend",
245
290
  "update",
@@ -259,6 +304,9 @@ const SANDBOX_VERBS = [
259
304
  "network",
260
305
  "desktop",
261
306
  "metrics",
307
+ "watch",
308
+ "mcp",
309
+ "identity-token",
262
310
  ];
263
311
  function need(value, what) {
264
312
  if (!value)
@@ -454,7 +502,7 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
454
502
  }
455
503
  if (command === "usage") {
456
504
  const value = await (await client(env)).usage();
457
- print(JSON.stringify(value, null, 2), value);
505
+ print(usageSummary(value), value);
458
506
  return 0;
459
507
  }
460
508
  if (command === "limits") {
@@ -486,8 +534,10 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
486
534
  return otelCommand(rest, env, out);
487
535
  if (command === "events") {
488
536
  const args = parse(rest);
489
- const page = await (await client(env)).events.list({
490
- ...(flag(args, "sandbox") ? { resourceId: flag(args, "sandbox") } : {}),
537
+ const rt = await client(env);
538
+ const sandbox = flag(args, "sandbox");
539
+ const page = await rt.events.list({
540
+ ...(sandbox ? { resourceId: await sandboxIdOf(rt, sandbox) } : {}),
491
541
  ...(flag(args, "type") ? { type: flag(args, "type") } : {}),
492
542
  ...(integer(args, "limit") ? { limit: integer(args, "limit") } : {}),
493
543
  });
@@ -501,6 +551,29 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
501
551
  }
502
552
  if (command === "keys")
503
553
  return keys(rest, env, out);
554
+ if (command === "sso") {
555
+ const status = await (await client(env)).sso.get();
556
+ print([
557
+ status.connections.length
558
+ ? table([
559
+ ["domain", "provider", "verified", "required", "new people join as"],
560
+ ...status.connections.map((c) => [
561
+ c.domain,
562
+ `${c.provider} ${c.protocol.toUpperCase()}`,
563
+ c.domainVerifiedAt
564
+ ? c.domainVerifiedAt.slice(0, 10)
565
+ : `no: TXT ${c.verification.name}`,
566
+ c.requireSso ? "yes" : "no",
567
+ c.defaultRole,
568
+ ]),
569
+ ])
570
+ : "No single sign-on yet.",
571
+ "",
572
+ `Directory sync (SCIM): ${status.scim.tokens} token${status.scim.tokens === 1 ? "" : "s"}, ${status.scim.activeUsers} of ${status.scim.users} people active, ${status.scim.groups.length} groups.`,
573
+ `An owner changes it at ${status.manage}`,
574
+ ].join("\n"), status);
575
+ return 0;
576
+ }
504
577
  if (command === "account" || command === "accounts")
505
578
  return accountCommand(rest, env, out);
506
579
  if (command === "audit") {
@@ -558,7 +631,11 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
558
631
  if (command === "mcp") {
559
632
  const { serveMcp } = await import("./mcp.js");
560
633
  await serveMcp(env);
561
- return 0;
634
+ /* Its input closed or a signal came: leave once stdout has drained. An
635
+ idle keep-alive connection to the API would otherwise hold the process
636
+ for several seconds after the client asked it to go. */
637
+ await new Promise((resolve) => process.stdout.write("", () => resolve()));
638
+ process.exit(0);
562
639
  }
563
640
  if (command === "docs") {
564
641
  const topic = rest[0] ?? "index";
@@ -652,8 +729,101 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
652
729
  product = "volume";
653
730
  return volume(rest, env, out);
654
731
  }
732
+ const networkProduct = { domains: "domain", ports: "port", addresses: "address", tunnels: "tunnel" }[command] ??
733
+ command;
734
+ if (NETWORK_PRODUCTS.includes(networkProduct)) {
735
+ product = networkProduct;
736
+ return networkProductCommand(networkProduct, rest, parse(rest, ["ipv6"]), out, () => client(env));
737
+ }
655
738
  throw unknown("command", command, COMMANDS);
656
739
  }
740
+ /** `runtime usage` for a person: the balance, the trial and what each kind
741
+ * of resource was charged, in dollars. --json keeps every figure, and each
742
+ * resource with its rates and CPU time. */
743
+ function usageSummary(u) {
744
+ const micros = (value) => typeof value === "string" || typeof value === "number" || typeof value === "bigint"
745
+ ? BigInt(value)
746
+ : 0n;
747
+ const hours = (ms) => (ms / 3_600_000).toLocaleString("en-US", { maximumFractionDigits: 1 });
748
+ const takenBack = micros(u.takenBack);
749
+ const rows = [
750
+ ["available", dollars(micros(u.available))],
751
+ ["credited", dollars(micros(u.credited))],
752
+ [
753
+ "spent",
754
+ `${dollars(micros(u.spent))}${takenBack > 0n ? `, ${dollars(takenBack)} of it refunded or disputed` : ""}`,
755
+ ],
756
+ ["held for running work", dollars(micros(u.held))],
757
+ ];
758
+ if (micros(u.expired) > 0n)
759
+ rows.push(["expired or taken back", dollars(micros(u.expired))]);
760
+ if (u.trial)
761
+ rows.push([
762
+ "free trial",
763
+ `${hours(u.trial.availableMs)} of ${hours(u.trial.totalMs)} hours left${u.trial.reservedMs > 0 ? `, ${hours(u.trial.reservedMs)} held by running sandboxes` : ""}`,
764
+ ]);
765
+ const kinds = new Map();
766
+ for (const resource of u.resources ?? []) {
767
+ const kind = typeof resource.kind === "string" ? resource.kind : "other";
768
+ const entry = kinds.get(kind) ?? { count: 0, charged: 0n, held: 0n };
769
+ entry.count++;
770
+ entry.charged += micros(resource.chargedMicros);
771
+ entry.held += micros(resource.heldMicros);
772
+ kinds.set(kind, entry);
773
+ }
774
+ const plural = (kind, n) => n === 1 ? kind : kind.endsWith("x") || kind.endsWith("s") ? `${kind}es` : `${kind}s`;
775
+ const byKind = [...kinds.entries()].sort((a, b) => b[1].count - a[1].count);
776
+ return [
777
+ table(rows),
778
+ "",
779
+ byKind.length
780
+ ? table([
781
+ ["RESOURCES", "COUNT", "CHARGED", "HELD"],
782
+ ...byKind.map(([kind, e]) => [
783
+ plural(kind, e.count),
784
+ e.count.toLocaleString("en-US"),
785
+ dollars(e.charged),
786
+ dollars(e.held),
787
+ ]),
788
+ ])
789
+ : "No resources yet.",
790
+ "",
791
+ `Each resource, with its rates and CPU time: ${me} usage --json`,
792
+ ].join("\n");
793
+ }
794
+ const SANDBOX_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
795
+ /** A sandbox's id from its id or its name, for the filters that take an id. */
796
+ async function sandboxIdOf(runtime, target) {
797
+ return SANDBOX_UUID.test(target) ? target : (await resolveSandbox(runtime, target)).id;
798
+ }
799
+ /** What a process has printed so far, in order, without waiting for more. */
800
+ async function outputSoFar(runtime, sandboxId, processId) {
801
+ const chunks = [];
802
+ let cursor = 0;
803
+ let truncated = false;
804
+ let latest = null;
805
+ // The guest keeps the latest 1 MiB; a read returns at most 192 KiB.
806
+ for (let page = 0; page < 16; page++) {
807
+ const read = await runtime.transport.json({
808
+ method: "GET",
809
+ path: `/v1/sandboxes/${encodeURIComponent(sandboxId)}/processes/${encodeURIComponent(processId)}/output`,
810
+ query: { cursor, maxBytes: 196_608 },
811
+ });
812
+ chunks.push(...read.chunks);
813
+ truncated ||= read.truncated;
814
+ latest = read.process ?? latest;
815
+ if (read.chunks.length === 0 || read.nextCursor <= cursor)
816
+ break;
817
+ cursor = read.nextCursor;
818
+ }
819
+ return {
820
+ processId,
821
+ state: latest?.state ?? null,
822
+ exitCode: latest?.exitCode ?? null,
823
+ truncated,
824
+ chunks,
825
+ };
826
+ }
657
827
  /** Dollars from integer microdollars: cents from a dollar up, and four
658
828
  * places below one, so a few minutes of use does not round to $0.01. */
659
829
  function dollars(micros) {
@@ -769,13 +939,25 @@ async function switching(command, argv, env, out) {
769
939
  : `${c.rival.name} would cost ${dollars(-saving)} less${percent}`;
770
940
  const figures = `${dollars(c.runtimeMicros)} on Runtime; the same sandboxes on ${c.rival.name}: ${dollars(c.rivalMicros)}; ${verdict}`;
771
941
  const month = c.perMonth ? BigInt(c.perMonth.savingMicros) : undefined;
942
+ const monthly = month === undefined
943
+ ? ""
944
+ : month >= 0n
945
+ ? `about ${dollars(month)} a month`
946
+ : `about ${dollars(-month)} a month more on Runtime`;
947
+ /* The month is projected from the days the usage covers, not from the
948
+ window asked for: say which, or a young account's 7 and 90 days read the
949
+ same with nothing to say why. */
950
+ const covered = c.perMonth?.fromDays ?? c.window.days;
951
+ const pace = month === undefined
952
+ ? "."
953
+ : covered < c.window.days
954
+ ? `. Your first sandbox in this window ran ${covered <= 1 ? "within the last day" : `${covered.toLocaleString("en-US")} days ago`}; at that pace, ${monthly}.`
955
+ : c.window.days === 30
956
+ ? `, ${monthly}.`
957
+ : `. At that pace, ${monthly}.`;
772
958
  const lines = c.basis === "usage"
773
959
  ? [
774
- `Your last ${c.window.days} days: ${figures}${month === undefined
775
- ? "."
776
- : month >= 0n
777
- ? `, about ${dollars(month)} a month.`
778
- : `, about ${dollars(-month)} a month more on Runtime.`}`,
960
+ `Your last ${c.window.days} days: ${figures}${pace}`,
779
961
  `Priced: ${c.usage.sandboxes.toLocaleString("en-US")} sandbox${c.usage.sandboxes === 1 ? "" : "es"}, ${(c.usage.runSeconds / 3600).toLocaleString("en-US", { maximumFractionDigits: 2 })} hours running.${trialNote(c.usage.trialSandboxes, c.usage.sandboxes)}${c.usage.unpricedSandboxes ? ` ${c.usage.unpricedSandboxes} left out: ${c.rival.name} publishes no price for their size.` : ""}`,
780
962
  ]
781
963
  : [
@@ -826,8 +1008,22 @@ async function sandbox(argv, env, out) {
826
1008
  out.write(out.json ? JSON.stringify({ usage: named(SANDBOX_HELP) }) : named(SANDBOX_HELP));
827
1009
  return 0;
828
1010
  }
1011
+ // Inside a sandbox, with no API key: the sandbox's own request token asks.
1012
+ if (verb === "identity-token") {
1013
+ const args = parse(rest);
1014
+ const audience = need(flag(args, "audience"), "--audience, such as sts.amazonaws.com");
1015
+ const lifetime = flag(args, "lifetime");
1016
+ const token = await identityToken({
1017
+ audience,
1018
+ ...(lifetime === undefined ? {} : { lifetimeSeconds: Number(lifetime) }),
1019
+ });
1020
+ print(token.token, token);
1021
+ return 0;
1022
+ }
829
1023
  const runtime = await client(env);
830
- const get = (id) => runtime.sandboxes.get(need(id, "a sandbox id"));
1024
+ /* By id or by name, for every command here, as ssh and port-forward
1025
+ always took one. */
1026
+ const get = (id) => resolveSandbox(runtime, need(id, "a sandbox id or name"));
831
1027
  switch (verb) {
832
1028
  case "create": {
833
1029
  const args = parse(rest, CREATE_FLAGS);
@@ -888,6 +1084,50 @@ async function sandbox(argv, env, out) {
888
1084
  print(`${sbx.id} ${sbx.state}`, sbx.info);
889
1085
  return 0;
890
1086
  }
1087
+ case "mount": {
1088
+ const args = parse(rest, ["read-only"]);
1089
+ const sbx = await get(args.positional[0]);
1090
+ const source = need(args.positional[1], "a bucket, as s3://bucket, r2://bucket or gcs://bucket");
1091
+ const found = /^(s3|r2|gcs):\/\/([^/]+)(?:\/(.+))?$/.exec(source);
1092
+ if (!found)
1093
+ throw usage("The bucket is s3://bucket[/prefix], r2://bucket or gcs://bucket.");
1094
+ const mounted = await sbx.mounts.add({
1095
+ provider: found[1],
1096
+ bucket: found[2],
1097
+ path: need(args.positional[2], "a path to mount it at, such as /data"),
1098
+ ...(found[3] ? { prefix: found[3] } : {}),
1099
+ ...(flag(args, "secret") ? { secret: flag(args, "secret") } : {}),
1100
+ ...(flag(args, "region") ? { region: flag(args, "region") } : {}),
1101
+ ...(flag(args, "endpoint") ? { endpoint: flag(args, "endpoint") } : {}),
1102
+ ...(flag(args, "account-id") ? { accountId: flag(args, "account-id") } : {}),
1103
+ ...(has(args, "read-only") ? { readOnly: true } : {}),
1104
+ });
1105
+ print(`${mounted.path} ${mounted.provider}://${mounted.bucket}`, mounted);
1106
+ return 0;
1107
+ }
1108
+ case "mounts": {
1109
+ const sbx = await get(rest[0]);
1110
+ const all = await sbx.mounts.list();
1111
+ print(all.length
1112
+ ? table([
1113
+ ["PATH", "BUCKET", "ENDPOINT", "SECRET", "MOUNTED"],
1114
+ ...all.map((m) => [
1115
+ m.path,
1116
+ `${m.provider}://${m.bucket}${m.prefix ? `/${m.prefix}` : ""}`,
1117
+ m.endpoint,
1118
+ m.secret ?? "-",
1119
+ m.mounted ? "yes" : "no",
1120
+ ]),
1121
+ ])
1122
+ : "No buckets mounted.", all);
1123
+ return 0;
1124
+ }
1125
+ case "unmount": {
1126
+ const sbx = await get(rest[0]);
1127
+ const gone = await sbx.mounts.remove(need(rest[1], "the mounted path"));
1128
+ print(`${gone.path} unmounted`, gone);
1129
+ return 0;
1130
+ }
891
1131
  case "snapshot": {
892
1132
  const args = parse(rest);
893
1133
  const sbx = await get(args.positional[0]);
@@ -992,7 +1232,7 @@ async function sandbox(argv, env, out) {
992
1232
  const args = parse(rest, ["f", "follow"]);
993
1233
  const sbx = await get(args.positional[0]);
994
1234
  const proc = await sbx.processes.get(need(args.positional[1], "a process id"));
995
- if (has(args, "f") || has(args, "follow") || !out.json) {
1235
+ if (has(args, "f") || has(args, "follow")) {
996
1236
  for await (const event of proc.output())
997
1237
  if (event.type === "stdout")
998
1238
  process.stdout.write(event.data);
@@ -1002,7 +1242,17 @@ async function sandbox(argv, env, out) {
1002
1242
  out.write(JSON.stringify(event));
1003
1243
  return 0;
1004
1244
  }
1005
- out.write(JSON.stringify(await proc.wait()));
1245
+ /* Without -f, what it has printed so far, then back to the prompt, as
1246
+ `docker logs` does: a server's log is read, not followed forever. */
1247
+ const so = await outputSoFar(runtime, sbx.id, proc.id);
1248
+ if (out.json) {
1249
+ out.write(JSON.stringify(so));
1250
+ return 0;
1251
+ }
1252
+ for (const chunk of so.chunks)
1253
+ (chunk.stream === "stderr" ? process.stderr : process.stdout).write(chunk.text);
1254
+ if (so.state === "running")
1255
+ process.stderr.write(`[${proc.id} is still running; follow it: ${me} sandbox logs ${sbx.id} ${proc.id} -f]\n`);
1006
1256
  return 0;
1007
1257
  }
1008
1258
  case "kill": {
@@ -1031,7 +1281,7 @@ async function sandbox(argv, env, out) {
1031
1281
  ...(flag(args, "glob") ? { glob: flag(args, "glob") } : {}),
1032
1282
  });
1033
1283
  print(entries
1034
- .map((e) => `${e.type === "directory" ? "d" : "-"} ${String(e.size).padStart(10)} ${e.path}`)
1284
+ .map((e) => `${e.type === "directory" ? "d" : e.type === "symlink" ? "l" : e.type === "other" ? "?" : "-"} ${String(e.size).padStart(10)} ${e.path}`)
1035
1285
  .join("\n") || "(empty)", entries);
1036
1286
  return 0;
1037
1287
  }
@@ -1101,6 +1351,27 @@ async function sandbox(argv, env, out) {
1101
1351
  }
1102
1352
  case "desktop":
1103
1353
  return desktop(await get(rest[0]), rest.slice(1), out);
1354
+ case "watch":
1355
+ return (await import("./cli-extras.js")).watchCommand(await get(rest[0]), rest.slice(1), out, kit);
1356
+ case "mcp":
1357
+ if (rest[0] === "catalog" || rest[0] === undefined) {
1358
+ const catalog = await runtime.mcp.catalog();
1359
+ print(table([
1360
+ ["ID", "LICENCE", "VERSION", "NEEDS", "WHAT"],
1361
+ ...catalog.map((entry) => [
1362
+ entry.id,
1363
+ entry.license,
1364
+ entry.version,
1365
+ entry.env
1366
+ .filter((item) => item.required)
1367
+ .map((item) => `${item.name}${item.secret ? " (secret)" : ""}`)
1368
+ .join(", ") || "-",
1369
+ entry.description,
1370
+ ]),
1371
+ ]), catalog);
1372
+ return 0;
1373
+ }
1374
+ return (await import("./cli-extras.js")).mcpCommand(await get(rest[0]), rest.slice(1), out, kit);
1104
1375
  case "metrics": {
1105
1376
  const args = parse(rest);
1106
1377
  const sbx = await get(args.positional[0]);
@@ -1236,8 +1507,20 @@ async function runCode(sbx, argv, out) {
1236
1507
  const args = parse(argv);
1237
1508
  const source = need(args.positional[0], "a file, or - to read the code from standard input");
1238
1509
  const code = await readInput(source);
1510
+ const byExtension = {
1511
+ js: "javascript",
1512
+ mjs: "javascript",
1513
+ ts: "typescript",
1514
+ r: "r",
1515
+ java: "java",
1516
+ jsh: "java",
1517
+ sh: "bash",
1518
+ bash: "bash",
1519
+ go: "go",
1520
+ };
1239
1521
  const language = (flag(args, "lang") ??
1240
- (/\.(m?js|ts)$/.test(source) ? "javascript" : "python"));
1522
+ byExtension[source.split(".").pop()?.toLowerCase() ?? ""] ??
1523
+ "python");
1241
1524
  const timeout = integer(args, "timeout");
1242
1525
  const execution = await sbx.interpreter.run(code, {
1243
1526
  language,
@@ -1252,6 +1535,8 @@ async function runCode(sbx, argv, out) {
1252
1535
  });
1253
1536
  const saved = [];
1254
1537
  const outDir = flag(args, "out-dir") ?? ".";
1538
+ if (execution.results.some((r) => r.data["image/png"] || r.refs["image/png"]))
1539
+ await mkdir(outDir, { recursive: true });
1255
1540
  for (const [index, result] of execution.results.entries()) {
1256
1541
  const inline = result.data["image/png"];
1257
1542
  const ref = result.refs["image/png"];
@@ -1316,6 +1601,8 @@ async function desktop(sbx, argv, out) {
1316
1601
  case "press":
1317
1602
  print("Pressed.", await d.press(need(rest[0], "keys, e.g. ctrl+l")));
1318
1603
  return 0;
1604
+ case "record":
1605
+ return (await import("./cli-extras.js")).recordCommand(sbx, rest, out, kit);
1319
1606
  case "windows": {
1320
1607
  const windows = await d.windows();
1321
1608
  print(windows.length
@@ -1324,7 +1611,7 @@ async function desktop(sbx, argv, out) {
1324
1611
  return 0;
1325
1612
  }
1326
1613
  }
1327
- throw usage(`${me} sandbox desktop <id> start|stop|screenshot|open|click|type|press|windows`);
1614
+ throw usage(`${me} sandbox desktop <id> start|stop|screenshot|open|click|type|press|windows|record`);
1328
1615
  }
1329
1616
  const KEYS_HELP = `runtime keys <command>
1330
1617
 
@@ -1483,7 +1770,9 @@ async function snapshot(argv, env, out) {
1483
1770
  case "list": {
1484
1771
  const args = parse(rest);
1485
1772
  const all = await (await api.list({
1486
- ...(flag(args, "sandbox") ? { sandboxId: flag(args, "sandbox") } : {}),
1773
+ ...(flag(args, "sandbox")
1774
+ ? { sandboxId: await sandboxIdOf(await client(env), flag(args, "sandbox")) }
1775
+ : {}),
1487
1776
  ...(flag(args, "name") ? { name: flag(args, "name") } : {}),
1488
1777
  })).toArray(1000);
1489
1778
  print(all.length
@@ -1744,10 +2033,17 @@ const VOLUME_HELP = `runtime volume <command>
1744
2033
 
1745
2034
  create --size-mib <N> [--name n] A persistent ext4 disk; prints its id
1746
2035
  ls Your volumes
1747
- get <id> One volume and where it is attached
1748
- rm <id> Delete it and everything on it
2036
+ get <id> One volume, its backups and where it is attached
2037
+ rm <id> Delete it and everything on it (its backups stay)
2038
+ backup <id> [--retention-days N] [--no-wait]
2039
+ Back it up now, off its host; prints the backup id
2040
+ backups [<id>] Backups, newest first (of one volume, if given)
2041
+ restore <backupId> [--name n] A new volume from a backup, on any host
2042
+ backup-rm <backupId> Delete a backup
2043
+ backup-policy <id> [--daily on|off] [--retention-days N]
2044
+ Daily backups on or off, and how long each is kept
1749
2045
  Attach one: runtime sandbox create --volume <id>:/data
1750
- A volume lives on one server and is not backed up off it.
2046
+ Volumes are backed up off their host daily unless you turn it off.
1751
2047
  `;
1752
2048
  async function volume(argv, env, out) {
1753
2049
  const [verb, ...rest] = argv;
@@ -1764,8 +2060,6 @@ async function volume(argv, env, out) {
1764
2060
  sizeMiB: integer(args, "size-mib") ?? Number(need(undefined, "--size-mib <N>")),
1765
2061
  ...(flag(args, "name") ? { name: flag(args, "name") } : {}),
1766
2062
  });
1767
- if (!out.json)
1768
- out.error("Not backed up off its server.");
1769
2063
  print(created.id, created);
1770
2064
  return 0;
1771
2065
  }
@@ -1774,12 +2068,13 @@ async function volume(argv, env, out) {
1774
2068
  const all = await (await api.list()).toArray(1000);
1775
2069
  print(all.length
1776
2070
  ? table([
1777
- ["ID", "NAME", "STATE", "MIB", "ATTACHED"],
2071
+ ["ID", "NAME", "STATE", "MIB", "BACKED UP", "ATTACHED"],
1778
2072
  ...all.map((v) => [
1779
2073
  v.id,
1780
2074
  v.name ?? "-",
1781
2075
  v.state,
1782
2076
  String(v.sizeMiB),
2077
+ v.backedUp ? (v.backups?.lastReadyAt ?? "yes") : "no",
1783
2078
  v.attachments.map((a) => `${a.sandboxId}:${a.path}`).join(", ") || "-",
1784
2079
  ]),
1785
2080
  ])
@@ -1797,8 +2092,78 @@ async function volume(argv, env, out) {
1797
2092
  print(`${found.id} ${found.state}`, found);
1798
2093
  return 0;
1799
2094
  }
2095
+ case "backup": {
2096
+ const args = parse(rest, ["no-wait"]);
2097
+ const id = need(args.positional[0], "a volume id");
2098
+ const days = integer(args, "retention-days");
2099
+ const made = await api.backup(id, days === undefined ? {} : { retentionDays: days }, has(args, "no-wait") ? { wait: 0 } : {});
2100
+ if (!out.json && made.state === "pending")
2101
+ out.error(`Copying it off the host; check with: ${me} volume backups ${id}`);
2102
+ print(made.id, made);
2103
+ return 0;
2104
+ }
2105
+ case "backups": {
2106
+ const volumeId = rest[0];
2107
+ const all = await (await api.backups(volumeId ? { volumeId } : {})).toArray(1000);
2108
+ print(all.length
2109
+ ? table([
2110
+ ["ID", "VOLUME", "STATE", "TRIGGER", "MIB", "STORED", "TAKEN", "EXPIRES"],
2111
+ ...all.map((b) => [
2112
+ b.id,
2113
+ b.volumeId,
2114
+ b.state,
2115
+ b.trigger,
2116
+ String(b.sizeMiB),
2117
+ b.storedBytes === null ? "-" : `${Math.ceil(b.storedBytes / 1048576)} MiB`,
2118
+ b.createdAt,
2119
+ b.expiresAt,
2120
+ ]),
2121
+ ])
2122
+ : "No backups yet.", all);
2123
+ return 0;
2124
+ }
2125
+ case "restore": {
2126
+ const args = parse(rest);
2127
+ const created = await api.restore(need(args.positional[0], "a backup id"), flag(args, "name") ? { name: flag(args, "name") } : {});
2128
+ if (!out.json && created.state === "creating")
2129
+ out.error(`Restoring; check with: ${me} volume get ${created.id}`);
2130
+ print(created.id, created);
2131
+ return 0;
2132
+ }
2133
+ case "backup-rm":
2134
+ case "backup-delete": {
2135
+ const found = await api.deleteBackup(need(rest[0], "a backup id"));
2136
+ print(`${found.id} ${found.state}`, found);
2137
+ return 0;
2138
+ }
2139
+ case "backup-policy": {
2140
+ const args = parse(rest);
2141
+ const id = need(args.positional[0], "a volume id");
2142
+ const daily = flag(args, "daily");
2143
+ if (daily !== undefined && !["on", "off", "true", "false"].includes(daily))
2144
+ throw usage("--daily takes on or off.");
2145
+ const days = integer(args, "retention-days");
2146
+ if (daily === undefined && days === undefined)
2147
+ throw usage("Give --daily on|off, --retention-days N or both.");
2148
+ const updated = await api.setBackupPolicy(id, {
2149
+ ...(daily === undefined ? {} : { daily: daily === "on" || daily === "true" }),
2150
+ ...(days === undefined ? {} : { retentionDays: days }),
2151
+ });
2152
+ print(`${updated.id} daily backups ${updated.backups.daily ? "on" : "off"}, kept ${updated.backups.retentionDays} days`, updated);
2153
+ return 0;
2154
+ }
1800
2155
  }
1801
- throw unknown("volume command", verb, ["create", "ls", "get", "rm"]);
2156
+ throw unknown("volume command", verb, [
2157
+ "create",
2158
+ "ls",
2159
+ "get",
2160
+ "rm",
2161
+ "backup",
2162
+ "backups",
2163
+ "restore",
2164
+ "backup-rm",
2165
+ "backup-policy",
2166
+ ]);
1802
2167
  }
1803
2168
  /** Flags `sandbox create` and `run` take without a value. */
1804
2169
  const CREATE_FLAGS = [
@@ -1852,7 +2217,10 @@ function createInput(args) {
1852
2217
  * (124 on a timeout), so a script's `set -e` behaves. */
1853
2218
  async function execute(sbx, command, args, out, timeout) {
1854
2219
  const target = command.length === 1 ? command[0] : command;
2220
+ const extras = await import("./cli-extras.js");
2221
+ const stdin = await extras.pipedStdin();
1855
2222
  const options = {
2223
+ ...(stdin ? { stdin } : {}),
1856
2224
  ...(flag(args, "cwd") ? { cwd: flag(args, "cwd") } : {}),
1857
2225
  ...(args.flags.has("env") ? { env: pairs(args, "env") } : {}),
1858
2226
  ...(timeout ? { timeoutMs: timeout * 1000 } : {}),
@@ -1868,7 +2236,12 @@ async function execute(sbx, command, args, out, timeout) {
1868
2236
  out.write(JSON.stringify(result));
1869
2237
  else if (result.timedOut)
1870
2238
  out.error(`runtime: timed out after ${timeout ?? 60} s; the command's process group was killed.`);
1871
- return result.timedOut ? 124 : Math.min(255, Math.max(0, result.exitCode ?? 1));
2239
+ // Never drop output silently: say so, and do not exit 0.
2240
+ const lost = extras.lostOutput(result, sbx.id);
2241
+ if (lost && !out.json)
2242
+ out.error(lost);
2243
+ const code = result.timedOut ? 124 : Math.min(255, Math.max(0, result.exitCode ?? 1));
2244
+ return lost && code === 0 ? 1 : code;
1872
2245
  }
1873
2246
  async function shell(sbx, command) {
1874
2247
  const stdin = process.stdin;