withruntime 0.11.2 → 0.14.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 (171) hide show
  1. package/BLAXEL.md +2 -2
  2. package/COMPATIBILITY.md +74 -9
  3. package/DAYTONA.md +33 -5
  4. package/E2B.md +85 -24
  5. package/README.md +11 -10
  6. package/README.src.md +10 -9
  7. package/VERCEL.md +43 -5
  8. package/compatibility-lock.json +58 -49
  9. package/dist/billing-cli.d.ts +1 -1
  10. package/dist/billing-cli.js +85 -5
  11. package/dist/blaxel/errors.d.ts +14 -0
  12. package/dist/blaxel/errors.js +29 -0
  13. package/dist/blaxel/index.d.ts +2 -2
  14. package/dist/blaxel/index.js +4 -4
  15. package/dist/blaxel/interpreter.d.ts +2 -5
  16. package/dist/blaxel/sandbox.d.ts +15 -6
  17. package/dist/blaxel/sandbox.js +64 -8
  18. package/dist/blaxel/snapshot.d.ts +4 -0
  19. package/dist/blaxel/snapshot.js +4 -0
  20. package/dist/cli/logs.d.ts +21 -0
  21. package/dist/cli/logs.js +188 -0
  22. package/dist/cli-extras.d.ts +3 -3
  23. package/dist/cli-extras.js +17 -12
  24. package/dist/cli-words.d.ts +10 -1
  25. package/dist/cli-words.js +18 -0
  26. package/dist/cli.d.ts +22 -3
  27. package/dist/cli.js +647 -273
  28. package/dist/client.d.ts +2 -4
  29. package/dist/client.js +27 -11
  30. package/dist/cloudflare/errors.d.ts +143 -0
  31. package/dist/cloudflare/errors.js +167 -0
  32. package/dist/cloudflare/index.d.ts +175 -6
  33. package/dist/cloudflare/index.js +416 -26
  34. package/dist/cloudflare/interpreter.d.ts +119 -0
  35. package/dist/cloudflare/interpreter.js +223 -0
  36. package/dist/cloudflare/streams.d.ts +38 -0
  37. package/dist/cloudflare/streams.js +145 -0
  38. package/dist/cloudflare/watch.d.ts +9 -0
  39. package/dist/cloudflare/watch.js +24 -0
  40. package/dist/codesandbox/index.d.ts +216 -28
  41. package/dist/codesandbox/index.js +478 -78
  42. package/dist/compat/core.d.ts +18 -0
  43. package/dist/compat/core.js +29 -0
  44. package/dist/compat/files.d.ts +1 -0
  45. package/dist/compat/files.js +25 -5
  46. package/dist/credentials.d.ts +3 -0
  47. package/dist/credentials.js +15 -0
  48. package/dist/daytona/computer-use.d.ts +13 -4
  49. package/dist/daytona/computer-use.js +66 -8
  50. package/dist/daytona/daytona.d.ts +7 -1
  51. package/dist/daytona/daytona.js +31 -4
  52. package/dist/daytona/errors.d.ts +11 -0
  53. package/dist/daytona/errors.js +18 -0
  54. package/dist/daytona/filesystem.js +2 -1
  55. package/dist/daytona/image.d.ts +13 -9
  56. package/dist/daytona/image.js +18 -12
  57. package/dist/daytona/index.d.ts +1 -1
  58. package/dist/daytona/sandbox.d.ts +8 -2
  59. package/dist/daytona/sandbox.js +16 -2
  60. package/dist/domains/index.d.ts +9 -0
  61. package/dist/domains/index.js +10 -0
  62. package/dist/e2b/commands.d.ts +1 -1
  63. package/dist/e2b/commands.js +6 -7
  64. package/dist/e2b/errors.d.ts +17 -0
  65. package/dist/e2b/errors.js +40 -1
  66. package/dist/e2b/index.d.ts +2 -2
  67. package/dist/e2b/index.js +1 -1
  68. package/dist/e2b/sandbox.d.ts +28 -22
  69. package/dist/e2b/sandbox.js +34 -8
  70. package/dist/e2b/unsupported.js +1 -1
  71. package/dist/errors.d.ts +11 -2
  72. package/dist/errors.js +17 -5
  73. package/dist/freestyle/index.d.ts +285 -37
  74. package/dist/freestyle/index.js +656 -144
  75. package/dist/identity.d.ts +3 -0
  76. package/dist/identity.js +27 -0
  77. package/dist/images/index.d.ts +9 -0
  78. package/dist/images/index.js +10 -0
  79. package/dist/index.d.ts +3 -1
  80. package/dist/ips/index.d.ts +9 -0
  81. package/dist/ips/index.js +10 -0
  82. package/dist/jobs/index.d.ts +9 -0
  83. package/dist/jobs/index.js +10 -0
  84. package/dist/keys.js +2 -0
  85. package/dist/login.js +2 -0
  86. package/dist/machine.d.ts +27 -4
  87. package/dist/machine.js +46 -14
  88. package/dist/mcp.js +144 -20
  89. package/dist/modal/index.d.ts +339 -61
  90. package/dist/modal/index.js +781 -121
  91. package/dist/network-cli.d.ts +3 -1
  92. package/dist/network-cli.js +78 -36
  93. package/dist/networks/index.d.ts +11 -0
  94. package/dist/networks/index.js +10 -0
  95. package/dist/ports/index.d.ts +9 -0
  96. package/dist/ports/index.js +10 -0
  97. package/dist/product-names.d.ts +52 -0
  98. package/dist/product-names.js +164 -0
  99. package/dist/product-renames.d.ts +103 -0
  100. package/dist/product-renames.js +189 -0
  101. package/dist/products/api-keys.d.ts +73 -0
  102. package/dist/products/api-keys.js +23 -0
  103. package/dist/products/apps.d.ts +157 -0
  104. package/dist/products/apps.js +60 -0
  105. package/dist/products/balancers.d.ts +87 -0
  106. package/dist/products/balancers.js +8 -0
  107. package/dist/products/billing.d.ts +41 -0
  108. package/dist/products/billing.js +13 -0
  109. package/dist/products/browser.d.ts +10 -0
  110. package/dist/products/browsers.d.ts +71 -0
  111. package/dist/products/browsers.js +102 -0
  112. package/dist/products/builds.d.ts +156 -0
  113. package/dist/products/builds.js +227 -0
  114. package/dist/products/index.d.ts +5 -16
  115. package/dist/products/index.js +5 -14
  116. package/dist/products/jobs.d.ts +19 -2
  117. package/dist/products/jobs.js +28 -11
  118. package/dist/products/logs.d.ts +245 -0
  119. package/dist/products/logs.js +274 -0
  120. package/dist/products/network-products.d.ts +38 -7
  121. package/dist/products/network-products.js +31 -18
  122. package/dist/products/observability.d.ts +8 -2
  123. package/dist/products/private-network.js +1 -1
  124. package/dist/products/queues.d.ts +149 -0
  125. package/dist/products/queues.js +213 -0
  126. package/dist/products/referrals.d.ts +21 -7
  127. package/dist/products/referrals.js +3 -3
  128. package/dist/products/resources.d.ts +54 -0
  129. package/dist/products/resources.js +122 -0
  130. package/dist/products/secrets.d.ts +29 -3
  131. package/dist/products/secrets.js +47 -18
  132. package/dist/products/signed-requests.d.ts +21 -0
  133. package/dist/products/signed-requests.js +11 -0
  134. package/dist/products/streams.d.ts +73 -0
  135. package/dist/products/streams.js +17 -0
  136. package/dist/products/switching.d.ts +35 -7
  137. package/dist/products/switching.js +4 -3
  138. package/dist/products/volumes.d.ts +6 -0
  139. package/dist/record-cli.d.ts +23 -0
  140. package/dist/record-cli.js +308 -0
  141. package/dist/renamed-products.d.ts +38 -0
  142. package/dist/renamed-products.js +34 -0
  143. package/dist/runloop/index.d.ts +9 -2
  144. package/dist/runloop/index.js +29 -8
  145. package/dist/runloop/sdk.d.ts +131 -0
  146. package/dist/runloop/sdk.js +167 -0
  147. package/dist/sandbox.d.ts +7 -2
  148. package/dist/sandbox.js +41 -11
  149. package/dist/sandboxes/index.d.ts +11 -0
  150. package/dist/sandboxes/index.js +12 -0
  151. package/dist/snapshots/index.d.ts +9 -0
  152. package/dist/snapshots/index.js +11 -0
  153. package/dist/snapshots.d.ts +7 -4
  154. package/dist/sprites/index.d.ts +343 -31
  155. package/dist/sprites/index.js +866 -84
  156. package/dist/ssh.d.ts +1 -1
  157. package/dist/ssh.js +11 -11
  158. package/dist/terminal.d.ts +24 -0
  159. package/dist/terminal.js +32 -0
  160. package/dist/transport.d.ts +18 -1
  161. package/dist/transport.js +80 -6
  162. package/dist/tunnel.js +1 -1
  163. package/dist/types.d.ts +71 -4
  164. package/dist/vercel/sandbox.d.ts +1 -1
  165. package/dist/vercel/sandbox.js +6 -4
  166. package/dist/vercel/snapshot.js +10 -2
  167. package/dist/volumes/index.d.ts +9 -0
  168. package/dist/volumes/index.js +10 -0
  169. package/dist/wallet.d.ts +4 -1
  170. package/dist/wallet.js +2 -1
  171. package/package.json +48 -14
package/dist/cli.js CHANGED
@@ -11,21 +11,27 @@ import { Sandbox as SandboxHandle } from "./sandbox.js";
11
11
  import { NotFoundError, RuntimeError } from "./errors.js";
12
12
  import { NETWORK_PRODUCTS, networkProductCommand } from "./network-cli.js";
13
13
  import { billingCommand } from "./billing-cli.js";
14
- import { cliRefusal, describeRecord, newestCpu, topicHelp } from "./cli-words.js";
14
+ import { cliRefusal, describeRecord, newestCpu, productWords, topicHelp } from "./cli-words.js";
15
15
  import { envFetch } from "./proxy.js";
16
+ import { logStream, terminalSafe, terminalSafeLines } from "./terminal.js";
16
17
  import { VERSION } from "./transport.js";
18
+ /** A log stream's text (a sandbox process's, an image build's, a job run's)
19
+ * as stdout shows it: escaped on a terminal unless --raw (terminal.ts). */
20
+ const logText = (args) => (text) => process.stdout.isTTY && !has(args, "raw") ? terminalSafeLines(text) : text;
17
21
  const HELP = `Runtime Cloud CLI ${VERSION}
18
22
 
19
23
  Usage: runtime <product> <command> [options] runtime <command> [options]
20
24
 
21
25
  Start
22
- sandbox run [--keep] [create options] -- <command>
26
+ sandboxes run [--keep] [create options] -- <command>
23
27
  A fresh sandbox runs one command, then stops
24
28
 
25
29
  Account
26
30
  login [--agent-name <name>] [--no-browser] [--wait]
27
31
  Connect this machine (no key to copy)
28
32
  login --with-key Or paste a key from withruntime.com/account/keys
33
+ billing topup <usd> --new-account --accept-terms [--wait]
34
+ No person? Open an account paid in USDC
29
35
  logout Revoke this machine's connection
30
36
  whoami The organization and agent you act as
31
37
  account [ls] The accounts this machine is connected to
@@ -36,16 +42,22 @@ Account
36
42
  The account's audit log (owner or admin keys)
37
43
  sso Single sign-on and SCIM directory sync (owner or
38
44
  admin keys); an owner changes it on the website
39
- usage [--csv] Balance, included usage and charges; --csv, each resource
45
+ usage [--csv] Balance, free usage and charges; --csv, each resource
40
46
  usage export --since <UTC time> --until <UTC time>
41
47
  Every settlement in the range as CSV; --json, each page
42
48
  limits Whether this key is read-only, and its daily spending limit
43
49
  secrets set <NAME> --host <host>... [--header <name> [--format '<text {value}>']]
44
50
  [--allow '[GET,HEAD] /path/*']... Store a secret sandboxes use without seeing it; the
45
- value is read from standard input, never an argument.
46
- --allow (paid): only these methods and paths get it
51
+ [--only-label key=value[,k=v]]... value is read from standard input, never an argument.
52
+ [--only-sandbox <id>]... --allow (paid): only these methods and paths get it.
53
+ [--all-sandboxes] --only-label: only sandboxes with those labels (each
54
+ flag one selector, any may match); --only-sandbox:
55
+ only that sandbox. A new value keeps the scope;
56
+ --all-sandboxes gives it to every sandbox again
47
57
  secrets set <NAME> --jobs [--host ...] A copy scheduled jobs put in their environment
48
58
  (job create --secret NAME); with --host, both
59
+ secrets scope <NAME> --only-label k=v... | --only-sandbox <id>... | --all-sandboxes
60
+ Change which sandboxes get it; no value needed
49
61
  secrets ls | get <NAME> Names, hosts, placeholders and jobs versions, never values
50
62
  secrets rotate <NAME> A new version of the jobs copy, from standard input
51
63
  secrets reveal <NAME> [--version N] Print the jobs copy's value. The sandboxes' copy is
@@ -53,13 +65,15 @@ Account
53
65
  secrets rm <NAME> Delete a secret, every copy
54
66
  keys create [--name <name>] [--read-only] [--daily-limit <usd>] [--account-wide]
55
67
  A new API key for CI, approved by an owner in the browser
56
- referrals Your referral link: you both get up to $500
68
+ referrals Your referral link: you both get up to $1,000, then
69
+ you earn 10% of what they spend for 12 months
57
70
  compare --from <provider> [--days 30] What your usage would cost at e2b, daytona, vercel,
58
71
  modal, cloudflare, fly, blaxel, lambda-microvms,
59
72
  freestyle, prime and others, and what you save
60
73
  switch [--from <provider>] Moving from e2b, daytona, vercel, modal, cloudflare, fly
61
- or blaxel: record it before your first top-up, and
62
- that top-up is matched, up to $100
74
+ or blaxel: record it before your first top-up.
75
+ Credit you add within 30 days of signing up is
76
+ matched, up to $1,000 in total
63
77
  ls Everything you run, every product
64
78
  feedback "<text>" [--kind bug|missing_feature|competitor_gap|migration_blocker|docs|pricing|praise|other]
65
79
  [--detail <text>] [--competitor <name>] [--request-id <req_...>]
@@ -72,13 +86,13 @@ Account
72
86
  mcp Serve MCP on stdio for agents that need it
73
87
 
74
88
  Connect
75
- sandbox ssh <id|name> [-- <command...>] Log in over SSH; \`runtime sandbox ssh help\` for editors
76
- sandbox ssh config [--install] Make \`ssh <id|name>.runtime\` work (VS Code, JetBrains)
77
- sandbox port-forward <id|name> <port|local:remote>...
89
+ sandboxes ssh <id|name> [-- <command...>] Log in over SSH; \`runtime sandboxes ssh help\` for editors
90
+ sandboxes ssh config [--install] Make \`ssh <id|name>.runtime\` work (VS Code, JetBrains)
91
+ sandboxes port-forward <id|name> <port|local:remote>...
78
92
  Forward local ports to ports in a sandbox
79
93
 
80
94
  Observability
81
- sandbox metrics <id> [--range 1h] CPU and memory over time (15m 1h 6h 24h 7d 30d)
95
+ sandboxes metrics <id> [--range 1h] CPU and memory over time (15m 1h 6h 24h 7d 30d)
82
96
  events [--sandbox <id>] [--type <type>] Lifecycle events, newest first
83
97
  webhooks ls | create <url> [--events a,b] | test <id> | deliveries <id>
84
98
  | update <id> [--url u] [--events a,b] [--enable|--disable]
@@ -88,22 +102,23 @@ Observability
88
102
  Events and metrics to an OpenTelemetry endpoint
89
103
 
90
104
  Products
91
- sandbox Linux microVMs: exec, shell, files, code, previews, network, desktop, browser
92
- snapshot Saved sandboxes, disk and memory, to start or fork from
93
- image Custom images from a Dockerfile, any public or private image, or a package list
94
- volume Persistent disks to attach to sandboxes
95
- job A command in a fresh sandbox, once or on a cron schedule, with its logs
96
- domain Your own hostname for a sandbox's port, with HTTPS
97
- port A public TCP port to a sandbox: databases, game servers
98
- address A dedicated outbound address, for allow-lists
99
- tunnel A WireGuard tunnel from your own network into your sandboxes
100
- network Account-wide network settings: your own proxy, sandboxes reaching each other by name
101
- Each has its own help: \`runtime sandbox help\`, \`runtime image help\`...
105
+ sandboxes Linux microVMs: exec, shell, files, code, previews, network, desktop, browser
106
+ snapshots Saved sandboxes, disk and memory, to start or fork from
107
+ images Custom images from a Dockerfile, any public or private image, or a package list
108
+ volumes Persistent disks to attach to sandboxes
109
+ jobs A command in a fresh sandbox, once or on a cron schedule, with its logs
110
+ domains Your own hostname for a sandbox's port, with HTTPS
111
+ ports A public TCP port to a sandbox: databases, game servers
112
+ ips A dedicated outbound address, for allow-lists
113
+ networks Your networks: a WireGuard tunnel in, your own proxy out, sandboxes reaching
114
+ each other by name
115
+ Each has its own help: \`runtime sandboxes help\`, \`runtime images help\`...
116
+ The singular (\`runtime sandbox\`) is a shortcut for the same product.
102
117
 
103
118
  Every command takes --json. Keys: RUNTIME_API_KEY, or the connection from \`runtime login\`.
104
119
  Behind a proxy: HTTPS_PROXY and NO_PROXY are read, on Node and Bun alike.
105
120
  `;
106
- const SANDBOX_HELP = `runtime sandbox <command>
121
+ const SANDBOX_HELP = `runtime sandboxes <command>
107
122
 
108
123
  Every <id> below can also be the sandbox's name.
109
124
 
@@ -112,7 +127,7 @@ const SANDBOX_HELP = `runtime sandbox <command>
112
127
  port-forward <id|name> <port|local:remote>... Forward local ports to ports in it
113
128
  create [--name n] [--label k=v]... [--env K=V]... [--vcpu 2] [--memory 4096] [--disk 4096]
114
129
  [--cpu shared|reserved] [--cpu-floor <thousandths>] [--max-cost <usd>] [--max-total-cost <usd>]
115
- [--timeout <seconds>] [--on-timeout pause|stop] [--trial|--paid] [--no-wait]
130
+ [--timeout <60-86400>] [--on-timeout pause|stop|delete] [--no-wait]
116
131
  [--no-internet] [--allow <host>]... [--deny <host>]... [--connect <host:port>]...
117
132
  [--image <name:tag|imageId> | --snapshot <snapshotId>] [--volume <volumeId>:/data[:snapshot]]...
118
133
  [--idle-pause <seconds>] [--no-auto-wake] [--persistent] [--get-or-create]
@@ -123,7 +138,9 @@ const SANDBOX_HELP = `runtime sandbox <command>
123
138
  Run and stream output; exits with its code
124
139
  spawn <id> [--pty] -- <command...> Start in the background; prints a process id
125
140
  ps <id> Running and recent processes
126
- logs <id> <processId> [-f] A process's output so far; -f follows it to the end
141
+ logs <id> <processId> [-f] [--raw] A process's output so far; -f follows it to the end;
142
+ on a terminal control characters show as \\uXXXX
143
+ unless --raw
127
144
  kill <id> <processId> [--signal SIGTERM]
128
145
  shell <id> [--command "bash -l"] An interactive terminal
129
146
  cp <src> <dst> Copy files or directories; name a sandbox
@@ -134,6 +151,8 @@ const SANDBOX_HELP = `runtime sandbox <command>
134
151
  stop --label k=v [--label k=v]... Stop every live sandbox with all those labels
135
152
  rm <id> Delete it for good: stops it, deletes its disk and
136
153
  paused memory, revokes its previews; snapshots stay
154
+ rm --label k=v [--label k=v]... Delete for good every sandbox with all those labels,
155
+ stopped ones too
137
156
  switch-image <id> <image> --keep-workspace Move it to another image, keeping /workspace (your home),
138
157
  volumes, env and previews; processes restart, the rest of
139
158
  its disk is lost (snapshot it first to keep everything)
@@ -144,7 +163,7 @@ const SANDBOX_HELP = `runtime sandbox <command>
144
163
  update <id> [--name n] [--label k=v]... [--env K=V]... [--unset-env K]... [--idle-pause <seconds>] [--auto-wake on|off]
145
164
  [--persistent on|off] [--max-total-cost <usd>|none]
146
165
  Change its settings; persistent keeps it running until stopped
147
- snapshot <id> [--name n] [--retention days] Keep its whole machine; prints the snapshot id
166
+ snapshot <id> [--name n] [--retention days|credit] Keep its whole machine; prints the snapshot id
148
167
  mount <id> <s3|r2|gcs>://<bucket>[/prefix] <path> [--secret NAME] [--region r]
149
168
  [--endpoint https://...] [--account-id id] [--read-only]
150
169
  Your bucket as a directory; the proxy signs, the sandbox never sees the key
@@ -152,7 +171,7 @@ const SANDBOX_HELP = `runtime sandbox <command>
152
171
  tailscale up <id> --auth-key-secret NAME [--hostname h] [--tag tag:x]...
153
172
  Join your tailnet (paid); the key is a secret set with --jobs
154
173
  tailscale status <id> | tailscale down <id> Its tailnet address; take it off the tailnet
155
- fork <id> [--count 3] [--name n] [--keep-snapshot] [--trial|--paid]
174
+ fork <id> [--count 3] [--name n] [--keep-snapshot]
156
175
  Copies of it as it is now, running; prints their ids
157
176
  run-code <id> <file|-> [--lang python|javascript|typescript|r|java|bash|go] [--context c] [--out-dir .]
158
177
  Run a notebook-style cell; charts saved as PNG
@@ -188,10 +207,10 @@ const SANDBOX_HELP = `runtime sandbox <command>
188
207
  Chromium for CDP agents: prints its cdpUrl
189
208
 
190
209
  Examples
191
- id=$(runtime sandbox create)
192
- runtime sandbox cp ./invoice.py $id:/workspace/invoice.py
193
- runtime sandbox exec $id -- python3 /workspace/invoice.py
194
- runtime sandbox stop $id
210
+ id=$(runtime sandboxes create)
211
+ runtime sandboxes cp ./invoice.py $id:/workspace/invoice.py
212
+ runtime sandboxes exec $id -- python3 /workspace/invoice.py
213
+ runtime sandboxes stop $id
195
214
  `;
196
215
  /** A command's options. Reading one its parse() did not declare is a bug in
197
216
  * this file, and says so, so a test that runs the command finds it. */
@@ -233,7 +252,14 @@ commandAfter) {
233
252
  break;
234
253
  }
235
254
  if (arg.startsWith("--") || (arg.startsWith("-") && arg.length === 2 && !/^-\d/.test(arg))) {
236
- const [name, inline] = arg.replace(/^-+/, "").split(/=(.*)/s, 2);
255
+ const [typed, inline] = arg.replace(/^-+/, "").split(/=(.*)/s, 2);
256
+ // A plural of an option is the option once per comma: --hosts a,b is
257
+ // --host a --host b (13 October 2026: Marc's first try at a secret).
258
+ const plural = !known.has(typed) &&
259
+ typed.endsWith("s") &&
260
+ LISTS.has(typed.slice(0, -1)) &&
261
+ known.has(typed.slice(0, -1));
262
+ const name = plural ? typed.slice(0, -1) : typed;
237
263
  if (!known.has(name))
238
264
  throw commandAfter && !meantOption(name, known)
239
265
  ? usage(`Unknown option ${arg.split("=")[0]}. If it belongs to the command, put the command after --: ${commandAfter}`)
@@ -245,13 +271,16 @@ commandAfter) {
245
271
  throw usage(`--${name} needs a value.`);
246
272
  if (boolean && value !== "true" && value !== "false")
247
273
  throw usage(`--${name} takes true or false.`);
248
- flags.set(name, [...(flags.get(name) ?? []), value]);
274
+ const values = plural ? value.split(",").filter(Boolean) : [value];
275
+ flags.set(name, [...(flags.get(name) ?? []), ...values]);
249
276
  }
250
277
  else
251
278
  positional.push(arg);
252
279
  }
253
280
  return { positional, flags, rest };
254
281
  }
282
+ /** Options given once per item, which a plural takes as a comma list. */
283
+ const LISTS = new Set(["host", "allow", "deny", "connect"]);
255
284
  /** The declared option a mistyped one most likely meant. --memory-mib for
256
285
  * --memory, --timeout-seconds for --timeout: the SDK's spelling of an option is
257
286
  * the CLI's with its unit added. */
@@ -270,6 +299,8 @@ function unknownOption(name, known) {
270
299
  }
271
300
  const flag = (args, name) => args.flags.get(name)?.at(-1);
272
301
  const has = (args, name) => args.flags.get(name)?.at(-1) === "true";
302
+ /** Days, or the word `credit` for null: kept while the account has credit. */
303
+ const daysOrCredit = (args, name) => flag(args, name) === "credit" ? null : integer(args, name);
273
304
  const integer = (args, name) => {
274
305
  const value = flag(args, name);
275
306
  if (value === undefined)
@@ -360,16 +391,15 @@ const COMMANDS = [
360
391
  "support",
361
392
  "docs",
362
393
  "mcp",
363
- "sandbox",
364
- "snapshot",
365
- "image",
366
- "volume",
367
- "job",
368
- "domain",
369
- "port",
370
- "address",
371
- "tunnel",
372
- "network",
394
+ "sandboxes",
395
+ "snapshots",
396
+ "images",
397
+ "volumes",
398
+ "jobs",
399
+ "domains",
400
+ "ports",
401
+ "ips",
402
+ "networks",
373
403
  "webhooks",
374
404
  "otel",
375
405
  "events",
@@ -378,30 +408,20 @@ const COMMANDS = [
378
408
  ];
379
409
  /** Commands with a help of their own: `runtime <command> help`. */
380
410
  const OWN_HELP = [
381
- "sandbox",
382
411
  "sandboxes",
383
412
  "sbx",
384
- "snapshot",
385
413
  "snapshots",
386
- "image",
387
414
  "images",
388
- "volume",
389
415
  "volumes",
390
- "job",
391
416
  "jobs",
392
417
  "keys",
393
418
  "webhooks",
394
419
  "webhook",
395
420
  "billing",
396
- "domain",
397
421
  "domains",
398
- "port",
399
422
  "ports",
400
- "address",
401
- "addresses",
402
- "tunnel",
403
- "tunnels",
404
- "network",
423
+ "ips",
424
+ "networks",
405
425
  ];
406
426
  /** Sandbox commands that once sat at the top level. */
407
427
  const SANDBOX_MOVED = ["run", "ssh", "port-forward", "forward"];
@@ -506,6 +526,14 @@ function table(rows) {
506
526
  .trimEnd())
507
527
  .join("\n");
508
528
  }
529
+ /** When a sandbox ends by itself (0300, 0381). A paid-ahead lease, expiresAt,
530
+ * is not its time limit; only an older server, which sends no endsAt, gives
531
+ * the end of a limited sandbox's time as expiresAt. */
532
+ function sandboxEnd(info) {
533
+ if (info.endsAt === null || (info.endsAt === undefined && !info.timeoutSeconds))
534
+ return "no limit";
535
+ return info.endsAt ?? info.expiresAt ?? "-";
536
+ }
509
537
  function describe(info) {
510
538
  return table([
511
539
  ["id", info.id],
@@ -515,19 +543,9 @@ function describe(info) {
515
543
  "shape",
516
544
  `${info.vcpu} vCPU, ${info.memoryMiB} MiB memory, ${info.diskMiB} MiB disk (${info.cpu})`,
517
545
  ],
518
- ["funding", info.funding],
519
546
  ["region", info.region],
520
- // A sandbox with no time limit runs while it works (0300): endsAt is
521
- // null. An older server sends no endsAt; its expiresAt was the end.
522
547
  ["time limit", info.timeoutSeconds ? `${info.timeoutSeconds} s` : "none"],
523
- [
524
- "ends",
525
- info.endsAt === null
526
- ? info.state === "running"
527
- ? "when idle, stopped or out of credit"
528
- : "-"
529
- : (info.endsAt ?? info.expiresAt ?? "-"),
530
- ],
548
+ ["ends", sandboxEnd(info)],
531
549
  ["at its end", info.onLeaseEnd ?? "-"],
532
550
  // Why a sandbox is no longer running (troubleshooting.md, "A sandbox
533
551
  // stopped on its own"); `get` left both out until 30 September 2026.
@@ -560,17 +578,22 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
560
578
  // Only what comes before -- is the CLI's: a --json after it is the
561
579
  // command's, whether or not the CLI was given its own.
562
580
  const dash = argv.indexOf("--");
563
- const [first, ...remaining] = argv.filter((arg, index) => arg !== "--json" || (dash !== -1 && index > dash));
581
+ // CLOUD.md section 4: the plural names the product, the singular is a
582
+ // typing shortcut, and an old word works for 30 days with a warning.
583
+ const words = productWords(argv.filter((arg, index) => arg !== "--json" || (dash !== -1 && index > dash)));
584
+ if (words.warning)
585
+ out.error(`Warning: ${named(words.warning)}`);
586
+ const [first, ...remaining] = words.argv;
564
587
  // Sandbox is one product of several, so its commands live under it
565
- // (AGENTS.md, 23 September 2026): `runtime sandbox run`, `sandbox ssh` and
588
+ // (AGENTS.md, 23 September 2026): `runtime sandboxes run`, `sandbox ssh` and
566
589
  // `sandbox port-forward`. The old top-level forms were removed, not aliased.
567
590
  if (first && SANDBOX_MOVED.includes(first))
568
- throw usage(`\`${me} ${first}\` is now \`${me} sandbox ${first === "forward" ? "port-forward" : first}\`.`);
591
+ throw usage(`\`${me} ${first}\` is now \`${me} sandboxes ${first === "forward" ? "port-forward" : first}\`.`);
569
592
  const nested = (first === "sandbox" || first === "sandboxes" || first === "sbx") &&
570
593
  SANDBOX_MOVED.includes(remaining[0] ?? "");
571
594
  const command = nested ? remaining[0] : first;
572
595
  const rest = nested ? remaining.slice(1) : remaining;
573
- // `runtime sandbox create --help` is how an agent asks what a command takes:
596
+ // `runtime sandboxes create --help` is how an agent asks what a command takes:
574
597
  // answer with the product's help, never by running the command.
575
598
  const cut = remaining.indexOf("--");
576
599
  const asked = (cut < 0 ? remaining : remaining.slice(0, cut)).some((arg) => arg === "--help" || arg === "-h") ||
@@ -603,10 +626,10 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
603
626
  ? JSON.stringify({ connected, usage: HELP })
604
627
  : `Runtime Cloud ${VERSION}. ${start}
605
628
 
606
- ${me} sandbox run -- python3 < invoice.py
629
+ ${me} sandboxes run -- python3 < invoice.py
607
630
  Run a local invoice script, then stop the sandbox
608
- ${me} sandbox create A sandbox that stays; prints its id
609
- ${me} sandbox exec <id> -- <command> Run in it
631
+ ${me} sandboxes create A sandbox that stays; prints its id
632
+ ${me} sandboxes exec <id> -- <command> Run in it
610
633
  ${me} help Everything else
611
634
  `);
612
635
  return 0;
@@ -616,14 +639,15 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
616
639
  return 0;
617
640
  }
618
641
  if (command === "run") {
619
- const args = parse(rest, [...CREATE_FLAGS, "keep"], [...CREATE_VALUES, "cwd", "env"], `${me} sandbox run -- python3 < invoice.py`);
642
+ const args = parse(rest, [...CREATE_FLAGS, "keep"], [...CREATE_VALUES, "cwd", "env"], `${me} sandboxes run -- python3 < invoice.py`);
620
643
  const commandLine = args.rest ?? args.positional;
621
644
  if (!commandLine.length)
622
- throw usage(`Give the command after --: ${me} sandbox run -- python3 < invoice.py`);
645
+ throw usage(`Give the command after --: ${me} sandboxes run -- python3 < invoice.py`);
623
646
  const runtime = await client(env);
624
647
  const timeout = integer(args, "timeout");
625
648
  const { timeoutSeconds: _lease, ...input } = createInput(args);
626
- const sbx = await runtime.sandboxes.create(input, { onCapacityWait: waiting(out) });
649
+ const onCapacityWait = waiting(out);
650
+ const sbx = await withVolumeRelease((options) => runtime.sandboxes.create(input, { ...options, onCapacityWait }), out);
627
651
  let cleanup;
628
652
  // SIGINT and normal completion share one stop. Keep an absolute deadline
629
653
  // across the stop retries and final state check, so cleanup cannot hang.
@@ -642,7 +666,7 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
642
666
  code: "cleanup_failed",
643
667
  status: 0,
644
668
  message: `Could not confirm that sandbox ${sbx.id} stopped. ${error instanceof Error ? error.message : String(error)}`,
645
- hint: `Check it and retry cleanup with: ${me} sandbox stop ${sbx.id}`,
669
+ hint: `Check it and retry cleanup with: ${me} sandboxes stop ${sbx.id}`,
646
670
  cause: error,
647
671
  }), out.json));
648
672
  return false;
@@ -658,7 +682,7 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
658
682
  finally {
659
683
  try {
660
684
  if (has(args, "keep"))
661
- out.error(`Kept sandbox ${sbx.id}. Run more with: ${me} sandbox exec ${sbx.id} -- <command>`);
685
+ out.error(`Kept sandbox ${sbx.id}. Run more with: ${me} sandboxes exec ${sbx.id} -- <command>`);
662
686
  else if (!(await stop()) && code === 0)
663
687
  code = 1;
664
688
  }
@@ -702,11 +726,11 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
702
726
  ...(spend.pilot ? [pilotFunding(spend.pilot)] : []),
703
727
  ...(trialLeft > 0
704
728
  ? [
705
- `included usage, ${trialLeft.toLocaleString("en-US", { maximumFractionDigits: 1 })} hours left without credit`,
729
+ `free usage, ${trialLeft.toLocaleString("en-US", { maximumFractionDigits: 1 })} hours left without credit`,
706
730
  ]
707
731
  : []),
708
732
  ...(credit > 0n ? [`${dollars(credit)} of credit`] : []),
709
- ].join("; ") || "no included usage or credit left: runtime billing";
733
+ ].join("; ") || "no free usage or credit left: runtime billing";
710
734
  print(table([
711
735
  ["organization", who.orgName ? `${who.orgName} (${who.orgId})` : who.orgId],
712
736
  ...(who.role ? [["role", who.role]] : []),
@@ -881,7 +905,7 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
881
905
  product = "billing";
882
906
  const origins = connectionOrigins(env);
883
907
  const { claimWalletAccount, openWalletAccount } = await import("./wallet.js");
884
- return billingCommand(rest, parse(rest, ["wait", "new-account", "accept-terms"], []), out, () => client(env), undefined, {
908
+ return billingCommand(rest, parse(rest, ["wait", "new-account", "accept-terms"], ["below", "amount", "monthly"]), out, () => client(env), undefined, {
885
909
  open: (input) => {
886
910
  checkBrowserOrigins(origins);
887
911
  return openWalletAccount(input, { authUrl: origins.auth });
@@ -905,21 +929,24 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
905
929
  });
906
930
  }
907
931
  if (command === "referrals") {
932
+ parse(rest, [], []);
908
933
  const r = await (await client(env)).referrals.get();
909
934
  const usd = (micros) => `$${(Number(BigInt(micros) / 10000n) / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
910
935
  print([
911
936
  `Your link: ${r.link}`,
912
937
  "",
913
- `When a company signs up with it and makes its first top-up of ${usd(r.minPurchaseMicros)} or more,`,
914
- `you each get credit equal to that top-up: at least ${usd(r.rewardMicros)}, at most ${usd(r.maxRewardMicros)}`,
915
- `(up to ${usd(r.yearlyCapMicros)} a year for you).`,
938
+ `A company that signs up with it has each payment in its first ${r.windowDays} days matched, up to ${usd(r.maxRewardMicros)},`,
939
+ `and you get the same; its first payment of ${usd(r.minPurchaseMicros)} or more gives each of you at least ${usd(r.rewardMicros)}.`,
940
+ `Then for ${r.shareMonths} months you earn ${r.shareBasisPoints / 100}% of what it spends from credit it paid for`,
941
+ `(up to ${usd(r.yearlyCapMicros)} a year for you, matches and share together).`,
916
942
  ...(r.enabled ? [] : ["The program is paused: new sign-ups are not counted right now."]),
917
943
  "",
918
944
  table([
919
945
  ["signed up", String(r.signedUp)],
920
- ["waiting for a first top-up", String(r.pending)],
921
- ["paid", String(r.paid)],
946
+ ["waiting for a first payment", String(r.pending)],
947
+ ["paying", String(r.payingCompanies)],
922
948
  ["earned", usd(r.earnedMicros)],
949
+ ["of which share", usd(r.shareEarnedMicros)],
923
950
  [`left this year (${r.year})`, usd(r.capRemainingMicros)],
924
951
  ]),
925
952
  ].join("\n"), r);
@@ -1001,7 +1028,7 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
1001
1028
  ]);
1002
1029
  print(rows.length
1003
1030
  ? table([["ID", "PRODUCT", "NAME", "STATE", "CREATED"], ...rows])
1004
- : "Nothing yet. Start with: runtime sandbox create", items);
1031
+ : "Nothing yet. Start with: runtime sandboxes create", items);
1005
1032
  return 0;
1006
1033
  }
1007
1034
  if (command === "ssh") {
@@ -1032,15 +1059,17 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
1032
1059
  product = "job";
1033
1060
  return jobCommand(rest, env, out);
1034
1061
  }
1035
- const networkProduct = { domains: "domain", ports: "port", addresses: "address", tunnels: "tunnel" }[command] ??
1036
- command;
1037
- if (NETWORK_PRODUCTS.includes(networkProduct)) {
1062
+ // WireGuard is inside networks: `runtime networks wireguard peer add`.
1063
+ const wireguard = command === "networks" && rest[0] === "wireguard";
1064
+ const networkProduct = wireguard ? "wireguard" : command;
1065
+ const networkRest = wireguard ? rest.slice(1) : rest;
1066
+ if (command !== "wireguard" && NETWORK_PRODUCTS.includes(networkProduct)) {
1038
1067
  product = networkProduct;
1039
- return networkProductCommand(networkProduct, rest, parse(rest, ["ipv6"], ["sandbox", "subnet", "public-key", "route", "out", "host", "secret"]), out, () => client(env));
1068
+ return networkProductCommand(networkProduct, networkRest, parse(networkRest, ["ipv6"], ["sandbox", "subnet", "public-key", "route", "out", "host", "secret"]), out, () => client(env));
1040
1069
  }
1041
1070
  throw unknown("command", command, COMMANDS);
1042
1071
  }
1043
- /** `runtime usage` for a person: the balance, the included usage and what each kind
1072
+ /** `runtime usage` for a person: the balance, the free usage and what each kind
1044
1073
  * of resource was charged, in dollars. --json keeps every figure, and each
1045
1074
  * resource with its rates and CPU time. */
1046
1075
  /** One row per resource, for a spreadsheet: when it ran, how big it was, how
@@ -1095,15 +1124,15 @@ export function usageCsv(u) {
1095
1124
  /** Milliseconds as hours, to a tenth: "8,731.2". */
1096
1125
  const hours = (ms) => (ms / 3_600_000).toLocaleString("en-US", { maximumFractionDigits: 1 });
1097
1126
  /** The machine time left without credit, as `runtime usage` and `runtime
1098
- * limits` say it: this month's included usage, as hours at 2 vCPU and 4 GiB,
1127
+ * limits` say it: this month's free usage, as hours at 2 vCPU and 4 GiB,
1099
1128
  * or for an account given free hours before 5 October 2026, those hours too. */
1100
1129
  function trialLeft(trial) {
1101
1130
  const renews = trial.renewsAt ? `, renews ${trial.renewsAt.slice(0, 10)}` : "";
1102
1131
  return trial.totalMs > 0
1103
- ? `${hours(trial.availableMs)} hours left (${hours(trial.totalMs)} free hours given before 5 October 2026, and the included usage${renews})`
1132
+ ? `${hours(trial.availableMs)} hours left (${hours(trial.totalMs)} free hours given before 5 October 2026, and the free usage${renews})`
1104
1133
  : `${hours(trial.availableMs)} hours at 2 vCPU and 4 GiB left this month${renews}`;
1105
1134
  }
1106
- /** This month's included usage, one row per product, as `runtime usage`
1135
+ /** This month's free usage, one row per product, as `runtime usage`
1107
1136
  * says it. */
1108
1137
  function allowanceRows(allowances) {
1109
1138
  const amount = (n) => n.toLocaleString("en-US", { maximumFractionDigits: 1 });
@@ -1112,6 +1141,43 @@ function allowanceRows(allowances) {
1112
1141
  `${amount(a.left)} of ${amount(a.quantity)} ${a.unit}s left`,
1113
1142
  ]);
1114
1143
  }
1144
+ /** What Runtime for Startups includes as `runtime usage` says it, in the
1145
+ * console's words: machine time left of what it gave, storage as the GB kept
1146
+ * free each month and the GB kept now, and what it runs at once. */
1147
+ function potRows(pot, limits) {
1148
+ const amount = (n) => n.toLocaleString("en-US", { maximumFractionDigits: 1 });
1149
+ const day = (iso) => {
1150
+ const at = new Date(iso);
1151
+ const month = at.toLocaleString("en-US", { month: "short", timeZone: "UTC" });
1152
+ return `${at.getUTCDate()} ${month} ${at.getUTCFullYear()}`;
1153
+ };
1154
+ const NAMES = {
1155
+ "machine-cpu": "vCPU-hours",
1156
+ "machine-memory": "memory",
1157
+ "saved-copies": "saved copies kept free",
1158
+ disks: "volumes",
1159
+ };
1160
+ const ORDER = ["machine-cpu", "machine-memory", "saved-copies", "disks"];
1161
+ const rank = (pool) => (ORDER.includes(pool) ? ORDER.indexOf(pool) : ORDER.length);
1162
+ const rows = [...pot]
1163
+ .sort((a, b) => rank(a.pool) - rank(b.pool))
1164
+ .map((p) => {
1165
+ const name = `${p.period === "month" ? "Runtime for Startups" : "startup hours"}: ${NAMES[p.pool] ?? p.pool.replace(/-/g, " ")}`;
1166
+ const until = p.expiresAt ? ` until ${day(p.expiresAt)}` : "";
1167
+ if (p.period === "month") {
1168
+ const kept = p.keptGB ?? 0;
1169
+ const over = kept > p.quantity ? `; past ${amount(p.quantity)} GB bills at the published rate` : "";
1170
+ return [
1171
+ name,
1172
+ `${amount(p.quantity)} GB kept free${until} · ${amount(kept)} GB kept now${over}`,
1173
+ ];
1174
+ }
1175
+ return [name, `${amount(p.left ?? 0)} of ${amount(p.quantity)} ${p.unit}s left${until}`];
1176
+ });
1177
+ if (limits)
1178
+ rows.push(["Runtime for Startups: sandboxes at once", amount(limits.sandboxesAtOnce)], ["Runtime for Startups: image builds at once", amount(limits.buildsAtOnce)]);
1179
+ return rows;
1180
+ }
1115
1181
  /** What a pilot gives, as `runtime whoami` and `runtime limits` say it: the
1116
1182
  * sandboxes it runs free and until when, past which credit does not run
1117
1183
  * them. */
@@ -1155,6 +1221,16 @@ function usageSummary(u) {
1155
1221
  rows.push(["free time", trialLeft(u.trial)]);
1156
1222
  if (u.allowances)
1157
1223
  rows.push(...allowanceRows(u.allowances));
1224
+ // Only a data property is an older server's answer. Do not invoke SDK
1225
+ // compatibility getters when a server sends neither generation of fields.
1226
+ const pot = "startupIncluded" in u
1227
+ ? u.startupIncluded
1228
+ : Object.getOwnPropertyDescriptor(u, "pot")?.value;
1229
+ const potLimits = "startupLimits" in u
1230
+ ? u.startupLimits
1231
+ : Object.getOwnPropertyDescriptor(u, "potLimits")?.value;
1232
+ if (pot?.length || potLimits)
1233
+ rows.push(...potRows(pot ?? [], potLimits));
1158
1234
  if (u.pilot)
1159
1235
  rows.push(["pilot sandbox hours", pilotLeft(u.pilot)]);
1160
1236
  if (u.outbound) {
@@ -1233,10 +1309,21 @@ async function outputSoFar(runtime, sandboxId, processId) {
1233
1309
  processId,
1234
1310
  state: latest?.state ?? null,
1235
1311
  exitCode: latest?.exitCode ?? null,
1312
+ ...(latest?.outOfMemory ? { outOfMemory: latest.outOfMemory } : {}),
1236
1313
  truncated,
1237
1314
  chunks,
1238
1315
  };
1239
1316
  }
1317
+ /** What a person reads when the sandbox's out-of-memory killer ended a
1318
+ * command: the API's message and hint, on standard error. A bare exit -9 (or
1319
+ * 137) said nothing of memory until 9 October 2026. */
1320
+ export function outOfMemoryLines(memory) {
1321
+ // The words come from an answer, not this CLI: escaped like a log line, so
1322
+ // nothing in them can drive the terminal (terminal.ts).
1323
+ return memory && typeof memory.message === "string" && typeof memory.hint === "string"
1324
+ ? `runtime: ${terminalSafe(memory.message)}\nHint: ${terminalSafe(memory.hint)}`
1325
+ : undefined;
1326
+ }
1240
1327
  /** Dollars from integer microdollars: cents from a dollar up, four places
1241
1328
  * below one, and every place below a hundredth of a cent, so a short trial
1242
1329
  * test never reads as $0.0000. */
@@ -1309,36 +1396,60 @@ const longDate = (iso) => new Date(iso).toLocaleDateString("en-GB", {
1309
1396
  year: "numeric",
1310
1397
  timeZone: "UTC",
1311
1398
  });
1312
- /** One line on the switching credit: how to claim it, or what it did. A
1313
- * switch already on record is named with its provider and date, so it never
1399
+ /** One line on the welcome match: while it is open, how much of it credit
1400
+ * added now can still get; after, what it got. Undefined when there is
1401
+ * nothing to say: the offer ended unused. */
1402
+ function welcomeLine(summary) {
1403
+ const w = summary.welcome;
1404
+ // A server from before the welcome match (0433) answers without it.
1405
+ if (!w)
1406
+ return undefined;
1407
+ const whole = (micros) => dollars(micros).replace(/\.00$/, "");
1408
+ if (w.eligible) {
1409
+ // A server from before 0435 answers without what is left.
1410
+ const left = w.leftMicros ?? (BigInt(w.maxMicros) - BigInt(w.creditMicros)).toString();
1411
+ return BigInt(w.creditMicros) > 0n
1412
+ ? `${whole(w.creditMicros)} of ${whole(w.maxMicros)} matched so far; credit you add by ${longDate(w.endsAt)} is matched, up to ${whole(left)} more.`
1413
+ : `Credit you add by ${longDate(w.endsAt)} is matched, up to ${whole(w.maxMicros)} in total.`;
1414
+ }
1415
+ switch (w.status) {
1416
+ case "paid":
1417
+ return `Your credit was matched: ${dollars(w.creditMicros)} of credit.`;
1418
+ case "superseded":
1419
+ return "Your payments are matched by your referral instead, which is never less.";
1420
+ case "reversed":
1421
+ return "The payments that were matched were refunded, so the match was taken back.";
1422
+ case "refused":
1423
+ return "Your credit was not matched. Ask support if you think that is wrong.";
1424
+ }
1425
+ return undefined;
1426
+ }
1427
+ /** What `switch` says: the switch on record, if any, and the welcome match.
1428
+ * A switch already on record is named with its provider and date, so it never
1314
1429
  * reads as if this command recorded it, or recorded the provider it names. */
1315
1430
  function switchingLine(summary, provider) {
1316
- const cap = dollars(summary.maxMicros);
1317
1431
  const sw = summary.switch;
1432
+ const welcome = welcomeLine(summary);
1318
1433
  if (!sw) {
1319
- if (!summary.eligible || !provider)
1320
- return undefined;
1321
- return `Switching from ${RIVAL_NAMES[provider]}? Run \`${me} switch --from ${provider}\` before your first top-up and we match that top-up, up to ${cap}.`;
1434
+ if (!provider || !summary.eligible)
1435
+ return welcome;
1436
+ return [
1437
+ `Switching from ${RIVAL_NAMES[provider]}? Run \`${me} switch --from ${provider}\` before your first top-up to record it.`,
1438
+ welcome,
1439
+ ]
1440
+ .filter(Boolean)
1441
+ .join(" ");
1322
1442
  }
1323
1443
  const from = RIVAL_NAMES[sw.provider];
1324
- const recorded = `This organization's switch from ${from} was recorded on ${longDate(sw.recordedAt)}`;
1325
1444
  const once = provider && provider !== sw.provider
1326
1445
  ? ` A switch is recorded once, so it stays ${from}, not ${RIVAL_NAMES[provider]}.`
1327
1446
  : "";
1328
- switch (sw.status) {
1329
- case "pending":
1330
- return `${recorded}: its first top-up is matched, up to ${cap}, once the payment settles.${once}`;
1331
- case "paid":
1332
- return `${recorded} and matched its first top-up: ${dollars(sw.creditMicros)} of credit.${once}`;
1333
- case "superseded":
1334
- return `${recorded}; its first top-up was matched by a referral instead, which is never less than the switching credit.${once}`;
1335
- case "reversed":
1336
- return `${recorded}; the top-up it matched was refunded, so the credit was taken back.${once}`;
1337
- case "refused":
1338
- return sw.refusedReason === "program_off"
1339
- ? `${recorded}; the switching credit was paused when its first top-up arrived.${once}`
1340
- : `${recorded}; its first top-up was not matched. Ask support if you think that is wrong.${once}`;
1341
- }
1447
+ return [
1448
+ `This organization's switch from ${from} was recorded on ${longDate(sw.recordedAt)}.${once}`,
1449
+ welcome,
1450
+ ]
1451
+ .filter(Boolean)
1452
+ .join(" ");
1342
1453
  }
1343
1454
  /** Running time in the largest unit that keeps it above zero: a short
1344
1455
  * trial test is seconds, not "0 hours". */
@@ -1378,10 +1489,13 @@ async function switching(command, argv, env, out) {
1378
1489
  if (command === "switch") {
1379
1490
  if (!from) {
1380
1491
  const summary = await api.get();
1381
- print(switchingLine(summary) ??
1382
- (summary.eligible
1383
- ? `Moving from a rival? Run \`${me} switch --from <provider>\` (${summary.providers.join(", ")}) before your first top-up, and that top-up is matched, up to ${dollars(summary.maxMicros)}.`
1384
- : "The switching credit matches a first top-up, and this organization has made one already."), summary);
1492
+ const record = summary.eligible
1493
+ ? `Moving from a rival? Run \`${me} switch --from <provider>\` (${summary.providers.join(", ")}) before your first top-up to record it.`
1494
+ : undefined;
1495
+ print((summary.switch
1496
+ ? switchingLine(summary)
1497
+ : [record, welcomeLine(summary)].filter(Boolean).join(" ")) ||
1498
+ "A switch is recorded before the first top-up, and this organization has made one already.", summary);
1385
1499
  return 0;
1386
1500
  }
1387
1501
  const provider = SWITCH_FROM[from];
@@ -1389,7 +1503,7 @@ async function switching(command, argv, env, out) {
1389
1503
  throw unknown("provider", from, Object.keys(RIVAL_NAMES));
1390
1504
  const summary = await api.record({ provider });
1391
1505
  print([
1392
- `Recorded: switching from ${RIVAL_NAMES[provider]}. ${switchingLine(summary) ?? ""}`.trim(),
1506
+ `Recorded: switching from ${RIVAL_NAMES[provider]}. ${welcomeLine(summary) ?? ""}`.trim(),
1393
1507
  ...dropInLines(provider),
1394
1508
  ].join("\n"), summary);
1395
1509
  return 0;
@@ -1504,12 +1618,34 @@ async function sandbox(argv, env, out) {
1504
1618
  const use = async (id) => SANDBOX_UUID.test(id ?? "")
1505
1619
  ? new SandboxHandle(runtime.transport, { id })
1506
1620
  : get(id);
1621
+ /* `stop --label` and `rm --label`: every sandbox with all the labels, each
1622
+ named with what happened to it; exits 1 if any failed. */
1623
+ const byLabel = (words) => words.some((word) => /^--label(=|$)/.test(word));
1624
+ const everyLabelled = async (act, words) => {
1625
+ const args = parse(words, [], ["label"]);
1626
+ if (args.positional.length)
1627
+ throw usage("Give a sandbox id or --label, not both.");
1628
+ const labels = pairs(args, "label");
1629
+ const { done, failed } = act === "stop"
1630
+ ? await runtime.sandboxes.stopAll({ labels }).then((r) => ({ ...r, done: r.stopped }))
1631
+ : await runtime.sandboxes.deleteAll({ labels }).then((r) => ({ ...r, done: r.deleted }));
1632
+ const past = act === "stop" ? "stopped" : "deleted";
1633
+ const said = (error) => (error instanceof Error ? error.message : String(error));
1634
+ print([
1635
+ done.length || failed.length
1636
+ ? `${done.length} ${past}${failed.length ? `, ${failed.length} failed` : ""}.`
1637
+ : `No ${act === "stop" ? "live " : ""}sandbox has those labels.`,
1638
+ ...done.map((id) => `${id} ${past}`),
1639
+ ...failed.map((f) => `${f.id} failed: ${said(f.error)}`),
1640
+ ].join("\n"), { [past]: done, failed: failed.map((f) => ({ id: f.id, error: said(f.error) })) });
1641
+ return failed.length ? 1 : 0;
1642
+ };
1507
1643
  switch (verb) {
1508
1644
  case "create": {
1509
1645
  const args = parse(rest, CREATE_FLAGS, CREATE_VALUES);
1510
- const sbx = await runtime.sandboxes.create(createInput(args), {
1511
- onCapacityWait: waiting(out),
1512
- });
1646
+ const input = createInput(args);
1647
+ const onCapacityWait = waiting(out);
1648
+ const sbx = await withVolumeRelease((options) => runtime.sandboxes.create(input, { ...options, onCapacityWait }), out);
1513
1649
  print(sbx.id, sbx.info);
1514
1650
  return 0;
1515
1651
  }
@@ -1534,24 +1670,21 @@ async function sandbox(argv, env, out) {
1534
1670
  ? null
1535
1671
  : await runtime.sandboxes.list({ state: ["stopped"], limit: 50 });
1536
1672
  const stopped = hidden?.data.length
1537
- ? `${hidden.data.length}${hidden.hasMore ? "+" : ""} stopped; runtime sandbox ls --all shows them.`
1673
+ ? `${hidden.data.length}${hidden.hasMore ? "+" : ""} stopped; runtime sandboxes ls --all shows them.`
1538
1674
  : "";
1539
1675
  const rows = all.map((s) => [
1540
1676
  s.id,
1541
1677
  s.info.name ?? "-",
1542
1678
  s.state,
1543
1679
  `${s.info.vcpu}/${s.info.memoryMiB}`,
1544
- s.info.funding,
1545
- // When it stops or pauses by itself (0381): its time limit's end, or
1546
- // "-" when it never will. An older server sends no endsAt.
1547
- (s.info.endsAt === undefined ? s.info.expiresAt : s.info.endsAt) ?? "-",
1680
+ sandboxEnd(s.info),
1548
1681
  ]);
1549
1682
  print([
1550
1683
  rows.length
1551
- ? table([["ID", "NAME", "STATE", "CPU/MIB", "FUNDING", "ENDS"], ...rows])
1684
+ ? table([["ID", "NAME", "STATE", "CPU/MIB", "ENDS"], ...rows])
1552
1685
  : stopped
1553
1686
  ? "No live sandboxes."
1554
- : "No sandboxes. Create one: runtime sandbox create",
1687
+ : "No sandboxes. Create one: runtime sandboxes create",
1555
1688
  stopped,
1556
1689
  ]
1557
1690
  .filter(Boolean)
@@ -1566,31 +1699,33 @@ async function sandbox(argv, env, out) {
1566
1699
  case "switch-image": {
1567
1700
  const args = parse(rest, ["keep-workspace"], []);
1568
1701
  const [id, image] = args.positional;
1569
- const sbx = await use(need(id, `a sandbox, e.g. ${me} sandbox switch-image <id> app:v2 --keep-workspace`));
1702
+ const sbx = await use(need(id, `a sandbox, e.g. ${me} sandboxes switch-image <id> app:v2 --keep-workspace`));
1570
1703
  if (!args.flags.has("keep-workspace"))
1571
- throw usage(`A switch keeps only /workspace (your home), volumes, env and previews; processes restart and everything else on its disk is lost. Add --keep-workspace to say so, or snapshot it first (${me} sandbox snapshot ${sbx.id}) to keep everything.`);
1704
+ throw usage(`A switch keeps only /workspace (your home), volumes, env and previews; processes restart and everything else on its disk is lost. Add --keep-workspace to say so, or snapshot it first (${me} sandboxes snapshot ${sbx.id}) to keep everything.`);
1572
1705
  await sbx.switchImage(need(image, "an image, e.g. app:v2"), { keep: "workspace" });
1573
1706
  print(describe(sbx.info), sbx.info);
1574
1707
  return 0;
1575
1708
  }
1576
1709
  case "resize": {
1577
1710
  const args = parse(rest, ["restart"], ["vcpu", "memory"]);
1578
- const sbx = await use(need(args.positional[0], `a sandbox, e.g. ${me} sandbox resize <id> --memory 4096 --restart`));
1711
+ const sbx = await use(need(args.positional[0], `a sandbox, e.g. ${me} sandboxes resize <id> --memory 4096 --restart`));
1579
1712
  const size = {
1580
1713
  ...(integer(args, "vcpu") !== undefined ? { vcpu: integer(args, "vcpu") } : {}),
1581
1714
  ...(integer(args, "memory") !== undefined ? { memoryMiB: integer(args, "memory") } : {}),
1582
1715
  };
1583
1716
  if (size.vcpu === undefined && size.memoryMiB === undefined)
1584
- throw usage(`Name a new size, e.g. ${me} sandbox resize ${sbx.id} --memory 4096 --restart`);
1717
+ throw usage(`Name a new size, e.g. ${me} sandboxes resize ${sbx.id} --memory 4096 --restart`);
1585
1718
  if (!args.flags.has("restart"))
1586
- throw usage(`A resize restarts it: its whole disk stays and its programs stop. Add --restart to say so, or snapshot it first (${me} sandbox snapshot ${sbx.id}) to keep its memory too.`);
1719
+ throw usage(`A resize restarts it: its whole disk stays and its programs stop. Add --restart to say so, or snapshot it first (${me} sandboxes snapshot ${sbx.id}) to keep its memory too.`);
1587
1720
  await sbx.resize(size, { restart: true });
1588
1721
  print(describe(sbx.info), sbx.info);
1589
1722
  return 0;
1590
1723
  }
1591
1724
  case "rm":
1592
1725
  case "delete": {
1593
- const sbx = await use(need(rest[0], `a sandbox, e.g. ${me} sandbox rm <id>`));
1726
+ if (byLabel(rest))
1727
+ return everyLabelled("delete", rest);
1728
+ const sbx = await use(need(rest[0], `a sandbox, e.g. ${me} sandboxes rm <id>`));
1594
1729
  const gone = await sbx.delete();
1595
1730
  print(`${gone.id} deleted`, gone);
1596
1731
  return 0;
@@ -1599,23 +1734,8 @@ async function sandbox(argv, env, out) {
1599
1734
  case "pause":
1600
1735
  case "wake":
1601
1736
  case "restart": {
1602
- if (verb === "stop" && rest.some((word) => /^--label(=|$)/.test(word))) {
1603
- const args = parse(rest, [], ["label"]);
1604
- if (args.positional.length)
1605
- throw usage("Give a sandbox id or --label, not both.");
1606
- const { stopped, failed } = await runtime.sandboxes.stopAll({
1607
- labels: pairs(args, "label"),
1608
- });
1609
- const said = (error) => (error instanceof Error ? error.message : String(error));
1610
- print([
1611
- stopped.length || failed.length
1612
- ? `${stopped.length} stopped${failed.length ? `, ${failed.length} failed` : ""}.`
1613
- : "No live sandbox has those labels.",
1614
- ...stopped.map((id) => `${id} stopped`),
1615
- ...failed.map((f) => `${f.id} failed: ${said(f.error)}`),
1616
- ].join("\n"), { stopped, failed: failed.map((f) => ({ id: f.id, error: said(f.error) })) });
1617
- return failed.length ? 1 : 0;
1618
- }
1737
+ if (verb === "stop" && byLabel(rest))
1738
+ return everyLabelled("stop", rest);
1619
1739
  const sbx = await get(rest[0]);
1620
1740
  await sbx[verb]();
1621
1741
  print(`${sbx.id} ${sbx.state}`, sbx.info);
@@ -1692,15 +1812,15 @@ async function sandbox(argv, env, out) {
1692
1812
  print(gone.left ? "Left the tailnet." : "Not on a tailnet.", gone);
1693
1813
  return 0;
1694
1814
  }
1695
- throw usage("runtime sandbox tailscale up|status|down <id>");
1815
+ throw usage("runtime sandboxes tailscale up|status|down <id>");
1696
1816
  }
1697
1817
  case "snapshot": {
1698
1818
  const args = parse(rest, [], ["name", "retention"]);
1699
1819
  const sbx = await get(args.positional[0]);
1700
1820
  const snapshot = await sbx.snapshot({
1701
1821
  ...(flag(args, "name") ? { name: flag(args, "name") } : {}),
1702
- ...(integer(args, "retention") !== undefined
1703
- ? { retentionDays: integer(args, "retention") }
1822
+ ...(daysOrCredit(args, "retention") !== undefined
1823
+ ? { retentionDays: daysOrCredit(args, "retention") }
1704
1824
  : {}),
1705
1825
  });
1706
1826
  print(snapshot.id, snapshot);
@@ -1772,26 +1892,26 @@ async function sandbox(argv, env, out) {
1772
1892
  }
1773
1893
  case "extend": {
1774
1894
  const sbx = await get(rest[0]);
1775
- const seconds = Number(need(rest[1], "seconds, e.g. runtime sandbox extend <id> 600"));
1895
+ const seconds = Number(need(rest[1], "seconds, e.g. runtime sandboxes extend <id> 600"));
1776
1896
  await sbx.extend(seconds);
1777
- print(sbx.info.endsAt === null
1897
+ print(sandboxEnd(sbx.info) === "no limit"
1778
1898
  ? `${sbx.id} has no time limit: it runs while it works, so nothing was extended`
1779
- : `${sbx.id} now ends at ${sbx.info.endsAt ?? sbx.info.expiresAt}`, sbx.info);
1899
+ : `${sbx.id} now ends at ${sandboxEnd(sbx.info)}`, sbx.info);
1780
1900
  return 0;
1781
1901
  }
1782
1902
  case "exec": {
1783
- const args = parse(rest, [], ["cwd", "env", "timeout"], `${me} sandbox exec <id> -- ls -la`);
1903
+ const args = parse(rest, [], ["cwd", "env", "timeout"], `${me} sandboxes exec <id> -- ls -la`);
1784
1904
  const command = args.rest ?? args.positional.slice(1);
1785
1905
  if (!command.length)
1786
- throw usage("Give the command after --: runtime sandbox exec <id> -- ls -la");
1906
+ throw usage("Give the command after --: runtime sandboxes exec <id> -- ls -la");
1787
1907
  const sbx = await use(args.positional[0]);
1788
1908
  return execute(sbx, command, args, out, integer(args, "timeout"));
1789
1909
  }
1790
1910
  case "spawn": {
1791
- const args = parse(rest, ["pty"], ["cwd", "env"], `${me} sandbox spawn <id> -- npm run dev`);
1911
+ const args = parse(rest, ["pty"], ["cwd", "env"], `${me} sandboxes spawn <id> -- npm run dev`);
1792
1912
  const command = args.rest ?? [];
1793
1913
  if (!command.length)
1794
- throw usage("Give the command after --: runtime sandbox spawn <id> -- npm run dev");
1914
+ throw usage("Give the command after --: runtime sandboxes spawn <id> -- npm run dev");
1795
1915
  const sbx = await use(args.positional[0]);
1796
1916
  const proc = await sbx.spawn(command.length === 1 ? command[0] : command, {
1797
1917
  ...(has(args, "pty") ? { pty: { cols: 120, rows: 40 } } : {}),
@@ -1807,36 +1927,54 @@ async function sandbox(argv, env, out) {
1807
1927
  print(list.length
1808
1928
  ? table([
1809
1929
  ["PROCESS", "STATE", "EXIT", "COMMAND"],
1810
- ...list.map((p) => [p.id, p.state, String(p.exitCode ?? "-"), p.command]),
1930
+ // The command line is the guest's report: escaped, as a log is.
1931
+ ...list.map((p) => [
1932
+ terminalSafe(String(p.id)),
1933
+ terminalSafe(String(p.state)),
1934
+ `${typeof p.exitCode === "number" ? p.exitCode : "-"}${p.outOfMemory ? " (out of memory)" : ""}`,
1935
+ terminalSafe(String(p.command ?? "")),
1936
+ ]),
1811
1937
  ])
1812
1938
  : "No processes.", list);
1939
+ const killed = list.find((p) => p.outOfMemory)?.outOfMemory;
1940
+ if (killed && typeof killed.hint === "string" && !out.json)
1941
+ out.error(`Hint: ${terminalSafe(killed.hint)}`);
1813
1942
  return 0;
1814
1943
  }
1815
1944
  case "logs": {
1816
- const args = parse(rest, ["f", "follow"], []);
1945
+ const args = parse(rest, ["f", "follow", "raw"], []);
1817
1946
  const sbx = await use(args.positional[0]);
1818
1947
  const proc = await sbx.processes.get(need(args.positional[1], "a process id"));
1948
+ const [stdout, stderr, shown] = [
1949
+ logStream(process.stdout, has(args, "raw")),
1950
+ logStream(process.stderr, has(args, "raw")),
1951
+ logText(args),
1952
+ ];
1819
1953
  if (has(args, "f") || has(args, "follow")) {
1820
1954
  for await (const event of proc.output())
1821
1955
  if (event.type === "stdout")
1822
- process.stdout.write(event.data);
1956
+ stdout(event.data);
1823
1957
  else if (event.type === "stderr")
1824
- process.stderr.write(event.data);
1958
+ stderr(event.data);
1825
1959
  else if (out.json)
1826
- out.write(JSON.stringify(event));
1960
+ out.write(shown(JSON.stringify(event)));
1961
+ else if (event.type === "exit" && event.outOfMemory)
1962
+ out.error(outOfMemoryLines(event.outOfMemory));
1827
1963
  return 0;
1828
1964
  }
1829
1965
  /* Without -f, what it has printed so far, then back to the prompt, as
1830
1966
  `docker logs` does: a server's log is read, not followed forever. */
1831
1967
  const so = await outputSoFar(runtime, sbx.id, proc.id);
1832
1968
  if (out.json) {
1833
- out.write(JSON.stringify(so));
1969
+ out.write(shown(JSON.stringify(so)));
1834
1970
  return 0;
1835
1971
  }
1836
1972
  for (const chunk of so.chunks)
1837
- (chunk.stream === "stderr" ? process.stderr : process.stdout).write(chunk.text);
1973
+ (chunk.stream === "stderr" ? stderr : stdout)(chunk.text);
1974
+ if (so.outOfMemory)
1975
+ out.error(outOfMemoryLines(so.outOfMemory));
1838
1976
  if (so.state === "running")
1839
- process.stderr.write(`[${proc.id} is still running; follow it: ${me} sandbox logs ${sbx.id} ${proc.id} -f]\n`);
1977
+ process.stderr.write(`[${proc.id} is still running; follow it: ${me} sandboxes logs ${sbx.id} ${proc.id} -f]\n`);
1840
1978
  return 0;
1841
1979
  }
1842
1980
  case "kill": {
@@ -1906,7 +2044,7 @@ async function sandbox(argv, env, out) {
1906
2044
  return runCode(await use(rest[0]), rest.slice(1), out);
1907
2045
  case "preview": {
1908
2046
  if (rest[0] === "rotate") {
1909
- const port = portOf(rest[2], "runtime sandbox preview rotate <id> 3000");
2047
+ const port = portOf(rest[2], "runtime sandboxes preview rotate <id> 3000");
1910
2048
  const sbx = await use(rest[1]);
1911
2049
  const preview = await sbx.previews.rotate(port);
1912
2050
  print(preview.visibility === "public"
@@ -1915,7 +2053,7 @@ async function sandbox(argv, env, out) {
1915
2053
  return 0;
1916
2054
  }
1917
2055
  const args = parse(rest, ["public"], ["ttl"]);
1918
- const port = portOf(args.positional[1], "runtime sandbox preview <id> 3000");
2056
+ const port = portOf(args.positional[1], "runtime sandboxes preview <id> 3000");
1919
2057
  const sbx = await use(args.positional[0]);
1920
2058
  const preview = await sbx.previews.create(port, {
1921
2059
  ...(has(args, "public") ? { visibility: "public" } : {}),
@@ -1935,7 +2073,7 @@ async function sandbox(argv, env, out) {
1935
2073
  ["PORT", "VISIBILITY", "URL"],
1936
2074
  ...list.map((p) => [String(p.port), p.visibility, p.url]),
1937
2075
  ])
1938
- : "No shared ports. Share one: runtime sandbox preview <id> 3000", list);
2076
+ : "No shared ports. Share one: runtime sandboxes preview <id> 3000", list);
1939
2077
  return 0;
1940
2078
  }
1941
2079
  case "session": {
@@ -1952,17 +2090,17 @@ async function sandbox(argv, env, out) {
1952
2090
  one.origins.join(",") || "-",
1953
2091
  ]),
1954
2092
  ])
1955
- : "No sessions. Make one: runtime sandbox session create <id> --origin https://app.example.com", list);
2093
+ : "No sessions. Make one: runtime sandboxes session create <id> --origin https://app.example.com", list);
1956
2094
  return 0;
1957
2095
  }
1958
2096
  if (verb === "revoke") {
1959
- const sessionId = need(rest[2], "a session id: runtime sandbox session revoke <id> <session-id>");
2097
+ const sessionId = need(rest[2], "a session id: runtime sandboxes session revoke <id> <session-id>");
1960
2098
  const ended = await (await use(rest[1])).sessions.revoke(sessionId);
1961
2099
  print(`Session ${ended.id} ended.`, ended);
1962
2100
  return 0;
1963
2101
  }
1964
2102
  if (verb !== "create")
1965
- throw usage("runtime sandbox session create <id> [--origin <url>]..., session ls <id>, or session revoke <id> <session-id>");
2103
+ throw usage("runtime sandboxes session create <id> [--origin <url>]..., session ls <id>, or session revoke <id> <session-id>");
1966
2104
  const args = parse(rest.slice(1), [], ["origin", "ttl", "name"]);
1967
2105
  const origins = args.flags.get("origin") ?? [];
1968
2106
  const ttl = integer(args, "ttl");
@@ -1976,7 +2114,7 @@ async function sandbox(argv, env, out) {
1976
2114
  return 0;
1977
2115
  }
1978
2116
  case "unshare": {
1979
- const port = portOf(rest[1], "runtime sandbox unshare <id> 3000");
2117
+ const port = portOf(rest[1], "runtime sandboxes unshare <id> 3000");
1980
2118
  const sbx = await use(rest[0]);
1981
2119
  print(`Port ${port} is no longer shared.`, await sbx.previews.delete(port));
1982
2120
  return 0;
@@ -2051,15 +2189,24 @@ function attachment(spec) {
2051
2189
  /** A secret's value, from standard input only, so it never sits in shell
2052
2190
  * history or a process list. Piped: everything, less one trailing newline.
2053
2191
  * A terminal: one line, not shown. */
2192
+ /** A pasted secret as the header it becomes: no line breaks (a token copied
2193
+ * from a terminal arrives wrapped), no bracketed-paste markers, no space at
2194
+ * either end. 13 October 2026: Marc's pasted Claude token was refused as
2195
+ * "not visible ASCII". A header cannot hold a line break, so none is lost. */
2196
+ export function cleanSecret(raw) {
2197
+ return raw
2198
+ .replaceAll("\u001b[200~", "")
2199
+ .replaceAll("\u001b[201~", "")
2200
+ .replace(/[\r\n]/g, "")
2201
+ .trim();
2202
+ }
2054
2203
  async function readSecret() {
2055
2204
  const stdin = process.stdin;
2056
2205
  if (!stdin.isTTY) {
2057
2206
  const chunks = [];
2058
2207
  for await (const chunk of stdin)
2059
2208
  chunks.push(chunk);
2060
- return Buffer.concat(chunks)
2061
- .toString("utf8")
2062
- .replace(/\r?\n$/, "");
2209
+ return cleanSecret(Buffer.concat(chunks).toString("utf8"));
2063
2210
  }
2064
2211
  process.stderr.write("Value (not shown), then Enter: ");
2065
2212
  stdin.setRawMode(true);
@@ -2072,7 +2219,7 @@ async function readSecret() {
2072
2219
  if (char === "\r" || char === "\n") {
2073
2220
  stdin.off("data", onData);
2074
2221
  process.stderr.write("\n");
2075
- return done(value);
2222
+ return done(cleanSecret(value));
2076
2223
  }
2077
2224
  if (char === "\u0003") {
2078
2225
  stdin.off("data", onData);
@@ -2115,6 +2262,48 @@ export function parseSecretRules(values) {
2115
2262
  }
2116
2263
  return [...byMethods.values()];
2117
2264
  }
2265
+ /** `--only-label role=hub,tier=x --only-sandbox <id>`: a sandbox whose labels
2266
+ * hold every pair of any one --only-label gets the secret, and so does each
2267
+ * --only-sandbox. `--all-sandboxes`: every sandbox again. Nothing: undefined,
2268
+ * so a replacement keeps the scope it has. */
2269
+ export function parseSecretScope(labels, sandboxes, all) {
2270
+ if (all && (labels?.length || sandboxes?.length))
2271
+ throw usage("--all-sandboxes gives the secret to every sandbox; leave out --only-label and --only-sandbox.");
2272
+ if (all)
2273
+ return {};
2274
+ if (!labels?.length && !sandboxes?.length)
2275
+ return undefined;
2276
+ const selectors = (labels ?? []).map((text) => {
2277
+ const selector = {};
2278
+ for (const pair of text.split(",").filter(Boolean)) {
2279
+ const at = pair.indexOf("=");
2280
+ if (at < 1)
2281
+ throw usage(`--only-label takes key=value pairs, such as role=hub; got "${text}".`);
2282
+ selector[pair.slice(0, at).trim()] = pair.slice(at + 1).trim();
2283
+ }
2284
+ if (!Object.keys(selector).length)
2285
+ throw usage(`--only-label takes key=value pairs, such as role=hub; got "${text}".`);
2286
+ return selector;
2287
+ });
2288
+ for (const id of sandboxes ?? [])
2289
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id))
2290
+ throw usage(`--only-sandbox takes a sandbox id; "${id}" is not one.`);
2291
+ return {
2292
+ ...(selectors.length ? { labels: selectors } : {}),
2293
+ ...(sandboxes?.length
2294
+ ? { sandboxes: [...new Set(sandboxes.map((id) => id.toLowerCase()))] }
2295
+ : {}),
2296
+ };
2297
+ }
2298
+ /** Who gets a secret, in a few words. */
2299
+ export const scopeText = (only) => only && (only.labels?.length || only.sandboxes?.length)
2300
+ ? [
2301
+ ...(only.labels ?? []).map((selector) => Object.entries(selector)
2302
+ .map(([key, value]) => `${key}=${value}`)
2303
+ .join(",")),
2304
+ ...(only.sandboxes ?? []),
2305
+ ].join(" | ")
2306
+ : "all";
2118
2307
  const ruleText = (rules) => rules?.length
2119
2308
  ? rules
2120
2309
  .map((rule) => `${rule.methods ? `${rule.methods.join(",")} ` : ""}${rule.paths.join(" ")}`)
@@ -2124,17 +2313,18 @@ export async function secretsCommand(argv, env, out,
2124
2313
  /** Tests give the value here; the CLI reads standard input. */
2125
2314
  read = readSecret) {
2126
2315
  const [verb, ...rest] = argv;
2127
- const args = parse(rest, ["jobs"], ["host", "header", "format", "allow", "value", "version"]);
2316
+ const args = parse(rest, ["jobs", "all-sandboxes"], ["host", "header", "format", "allow", "value", "version", "only-label", "only-sandbox"]);
2128
2317
  const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
2129
2318
  const rows = (list) => list.length
2130
2319
  ? table([
2131
- ["name", "hosts", "header", "placeholder", "allow", "jobs"],
2320
+ ["name", "hosts", "header", "placeholder", "allow", "sandboxes", "jobs"],
2132
2321
  ...list.map(({ name, sandboxes: secret, jobs }) => [
2133
2322
  name,
2134
2323
  secret ? secret.hosts.join(",") : "-",
2135
2324
  secret?.header ? `${secret.header}: ${secret.format ?? "{value}"}` : "-",
2136
2325
  secret ? secret.placeholder : "-",
2137
2326
  secret ? ruleText(secret.rules) : "-",
2327
+ secret ? scopeText(secret.only) : "-",
2138
2328
  jobs ? `v${jobs.version}` : "-",
2139
2329
  ]),
2140
2330
  ])
@@ -2157,7 +2347,13 @@ read = readSecret) {
2157
2347
  const name = need(args.positional[0], "the secret's name, such as OPENAI_API_KEY");
2158
2348
  const hosts = args.flags.get("host") ?? [];
2159
2349
  const jobs = verb === "rotate" || has(args, "jobs");
2160
- if (verb === "rotate" && (hosts.length || args.flags.has("header") || args.flags.has("allow")))
2350
+ if (verb === "rotate" &&
2351
+ (hosts.length ||
2352
+ args.flags.has("header") ||
2353
+ args.flags.has("allow") ||
2354
+ args.flags.has("only-label") ||
2355
+ args.flags.has("only-sandbox") ||
2356
+ args.flags.has("all-sandboxes")))
2161
2357
  throw usage(`rotate replaces the copy for jobs. ${jobsOnly}; to change the sandboxes' copy, set it again with --host.`);
2162
2358
  if (!hosts.length && !jobs)
2163
2359
  throw usage("Give --host once for each host the value may go to, such as --host api.openai.com, and/or --jobs to let scheduled jobs use it.");
@@ -2174,38 +2370,51 @@ read = readSecret) {
2174
2370
  }
2175
2371
  if (!hosts.length) {
2176
2372
  const saved = await runtime.secrets.set(name, { value, jobs: true });
2177
- print(`Stored ${name} for jobs, version ${saved.jobs.version}. Bind it: ${me} job create ... --secret ${name}`, saved);
2373
+ print(`Stored ${name} for jobs, version ${saved.jobs.version}. Bind it: ${me} jobs create ... --secret ${name}`, saved);
2178
2374
  return 0;
2179
2375
  }
2180
2376
  const header = flag(args, "header");
2181
2377
  const format = flag(args, "format");
2182
2378
  const allow = args.flags.get("allow");
2183
2379
  const rules = allow ? parseSecretRules(allow) : undefined;
2380
+ const only = parseSecretScope(args.flags.get("only-label"), args.flags.get("only-sandbox"), has(args, "all-sandboxes"));
2184
2381
  const saved = await runtime.secrets.set(name, {
2185
2382
  value,
2186
2383
  hosts,
2187
2384
  ...(header ? { header } : {}),
2188
2385
  ...(format ? { format } : {}),
2189
2386
  ...(rules ? { rules } : {}),
2387
+ ...(only ? { only } : {}),
2190
2388
  ...(jobs ? { jobs: true } : {}),
2191
2389
  });
2192
2390
  print([
2193
- `Stored ${saved.name}. Sandboxes see ${saved.name}=${saved.placeholder}.`,
2391
+ saved.only
2392
+ ? `Stored ${saved.name}. Only these sandboxes get it and see ${saved.name}=${saved.placeholder}: ${scopeText(saved.only)}.`
2393
+ : `Stored ${saved.name}. Every sandbox sees ${saved.name}=${saved.placeholder}.`,
2194
2394
  saved.header
2195
2395
  ? `Requests to ${saved.hosts.join(", ")} get ${saved.header}: ${saved.format ?? "{value}"} over HTTPS.`
2196
- : `Over HTTPS to ${saved.hosts.join(", ")}, the placeholder becomes the value in the URL and headers.`,
2396
+ : `Over HTTPS to ${saved.hosts.join(", ")}, the placeholder becomes the value in the Authorization header or another credential header (x-api-key, x-goog-api-key); in the URL it stays a placeholder.`,
2197
2397
  ...(saved.rules?.length ? [`Only these requests get it: ${ruleText(saved.rules)}.`] : []),
2198
2398
  ...(saved.enforced
2199
2399
  ? []
2200
2400
  : ["A host is catching up; running sandboxes have it within a minute."]),
2201
2401
  ...(saved.jobs
2202
2402
  ? [
2203
- `Also stored for jobs, version ${saved.jobs.version}: --secret ${saved.name} on ${me} job create.`,
2403
+ `Also stored for jobs, version ${saved.jobs.version}: --secret ${saved.name} on ${me} jobs create.`,
2204
2404
  ]
2205
2405
  : []),
2206
2406
  ].join("\n"), saved);
2207
2407
  return 0;
2208
2408
  }
2409
+ if (verb === "scope") {
2410
+ const name = need(args.positional[0], "the secret's name");
2411
+ const only = parseSecretScope(args.flags.get("only-label"), args.flags.get("only-sandbox"), has(args, "all-sandboxes"));
2412
+ if (!only)
2413
+ throw usage("Say who gets it: --only-label key=value (once per selector), --only-sandbox <id>, or --all-sandboxes.");
2414
+ const saved = await (await client(env)).secrets.scope(name, only);
2415
+ print(`${saved.name}: ${saved.only ? `only ${scopeText(saved.only)}` : "every sandbox"}.${saved.enforced ? "" : " A host is catching up; running sandboxes have it within a minute."}`, saved);
2416
+ return 0;
2417
+ }
2209
2418
  if (verb === "reveal") {
2210
2419
  const name = need(args.positional[0], "the secret's name");
2211
2420
  const version = flag(args, "version");
@@ -2221,7 +2430,7 @@ read = readSecret) {
2221
2430
  print(`Deleted ${name}${removed.from.length > 1 ? " for sandboxes and jobs" : removed.from[0] === "jobs" ? " for jobs" : ""}.`, removed);
2222
2431
  return 0;
2223
2432
  }
2224
- throw usage("secrets takes set, ls, get, rotate, reveal or rm. Run `runtime help`.");
2433
+ throw usage("secrets takes set, scope, ls, get, rotate, reveal or rm. Run `runtime help`.");
2225
2434
  }
2226
2435
  async function readInput(source) {
2227
2436
  if (source !== "-")
@@ -2339,7 +2548,7 @@ async function desktop(sbx, argv, out) {
2339
2548
  return 0;
2340
2549
  }
2341
2550
  }
2342
- throw usage(`${me} sandbox desktop <id> start|stop|screenshot|open|click|type|press|windows|record`);
2551
+ throw usage(`${me} sandboxes desktop <id> start|stop|screenshot|open|click|type|press|windows|record`);
2343
2552
  }
2344
2553
  async function browser(sbx, argv, out) {
2345
2554
  const [action, ...rest] = argv;
@@ -2353,7 +2562,8 @@ async function browser(sbx, argv, out) {
2353
2562
  ...(integer(args, "height") !== undefined ? { height: integer(args, "height") } : {}),
2354
2563
  });
2355
2564
  print(`${started.version}${started.headless ? "" : `, on the desktop: ${started.streamUrl}`}\n` +
2356
- `CDP: ${started.cdpUrl}\n(private; it expires ${started.expiresAt ?? "never"})`, started);
2565
+ `CDP: ${started.cdpUrl}\n(private; it expires ${started.expiresAt ?? "never"})` +
2566
+ (started.hint ? `\n${started.hint}` : ""), started);
2357
2567
  return 0;
2358
2568
  }
2359
2569
  case "get":
@@ -2368,27 +2578,34 @@ async function browser(sbx, argv, out) {
2368
2578
  return 0;
2369
2579
  }
2370
2580
  }
2371
- throw usage(`${me} sandbox browser <id> start [--headed]|get|stop`);
2581
+ throw usage(`${me} sandboxes browser <id> start [--headed]|get|stop`);
2372
2582
  }
2373
2583
  const KEYS_HELP = `runtime keys <command>
2374
2584
 
2375
- create [--name <name>] [--read-only] [--daily-limit <usd>] [--account-wide]
2376
- A new API key, for a CI runner or any place with no browser. Prints a link
2377
- and a code; an account owner approves the key in the browser, seeing its
2378
- name, access and limit. The key is printed once, alone on stdout, and
2379
- lasts until revoked. --json prints it as JSON, still alone on stdout.
2380
- --account-wide makes a key that sees and uses every sandbox and other
2381
- resource in the account, whichever key made it; only an owner or admin
2382
- can approve one. Without it, a key sees only what it makes.
2585
+ create [--name <name>] [--read-only] [--daily-limit <usd>] [--account-wide] [--browser]
2586
+ A new API key. With a key saved on this machine (or RUNTIME_API_KEY), that
2587
+ key makes it at once, with no browser: a key per worker in an agent swarm.
2588
+ It never has more than the key that made it, and when that key has a daily
2589
+ limit, the keys it made share it, so each needs --daily-limit. Without a
2590
+ saved key, or with --browser, it prints a link and a code and an account
2591
+ owner approves the key in the browser. The key is printed once, alone on
2592
+ stdout, and lasts until revoked. --json prints it as JSON, still alone on
2593
+ stdout. --account-wide makes a key that sees and uses every sandbox and
2594
+ other resource in the account; without it, a key sees only what it makes.
2595
+ ls This key and the keys it made, with what each spent today
2596
+ usage <keyId> What one of them has spent and holds
2597
+ revoke <keyId> Revoke one: a worker's key when the worker is done
2383
2598
 
2384
- runtime keys create --name ci --daily-limit 25 | gh secret set RUNTIME_API_KEY
2599
+ runtime keys create --name worker-1 --daily-limit 5
2600
+ runtime keys create --name ci --daily-limit 25 --browser | gh secret set RUNTIME_API_KEY
2385
2601
 
2386
- List, limit and revoke keys at https://withruntime.com/account/keys.
2602
+ Making, listing and revoking keys with a key needs one an owner or admin made.
2603
+ Every key is also at https://withruntime.com/account/keys.
2387
2604
  `;
2388
- /* `runtime keys create`: a key made from the terminal and approved in the
2389
- browser. Marc, 23 September 2026; ARCHITECTURE.md section 10, "Keys from the
2390
- terminal". Only the options the keys page offers. No key can list, create or
2391
- revoke keys through the API, so `ls` and `revoke` send you to the page. */
2605
+ /* `runtime keys`: a key made from the terminal, by this machine's key with no
2606
+ browser (Marc, 14 October 2026, agent swarms), or approved in the browser
2607
+ (Marc, 23 September 2026; ARCHITECTURE.md section 10, "Keys from the
2608
+ terminal"). Only the options the keys page offers. */
2392
2609
  /* `runtime account`: the accounts this machine is connected to, and which one
2393
2610
  every command uses (ARCHITECTURE.md section 3.9). Each is its own saved
2394
2611
  connection with its own key; the SDK itself stays key-based. */
@@ -2494,19 +2711,63 @@ async function nameSaved(env) {
2494
2711
  await store.save({ ...again, orgName: who.orgName });
2495
2712
  }
2496
2713
  }
2714
+ /** Dollars from integer microdollars, for a person to read: "$5.00". */
2715
+ const usdOf = (micros) => `$${(Number(BigInt(micros) / 10000n) / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
2497
2716
  async function keys(argv, env, out) {
2498
2717
  product = "keys";
2499
2718
  const [verb, ...rest] = argv;
2719
+ const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
2500
2720
  if (!verb || verb === "help" || verb === "--help" || verb === "-h") {
2501
2721
  out.write(out.json ? JSON.stringify({ usage: named(KEYS_HELP) }) : named(KEYS_HELP));
2502
2722
  return 0;
2503
2723
  }
2504
- if (verb === "ls" || verb === "list" || verb === "revoke")
2505
- throw usage("List and revoke keys at https://withruntime.com/account/keys. No key can list or revoke keys.");
2724
+ if (verb === "ls" || verb === "list") {
2725
+ const args = parse(rest, [], []);
2726
+ if (args.flags.size || args.positional.length)
2727
+ throw usage(`Usage: ${named("runtime keys ls")}`);
2728
+ const all = await (await client(env)).keys.list();
2729
+ print(all.length
2730
+ ? table([
2731
+ ["KEY", "NAME", "ACCESS", "REACH", "DAILY LIMIT", "LAST 24H", "MADE BY", "STATE"],
2732
+ ...all.map((k) => [
2733
+ k.keyId,
2734
+ k.name,
2735
+ k.access,
2736
+ k.reach,
2737
+ k.dailyLimitMicros === null ? "none" : usdOf(k.dailyLimitMicros),
2738
+ usdOf(k.dailyUsedMicros),
2739
+ k.madeBy ?? "website",
2740
+ k.revokedAt ? "revoked" : "live",
2741
+ ]),
2742
+ ])
2743
+ : "No keys.", all);
2744
+ return 0;
2745
+ }
2746
+ if (verb === "revoke" || verb === "rm" || verb === "usage") {
2747
+ const args = parse(rest, [], []);
2748
+ const id = args.positional[0];
2749
+ if (!id || args.positional.length > 1 || args.flags.size)
2750
+ throw usage(`Usage: ${named(`runtime keys ${verb} <keyId>`)}`);
2751
+ const api = (await client(env)).keys;
2752
+ if (verb === "usage") {
2753
+ const used = await api.usage(id);
2754
+ print([
2755
+ `${used.name} (${used.keyId})${used.revokedAt ? ", revoked" : ""}`,
2756
+ ` last 24 hours ${usdOf(used.dailyUsedMicros)}${used.dailyLimitMicros === null ? "" : ` of ${usdOf(used.dailyLimitMicros)}`}`,
2757
+ ` spent ${usdOf(used.spentMicros)}`,
2758
+ ` held now ${usdOf(used.heldMicros)}`,
2759
+ ` sandboxes ${used.runningSandboxes} running, ${used.resources} kept in all`,
2760
+ ].join("\n"), used);
2761
+ return 0;
2762
+ }
2763
+ const revoked = await api.revoke(id);
2764
+ print(`Revoked ${revoked.keyId}. It stops working within ten seconds.`, revoked);
2765
+ return 0;
2766
+ }
2506
2767
  if (verb !== "create")
2507
- throw unknown("keys command", verb, ["create"]);
2508
- const args = parse(rest, ["read-only", "account-wide"], ["name", "daily-limit"]);
2509
- const known = new Set(["name", "read-only", "daily-limit", "account-wide"]);
2768
+ throw unknown("keys command", verb, ["create", "ls", "usage", "revoke"]);
2769
+ const args = parse(rest, ["read-only", "account-wide", "browser"], ["name", "daily-limit"]);
2770
+ const known = new Set(["name", "read-only", "daily-limit", "account-wide", "browser"]);
2510
2771
  for (const name of args.flags.keys())
2511
2772
  if (!known.has(name))
2512
2773
  throw usage(`Unknown option --${name}.`);
@@ -2519,36 +2780,58 @@ async function keys(argv, env, out) {
2519
2780
  if (readOnly && has(args, "account-wide"))
2520
2781
  throw usage("A read-only key already sees the whole account, so it takes no --account-wide.");
2521
2782
  const { createKey, dailyLimitMicros } = await import("./keys.js");
2522
- const created = await createKey({
2783
+ const request = {
2523
2784
  name: flag(args, "name") ?? (readOnly ? "Read-only key" : "My API key"),
2524
2785
  access: readOnly ? "read" : "full",
2525
2786
  ...(has(args, "account-wide") ? { reach: "account" } : {}),
2526
2787
  dailyLimitMicros: limit === undefined ? null : dailyLimitMicros(limit),
2527
- }, env, { notify: out.error });
2528
- const { confirm, ...key } = created;
2529
- out.write(out.json ? JSON.stringify(key) : key.key);
2788
+ };
2789
+ /* A key on this machine makes the new one itself, with no browser: an
2790
+ agent with no person, a swarm's coordinator (Marc, 14 October 2026). */
2791
+ const saved = has(args, "browser")
2792
+ ? null
2793
+ : env.RUNTIME_API_KEY || (await connectionStore(env).read())?.key || null;
2794
+ let key;
2795
+ if (saved) {
2796
+ key = await new Runtime({
2797
+ apiKey: saved,
2798
+ baseUrl: env.RUNTIME_API_URL,
2799
+ maxRetries: 4,
2800
+ }).keys.create({
2801
+ name: request.name,
2802
+ access: request.access,
2803
+ reach: request.reach ?? "agent",
2804
+ dailyLimitMicros: request.dailyLimitMicros,
2805
+ });
2806
+ }
2807
+ else
2808
+ key = await createKey(request, env, { notify: out.error });
2809
+ const { confirm, ...made } = key;
2810
+ out.write(out.json ? JSON.stringify(made) : made.key);
2530
2811
  const terms = [
2531
- key.access === "read" ? "read only" : "full access",
2532
- key.reach === "account" ? "account-wide" : undefined,
2533
- key.access === "read"
2812
+ made.access === "read" ? "read only" : "full access",
2813
+ made.reach === "account" ? "account-wide" : undefined,
2814
+ made.access === "read"
2534
2815
  ? undefined
2535
- : key.dailyLimitMicros === null
2816
+ : made.dailyLimitMicros === null
2536
2817
  ? "no daily limit"
2537
- : `daily limit $${(Number(BigInt(key.dailyLimitMicros) / 10000n) / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
2538
- "until revoked",
2818
+ : `daily limit ${usdOf(made.dailyLimitMicros)}`,
2819
+ made.expires ? `until ${made.expires}` : "until revoked",
2539
2820
  ].filter(Boolean);
2540
- out.error(`Created key "${key.name}" (${terms.join(", ")}). Store it now: it is not shown again, and Runtime keeps only a hash of it.\nIn CI, give it to your jobs as RUNTIME_API_KEY from the secret store.`);
2541
- await confirm();
2821
+ out.error(saved
2822
+ ? `Created key "${made.name}" (${terms.join(", ")}), made by this machine's key. Store it now: it is not shown again, and Runtime keeps only a hash of it.\nGive it to one worker as RUNTIME_API_KEY; revoke it with ${named("runtime keys revoke")} ${made.keyId} when that worker is done.`
2823
+ : `Created key "${made.name}" (${terms.join(", ")}). Store it now: it is not shown again, and Runtime keeps only a hash of it.\nIn CI, give it to your jobs as RUNTIME_API_KEY from the secret store.`);
2824
+ await confirm?.();
2542
2825
  return 0;
2543
2826
  }
2544
- const SNAPSHOT_HELP = `runtime snapshot <command>
2827
+ const SNAPSHOT_HELP = `runtime snapshots <command>
2545
2828
 
2546
2829
  ls [--sandbox <id>] [--name n] Your snapshots
2547
2830
  get <id> One snapshot
2548
- extend <id> <days> Keep it longer
2831
+ extend <id> <days|credit> Keep it longer; credit: while you have credit
2549
2832
  rm <id> Delete it
2550
- Take one with \`runtime sandbox snapshot <sandboxId>\`; start a sandbox from it
2551
- with \`runtime sandbox create --snapshot <snapshotId>\`.
2833
+ Take one with \`runtime sandboxes snapshot <sandboxId>\`; start a sandbox from it
2834
+ with \`runtime sandboxes create --snapshot <snapshotId>\`.
2552
2835
  `;
2553
2836
  async function snapshot(argv, env, out) {
2554
2837
  const [verb, ...rest] = argv;
@@ -2571,9 +2854,15 @@ async function snapshot(argv, env, out) {
2571
2854
  print(all.length
2572
2855
  ? table([
2573
2856
  ["ID", "NAME", "STATUS", "FROM", "EXPIRES"],
2574
- ...all.map((s) => [s.id, s.name ?? "-", s.status, s.sourceSandboxId, s.expiresAt]),
2857
+ ...all.map((s) => [
2858
+ s.id,
2859
+ s.name ?? "-",
2860
+ s.status,
2861
+ s.sourceSandboxId,
2862
+ s.expiresAt ?? "with credit",
2863
+ ]),
2575
2864
  ])
2576
- : "No snapshots. Take one: runtime sandbox snapshot <sandboxId>", all);
2865
+ : "No snapshots. Take one: runtime sandboxes snapshot <sandboxId>", all);
2577
2866
  return 0;
2578
2867
  }
2579
2868
  case "get": {
@@ -2582,9 +2871,11 @@ async function snapshot(argv, env, out) {
2582
2871
  return 0;
2583
2872
  }
2584
2873
  case "extend": {
2585
- const days = integer(parse(rest.slice(1), [], ["days"]), "days") ?? Number(need(rest[1], "days"));
2874
+ const word = parse(rest.slice(1), [], ["days"]);
2875
+ const days = daysOrCredit(word, "days") ??
2876
+ (need(rest[1], "days") === "credit" ? null : Number(need(rest[1], "days")));
2586
2877
  const found = await api.extend(need(rest[0], "a snapshot id"), days);
2587
- print(`${found.id} kept until ${found.expiresAt}`, found);
2878
+ print(`${found.id} kept ${found.expiresAt === null ? "while you have credit" : `until ${found.expiresAt}`}`, found);
2588
2879
  return 0;
2589
2880
  }
2590
2881
  case "rm":
@@ -2597,7 +2888,7 @@ async function snapshot(argv, env, out) {
2597
2888
  }
2598
2889
  throw unknown("snapshot command", verb, ["ls", "get", "extend", "rm"]);
2599
2890
  }
2600
- const IMAGE_HELP = `runtime image <command>
2891
+ const IMAGE_HELP = `runtime images <command>
2601
2892
 
2602
2893
  build [<folder>] [--dockerfile|-f <Dockerfile>] | --from <image> | --pip <pkg>... --apt <pkg>... --npm <pkg>...
2603
2894
  [--name <name>] [--tag|-t name[:tag]]... [--target <stage>] [--build-arg K=V]... [--no-cache]
@@ -2612,8 +2903,9 @@ const IMAGE_HELP = `runtime image <command>
2612
2903
  ls Your images
2613
2904
  versions <name> Every version of a name, newest first
2614
2905
  get <image> One image: an id, name, name:tag or name@version
2615
- logs <image> [--follow]
2616
- Its build log
2906
+ logs <image> [--follow] [--raw]
2907
+ Its build log; on a terminal control characters
2908
+ show as \\uXXXX unless --raw
2617
2909
  tag <image> <tag> Point a tag of the image's name at this version
2618
2910
  untag <image> <tag> Take a tag off
2619
2911
  rm <image> Delete that version and its tags
@@ -2624,7 +2916,7 @@ const IMAGE_HELP = `runtime image <command>
2624
2916
  registry set <ecr registry> --access-key-id <id>
2625
2917
  Amazon ECR: the secret access key is read from standard input
2626
2918
  registry rm <registry>
2627
- Use one: runtime sandbox create --image app:latest
2919
+ Use one: runtime sandboxes create --image app:latest
2628
2920
  `;
2629
2921
  /** Options `image build` takes with a value. */
2630
2922
  const IMAGE_BUILD_VALUES = [
@@ -2759,7 +3051,7 @@ read = readSecret) {
2759
3051
  ])
2760
3052
  : name
2761
3053
  ? `No versions of ${name}.`
2762
- : "No images. Build one: runtime image build --pip pandas", all);
3054
+ : "No images. Build one: runtime images build --pip pandas", all);
2763
3055
  return 0;
2764
3056
  }
2765
3057
  case "get": {
@@ -2768,13 +3060,14 @@ read = readSecret) {
2768
3060
  return 0;
2769
3061
  }
2770
3062
  case "logs": {
2771
- const args = parse(rest, ["follow", "f"], []);
3063
+ const args = parse(rest, ["follow", "f", "raw"], []);
2772
3064
  const id = (await api.resolve(need(args.positional[0], "an image"))).id;
3065
+ const shown = logText(args);
2773
3066
  if (has(args, "follow") || has(args, "f")) {
2774
3067
  const lines = [];
2775
- const done = await api.followLogs(id, (line) => out.json ? lines.push(line.text) : out.write(line.text));
3068
+ const done = await api.followLogs(id, (line) => out.json ? lines.push(line.text) : out.write(shown(line.text)));
2776
3069
  if (out.json)
2777
- print("", { lines, state: done.state });
3070
+ out.write(shown(JSON.stringify({ lines, state: done.state })));
2778
3071
  return done.state === "failed" ? 1 : 0;
2779
3072
  }
2780
3073
  let after = 0;
@@ -2786,7 +3079,7 @@ read = readSecret) {
2786
3079
  break;
2787
3080
  after = page.nextAfter;
2788
3081
  }
2789
- print(lines.join("\n") || "(no log yet)", lines);
3082
+ out.write(shown(out.json ? JSON.stringify(lines) : lines.join("\n") || "(no log yet)"));
2790
3083
  return 0;
2791
3084
  }
2792
3085
  case "tag":
@@ -2813,7 +3106,7 @@ read = readSecret) {
2813
3106
  ["REGISTRY", "KIND", "USER", "SINCE"],
2814
3107
  ...all.map((r) => [r.registry, r.kind, r.username, r.createdAt]),
2815
3108
  ])
2816
- : "No registry credentials. Store some: runtime image registry set ghcr.io --username you", all);
3109
+ : "No registry credentials. Store some: runtime images registry set ghcr.io --username you", all);
2817
3110
  return 0;
2818
3111
  }
2819
3112
  if (action === "set") {
@@ -2853,10 +3146,10 @@ read = readSecret) {
2853
3146
  "registry",
2854
3147
  ]);
2855
3148
  }
2856
- const JOB_HELP = `runtime job <command>
3149
+ const JOB_HELP = `runtime jobs <command>
2857
3150
 
2858
3151
  A job runs a command in a fresh sandbox, once or on a schedule. Each run spends
2859
- the included usage first, then credit at the sandbox rates.
3152
+ the free usage first, then credit at the sandbox rates.
2860
3153
 
2861
3154
  create <name> (--cron "<min hour day month weekday>" [--timezone Europe/Berlin] | --at <time|now>)
2862
3155
  [--vcpu 2] [--memory 4096] [--disk 4096] [--cpu shared|reserved] [--cpu-floor <thousandths>]
@@ -2871,14 +3164,21 @@ const JOB_HELP = `runtime job <command>
2871
3164
  ls Your jobs: state, schedule and next run
2872
3165
  get <id> One job, in full
2873
3166
  runs <id> Its runs: state, exit code, when
2874
- logs <runId|jobId> [-f] A run's output (a job id: its latest run); -f follows it
3167
+ logs <runId|jobId> [-f] [--raw] A run's output (a job id: its latest run); -f follows it;
3168
+ on a terminal control characters show as \\uXXXX
3169
+ unless --raw
2875
3170
  pause <id> | resume <id> Stop or restart scheduling; a run already going finishes
3171
+ trigger <id> Run it now, once; its schedule does not move, and a
3172
+ paused job stays paused
3173
+ reschedule <id> (--cron "<five fields>" [--timezone Europe/Berlin] | --at <time|now>)
3174
+ A new schedule; the next run is its next time
2876
3175
  cancel <id> End it for good and stop a run in progress
2877
3176
 
2878
3177
  Examples
2879
- runtime job create nightly --cron "0 3 * * *" --timezone Europe/Berlin -- python3 /workspace/report.py
2880
- runtime job create once --at now -- bash -lc 'echo hello from a job'
2881
- runtime job logs <jobId> -f
3178
+ runtime jobs create nightly --cron "0 3 * * *" --timezone Europe/Berlin -- python3 /workspace/report.py
3179
+ runtime jobs create once --at now -- bash -lc 'echo hello from a job'
3180
+ runtime jobs trigger <jobId> && runtime jobs logs <jobId> -f
3181
+ runtime jobs reschedule <jobId> --cron "30 4 * * 1-5" --timezone Europe/Berlin
2882
3182
  `;
2883
3183
  const when = (ms) => (ms == null ? "-" : new Date(ms).toISOString());
2884
3184
  function scheduleText(job) {
@@ -2926,13 +3226,13 @@ async function jobCommand(argv, env, out) {
2926
3226
  "max-cost",
2927
3227
  "max-total-cost",
2928
3228
  "region",
2929
- ], `${me} job create <name> --cron "0 3 * * *" -- python3 -c '...'`);
3229
+ ], `${me} jobs create <name> --cron "0 3 * * *" -- python3 -c '...'`);
2930
3230
  const name = need(args.positional[0], "the job's name, such as nightly-report");
2931
3231
  if (args.positional.length > 1)
2932
- throw usage(`Put the command after --: ${me} job create ${name} ... -- ${args.positional.slice(1).join(" ")}`);
3232
+ throw usage(`Put the command after --: ${me} jobs create ${name} ... -- ${args.positional.slice(1).join(" ")}`);
2933
3233
  const argv = args.rest ?? [];
2934
3234
  if (!argv.length)
2935
- throw usage(`Give the command after --: ${me} job create ${name} --at now -- echo hello`);
3235
+ throw usage(`Give the command after --: ${me} jobs create ${name} --at now -- echo hello`);
2936
3236
  const cron = flag(args, "cron");
2937
3237
  const at = flag(args, "at");
2938
3238
  if ((cron === undefined) === (at === undefined))
@@ -2989,7 +3289,7 @@ async function jobCommand(argv, env, out) {
2989
3289
  : {}),
2990
3290
  });
2991
3291
  if (!out.json)
2992
- out.error(`Scheduled ${job.name}: ${scheduleText(job)}. Next run ${when(job.nextRunAt)}. Follow it: ${me} job logs ${job.id} -f`);
3292
+ out.error(`Scheduled ${job.name}: ${scheduleText(job)}. Next run ${when(job.nextRunAt)}. Follow it: ${me} jobs logs ${job.id} -f`);
2993
3293
  print(job.id, job);
2994
3294
  return 0;
2995
3295
  }
@@ -3009,7 +3309,7 @@ async function jobCommand(argv, env, out) {
3009
3309
  j.blockedReason ?? "-",
3010
3310
  ]),
3011
3311
  ])
3012
- : `No jobs. Schedule one: ${me} job create hello --at now -- echo hello`, all);
3312
+ : `No jobs. Schedule one: ${me} jobs create hello --at now -- echo hello`, all);
3013
3313
  return 0;
3014
3314
  }
3015
3315
  case "get": {
@@ -3038,8 +3338,13 @@ async function jobCommand(argv, env, out) {
3038
3338
  return 0;
3039
3339
  }
3040
3340
  case "logs": {
3041
- const args = parse(rest, ["f", "follow"], []);
3341
+ const args = parse(rest, ["f", "follow", "raw"], []);
3042
3342
  const id = need(args.positional[0], "a run id or a job id");
3343
+ const [stdout, stderr, shown] = [
3344
+ logStream(process.stdout, has(args, "raw")),
3345
+ logStream(process.stderr, has(args, "raw")),
3346
+ logText(args),
3347
+ ];
3043
3348
  let runId = id;
3044
3349
  try {
3045
3350
  await api.run(id);
@@ -3067,25 +3372,27 @@ async function jobCommand(argv, env, out) {
3067
3372
  for (;;) {
3068
3373
  const page = await api.logs(runId, { cursor, limitBytes: 65536 });
3069
3374
  if (out.json)
3070
- out.write(JSON.stringify(page));
3375
+ out.write(shown(JSON.stringify(page)));
3071
3376
  else
3072
3377
  for (const chunk of page.chunks)
3073
- (chunk.stream === "stderr" ? process.stderr : process.stdout).write(chunk.text);
3378
+ (chunk.stream === "stderr" ? stderr : stdout)(chunk.text);
3074
3379
  cursor = page.nextCursor;
3075
3380
  if (page.complete) {
3381
+ if (!follow && out.json)
3382
+ return 0;
3383
+ const run = await api.run(runId);
3076
3384
  if (!out.json) {
3077
- const run = await api.run(runId);
3078
3385
  if (page.truncated)
3079
3386
  process.stderr.write("[the run printed more than it keeps; this is its last part]\n");
3080
3387
  process.stderr.write(`[${run.state}${run.exitCode === null ? "" : `, exit code ${run.exitCode}`}${run.reason ? `, ${run.reason}` : ""}]\n`);
3081
3388
  }
3082
- return 0;
3389
+ return follow ? (run.exitCode ?? (run.state === "succeeded" ? 0 : 1)) : 0;
3083
3390
  }
3084
3391
  if (page.chunks.length)
3085
3392
  continue;
3086
3393
  if (!follow) {
3087
3394
  if (!out.json)
3088
- process.stderr.write(`[${runId} is still going; follow it: ${me} job logs ${runId} -f]\n`);
3395
+ process.stderr.write(`[${runId} is still going; follow it: ${me} jobs logs ${runId} -f]\n`);
3089
3396
  return 0;
3090
3397
  }
3091
3398
  await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -3099,6 +3406,27 @@ async function jobCommand(argv, env, out) {
3099
3406
  print(`${job.name} ${job.state}.`, job);
3100
3407
  return 0;
3101
3408
  }
3409
+ case "trigger": {
3410
+ const id = need(rest[0], "a job id");
3411
+ const job = await api.trigger(id);
3412
+ print(`${job.name} runs now${job.state === "paused" ? ", once; it stays paused" : ""}. Follow it: ${me} jobs logs ${job.id} -f`, job);
3413
+ return 0;
3414
+ }
3415
+ case "reschedule": {
3416
+ const args = parse(rest, [], ["cron", "timezone", "at"], `${me} jobs reschedule <id> --cron "0 3 * * *" [--timezone Europe/Berlin]`);
3417
+ const id = need(args.positional[0], "a job id");
3418
+ const cron = flag(args, "cron");
3419
+ const at = flag(args, "at");
3420
+ if ((cron === undefined) === (at === undefined))
3421
+ throw usage('Give --cron "<five fields>" for a schedule, or --at <time|now> for one run.');
3422
+ if (at !== undefined && flag(args, "timezone") !== undefined)
3423
+ throw usage("--timezone goes with --cron; --at takes a time with its own offset.");
3424
+ const job = await api.reschedule(id, cron !== undefined
3425
+ ? { cron, ...(flag(args, "timezone") ? { timezone: flag(args, "timezone") } : {}) }
3426
+ : { at: atTime(at) });
3427
+ print(`${job.name}: ${scheduleText(job)}. Next run ${when(job.nextRunAt)}.`, job);
3428
+ return 0;
3429
+ }
3102
3430
  }
3103
3431
  throw unknown("job command", verb, [
3104
3432
  "create",
@@ -3108,12 +3436,15 @@ async function jobCommand(argv, env, out) {
3108
3436
  "logs",
3109
3437
  "pause",
3110
3438
  "resume",
3439
+ "trigger",
3440
+ "reschedule",
3111
3441
  "cancel",
3112
3442
  ]);
3113
3443
  }
3114
- const VOLUME_HELP = `runtime volume <command>
3444
+ const VOLUME_HELP = `runtime volumes <command>
3115
3445
 
3116
- create --size-mib <N> [--name n] [--shared] A persistent disk; prints its id
3446
+ create --size-mib <N> [--name n] [--label k=v]... [--shared]
3447
+ A persistent disk; prints its id
3117
3448
  ls Your volumes
3118
3449
  get <id> One volume, its backups and where it is attached
3119
3450
  resize <id> --size-mib <N> Grow a detached volume on its current host
@@ -3128,7 +3459,7 @@ const VOLUME_HELP = `runtime volume <command>
3128
3459
  backup-rm <backupId> Delete a backup
3129
3460
  backup-policy <id> [--daily on|off] [--retention-days N]
3130
3461
  Daily backups on or off, and how long each is kept
3131
- Attach one: runtime sandbox create --volume <id>:/data
3462
+ Attach one: runtime sandboxes create --volume <id>:/data
3132
3463
  Volumes are backed up off their host daily unless you turn it off.
3133
3464
  Shared commands work where shared disks are enabled.
3134
3465
  `;
@@ -3142,10 +3473,11 @@ async function volume(argv, env, out) {
3142
3473
  const api = (await client(env)).volumes;
3143
3474
  switch (verb) {
3144
3475
  case "create": {
3145
- const args = parse(rest, ["shared"], ["size-mib", "name"]);
3476
+ const args = parse(rest, ["shared"], ["size-mib", "name", "label"]);
3146
3477
  const created = await api.create({
3147
3478
  sizeMiB: integer(args, "size-mib") ?? Number(need(undefined, "--size-mib <N>")),
3148
3479
  ...(flag(args, "name") ? { name: flag(args, "name") } : {}),
3480
+ ...(args.flags.has("label") ? { labels: pairs(args, "label") } : {}),
3149
3481
  ...(has(args, "shared") ? { shared: true } : {}),
3150
3482
  });
3151
3483
  print(created.id, created);
@@ -3166,7 +3498,7 @@ async function volume(argv, env, out) {
3166
3498
  v.attachments.map((a) => `${a.sandboxId}:${a.path}`).join(", ") || "-",
3167
3499
  ]),
3168
3500
  ])
3169
- : "No volumes. Create one: runtime volume create --size-mib 10240", all);
3501
+ : "No volumes. Create one: runtime volumes create --size-mib 10240", all);
3170
3502
  return 0;
3171
3503
  }
3172
3504
  case "get": {
@@ -3178,7 +3510,7 @@ async function volume(argv, env, out) {
3178
3510
  const args = parse(rest, [], ["size-mib"]);
3179
3511
  const id = need(args.positional[0], "a volume id");
3180
3512
  if (args.positional.length !== 1 || args.rest)
3181
- throw usage(`Use ${me} volume resize <id> --size-mib <N>.`);
3513
+ throw usage(`Use ${me} volumes resize <id> --size-mib <N>.`);
3182
3514
  const sizeMiB = integer(args, "size-mib") ?? Number(need(undefined, "--size-mib <N>"));
3183
3515
  const resized = await api.resize(id, { sizeMiB });
3184
3516
  print(describeRecord(resized, table), resized);
@@ -3190,7 +3522,7 @@ async function volume(argv, env, out) {
3190
3522
  const sandboxId = need(args.positional[1], "a sandbox id");
3191
3523
  const path = need(flag(args, "path"), "--path /data");
3192
3524
  if (args.positional.length !== 2 || args.rest)
3193
- throw usage(`Use ${me} volume attach <id> <sandboxId> --path /data.`);
3525
+ throw usage(`Use ${me} volumes attach <id> <sandboxId> --path /data.`);
3194
3526
  const attachment = await api.attach(id, { sandboxId, path });
3195
3527
  print(describeRecord(attachment, table), attachment);
3196
3528
  return attachment.error ? 1 : 0;
@@ -3201,7 +3533,7 @@ async function volume(argv, env, out) {
3201
3533
  const id = need(args.positional[0], "a volume id");
3202
3534
  const attachmentId = need(args.positional[1], "an attachment id");
3203
3535
  if (args.positional.length !== 2 || args.rest)
3204
- throw usage(`Use ${me} volume ${verb} <id> <attachmentId>.`);
3536
+ throw usage(`Use ${me} volumes ${verb} <id> <attachmentId>.`);
3205
3537
  const attachment = verb === "detach"
3206
3538
  ? await api.detach(id, attachmentId)
3207
3539
  : await api.getAttachment(id, attachmentId);
@@ -3210,7 +3542,8 @@ async function volume(argv, env, out) {
3210
3542
  }
3211
3543
  case "rm":
3212
3544
  case "delete": {
3213
- const found = await api.delete(need(rest[0], "a volume id"));
3545
+ const id = need(rest[0], "a volume id");
3546
+ const found = await withVolumeRelease((options) => api.delete(id, options), out);
3214
3547
  print(`${found.id} ${found.state}`, found);
3215
3548
  return 0;
3216
3549
  }
@@ -3220,7 +3553,7 @@ async function volume(argv, env, out) {
3220
3553
  const days = integer(args, "retention-days");
3221
3554
  const made = await api.backup(id, days === undefined ? {} : { retentionDays: days }, has(args, "no-wait") ? { wait: 0 } : {});
3222
3555
  if (!out.json && made.state === "pending")
3223
- out.error(`Copying it off the host; check with: ${me} volume backups ${id}`);
3556
+ out.error(`Copying it off the host; check with: ${me} volumes backups ${id}`);
3224
3557
  print(made.id, made);
3225
3558
  return 0;
3226
3559
  }
@@ -3248,7 +3581,7 @@ async function volume(argv, env, out) {
3248
3581
  const args = parse(rest, [], ["name"]);
3249
3582
  const created = await api.restore(need(args.positional[0], "a backup id"), flag(args, "name") ? { name: flag(args, "name") } : {});
3250
3583
  if (!out.json && created.state === "creating")
3251
- out.error(`Restoring; check with: ${me} volume get ${created.id}`);
3584
+ out.error(`Restoring; check with: ${me} volumes get ${created.id}`);
3252
3585
  print(created.id, created);
3253
3586
  return 0;
3254
3587
  }
@@ -3307,12 +3640,47 @@ const CREATE_FLAGS = [
3307
3640
  function waiting(out) {
3308
3641
  const said = new Set();
3309
3642
  return (refusal) => {
3643
+ // The CLI's volume wait owns this refusal, including its deadline.
3644
+ if (refusal.code === "volume_releasing")
3645
+ throw refusal;
3310
3646
  if (said.has(refusal.code))
3311
3647
  return;
3312
3648
  said.add(refusal.code);
3313
3649
  out.error(`${refusal.message} Waiting for room, up to 2 minutes; Ctrl-C stops.`);
3314
3650
  };
3315
3651
  }
3652
+ /** A stopping sandbox can take a minute to let go of its volume. Retry only
3653
+ * that refusal, with the same key so a create cannot make two sandboxes. */
3654
+ async function withVolumeRelease(operation, out) {
3655
+ const idempotencyKey = crypto.randomUUID();
3656
+ let until;
3657
+ for (;;) {
3658
+ try {
3659
+ return await operation({
3660
+ idempotencyKey,
3661
+ ...(until === undefined
3662
+ ? {}
3663
+ : { signal: AbortSignal.timeout(Math.max(1, Math.ceil(until - performance.now()))) }),
3664
+ });
3665
+ }
3666
+ catch (error) {
3667
+ if (!(error instanceof RuntimeError) ||
3668
+ error.code !== "volume_releasing" ||
3669
+ error.status !== 409)
3670
+ throw error;
3671
+ if (until === undefined) {
3672
+ until = performance.now() + 90_000;
3673
+ out.error("Waiting for the volume to be let go, up to 90 seconds; Ctrl-C stops.");
3674
+ }
3675
+ const left = until - performance.now();
3676
+ if (left <= 0)
3677
+ throw error;
3678
+ await new Promise((resolve) => setTimeout(resolve, Math.min(1000, left)));
3679
+ if (performance.now() >= until)
3680
+ throw error;
3681
+ }
3682
+ }
3683
+ }
3316
3684
  /** Options `sandbox create` and `run` take with a value. */
3317
3685
  const CREATE_VALUES = [
3318
3686
  "name",
@@ -3353,6 +3721,9 @@ function usdMicros(args, name) {
3353
3721
  }
3354
3722
  /** A create body from the command line's flags. */
3355
3723
  function createInput(args) {
3724
+ for (const name of ["image", "snapshot", "volume", "name", "region", "connect"])
3725
+ if (args.flags.get(name)?.some((value) => !value.trim()))
3726
+ throw usage(`--${name} needs a non-empty value.`);
3356
3727
  const rules = ["allow", "deny", "connect"].filter((name) => args.flags.has(name));
3357
3728
  return {
3358
3729
  ...(flag(args, "image") ? { image: flag(args, "image") } : {}),
@@ -3387,7 +3758,7 @@ function createInput(args) {
3387
3758
  ? { timeoutSeconds: integer(args, "timeout") }
3388
3759
  : {}),
3389
3760
  ...(flag(args, "on-timeout")
3390
- ? { onLeaseEnd: oneOf(args, "on-timeout", ["pause", "stop"]) }
3761
+ ? { onLeaseEnd: oneOf(args, "on-timeout", ["pause", "stop", "delete"]) }
3391
3762
  : {}),
3392
3763
  ...(integer(args, "idle-pause") !== undefined
3393
3764
  ? { idlePauseSeconds: integer(args, "idle-pause") }
@@ -3449,6 +3820,9 @@ ownInterrupt = true) {
3449
3820
  // (Sandbox.exec); `--json` answers the result and prints no such line.
3450
3821
  else if (result.timedOut)
3451
3822
  out.error(`runtime: timed out after ${timeout ? `${timeout} s` : "24 h"}; the command's process group was killed.`);
3823
+ const memory = outOfMemoryLines(result.outOfMemory);
3824
+ if (memory && !out.json)
3825
+ out.error(memory);
3452
3826
  // Never drop output silently: say so, and do not exit 0.
3453
3827
  const lost = extras.lostOutput(result, sbx.id);
3454
3828
  if (lost && !out.json)