withruntime 0.11.2 → 0.12.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 (164) 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 +9 -8
  6. package/README.src.md +8 -7
  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 +77 -1
  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 +14 -5
  17. package/dist/blaxel/sandbox.js +63 -7
  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/logs.test.d.ts +1 -0
  23. package/dist/cli/logs.test.js +272 -0
  24. package/dist/cli-extras.d.ts +3 -3
  25. package/dist/cli-extras.js +17 -12
  26. package/dist/cli-words.d.ts +10 -1
  27. package/dist/cli-words.js +18 -0
  28. package/dist/cli.d.ts +5 -1
  29. package/dist/cli.js +405 -204
  30. package/dist/client.d.ts +2 -4
  31. package/dist/client.js +27 -11
  32. package/dist/cloudflare/errors.d.ts +143 -0
  33. package/dist/cloudflare/errors.js +167 -0
  34. package/dist/cloudflare/index.d.ts +175 -6
  35. package/dist/cloudflare/index.js +416 -26
  36. package/dist/cloudflare/interpreter.d.ts +119 -0
  37. package/dist/cloudflare/interpreter.js +223 -0
  38. package/dist/cloudflare/streams.d.ts +38 -0
  39. package/dist/cloudflare/streams.js +145 -0
  40. package/dist/cloudflare/watch.d.ts +9 -0
  41. package/dist/cloudflare/watch.js +24 -0
  42. package/dist/codesandbox/index.d.ts +216 -28
  43. package/dist/codesandbox/index.js +478 -78
  44. package/dist/compat/core.d.ts +18 -0
  45. package/dist/compat/core.js +29 -0
  46. package/dist/compat/files.d.ts +1 -0
  47. package/dist/compat/files.js +25 -5
  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 +6 -0
  51. package/dist/daytona/daytona.js +30 -3
  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 +27 -21
  69. package/dist/e2b/sandbox.js +34 -8
  70. package/dist/e2b/unsupported.js +1 -1
  71. package/dist/errors.d.ts +10 -1
  72. package/dist/errors.js +16 -4
  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 +1 -0
  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 +12 -3
  87. package/dist/machine.js +20 -3
  88. package/dist/mcp.js +1 -1
  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/apps.d.ts +176 -0
  102. package/dist/products/apps.js +165 -0
  103. package/dist/products/billing.d.ts +41 -0
  104. package/dist/products/billing.js +13 -0
  105. package/dist/products/browser.d.ts +10 -0
  106. package/dist/products/browsers.d.ts +71 -0
  107. package/dist/products/browsers.js +102 -0
  108. package/dist/products/builds.d.ts +126 -0
  109. package/dist/products/builds.js +221 -0
  110. package/dist/products/index.d.ts +3 -16
  111. package/dist/products/index.js +3 -14
  112. package/dist/products/jobs.d.ts +17 -0
  113. package/dist/products/jobs.js +27 -10
  114. package/dist/products/logs.d.ts +245 -0
  115. package/dist/products/logs.js +274 -0
  116. package/dist/products/logs.test.d.ts +1 -0
  117. package/dist/products/logs.test.js +343 -0
  118. package/dist/products/network-products.d.ts +38 -7
  119. package/dist/products/network-products.js +31 -18
  120. package/dist/products/observability.d.ts +3 -2
  121. package/dist/products/private-network.js +1 -1
  122. package/dist/products/queues.d.ts +149 -0
  123. package/dist/products/queues.js +213 -0
  124. package/dist/products/referrals.d.ts +21 -7
  125. package/dist/products/referrals.js +3 -3
  126. package/dist/products/secrets.d.ts +7 -3
  127. package/dist/products/secrets.js +39 -18
  128. package/dist/products/signed-requests.d.ts +21 -0
  129. package/dist/products/signed-requests.js +11 -0
  130. package/dist/products/streams.d.ts +73 -0
  131. package/dist/products/streams.js +17 -0
  132. package/dist/products/switching.d.ts +35 -7
  133. package/dist/products/switching.js +4 -3
  134. package/dist/products/volumes.d.ts +3 -0
  135. package/dist/record-cli.d.ts +23 -0
  136. package/dist/record-cli.js +308 -0
  137. package/dist/renamed-products.d.ts +38 -0
  138. package/dist/renamed-products.js +34 -0
  139. package/dist/runloop/index.d.ts +9 -2
  140. package/dist/runloop/index.js +29 -8
  141. package/dist/runloop/sdk.d.ts +131 -0
  142. package/dist/runloop/sdk.js +167 -0
  143. package/dist/sandbox.d.ts +3 -2
  144. package/dist/sandbox.js +14 -9
  145. package/dist/sandboxes/index.d.ts +11 -0
  146. package/dist/sandboxes/index.js +12 -0
  147. package/dist/snapshots/index.d.ts +9 -0
  148. package/dist/snapshots/index.js +11 -0
  149. package/dist/snapshots.d.ts +7 -4
  150. package/dist/sprites/index.d.ts +343 -31
  151. package/dist/sprites/index.js +866 -84
  152. package/dist/ssh.d.ts +1 -1
  153. package/dist/ssh.js +11 -11
  154. package/dist/terminal.d.ts +24 -0
  155. package/dist/terminal.js +32 -0
  156. package/dist/transport.d.ts +15 -1
  157. package/dist/transport.js +56 -3
  158. package/dist/tunnel.js +1 -1
  159. package/dist/types.d.ts +63 -0
  160. package/dist/vercel/sandbox.js +5 -3
  161. package/dist/vercel/snapshot.js +10 -2
  162. package/dist/volumes/index.d.ts +9 -0
  163. package/dist/volumes/index.js +10 -0
  164. package/package.json +48 -2
@@ -1,9 +1,244 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { PassThrough, Writable } from "node:stream";
3
- import { RuntimeError } from "../errors.js";
4
- import { all, client, CompatibilityError, create, destroy, execOptions, lookup, only, ready, region, } from "../compat/core.js";
3
+ import { NotFoundError, RuntimeError } from "../errors.js";
4
+ import { timeoutSignal } from "../transport.js";
5
+ import { all, client, CompatibilityError, create, destroy, execOptions, lookup, only, ready, refuse, region, } from "../compat/core.js";
5
6
  import { GuestFiles } from "../compat/files.js";
6
7
  export { CompatibilityError };
8
+ export const ERR_CODE_CREATION_RATE_LIMITED = "sprite_creation_rate_limited";
9
+ export const ERR_CODE_CONCURRENT_LIMIT_EXCEEDED = "concurrent_sprite_limit_exceeded";
10
+ /** A Sprites progress or event stream: the work behind it runs whether or
11
+ * not anyone reads, as the vendor's server-side operation does; `next()`
12
+ * answers null at the end, `close()` stops reading (and stops a watch). */
13
+ class Feed {
14
+ #items = [];
15
+ #waiters = [];
16
+ #ended = false;
17
+ #closed = false;
18
+ #error;
19
+ constructor(source) {
20
+ if (source)
21
+ void (async () => {
22
+ try {
23
+ for await (const item of source)
24
+ this.push(item);
25
+ this.end();
26
+ }
27
+ catch (error) {
28
+ this.end(error);
29
+ }
30
+ })();
31
+ }
32
+ push(item) {
33
+ if (this.#ended || this.#closed)
34
+ return;
35
+ this.#items.push(item);
36
+ this.#wake();
37
+ }
38
+ end(error) {
39
+ if (this.#ended)
40
+ return;
41
+ this.#ended = true;
42
+ this.#error = error;
43
+ this.#wake();
44
+ }
45
+ /** Called once by close(); a watch stops its native watch here. */
46
+ stop() { }
47
+ #wake() {
48
+ for (const waiter of this.#waiters.splice(0))
49
+ waiter();
50
+ }
51
+ async next() {
52
+ while (!this.#items.length && !this.#ended && !this.#closed)
53
+ await new Promise((resolve) => this.#waiters.push(resolve));
54
+ if (this.#closed)
55
+ return null;
56
+ if (this.#items.length)
57
+ return this.#items.shift();
58
+ if (this.#error !== undefined) {
59
+ const error = this.#error;
60
+ this.#error = undefined;
61
+ throw error instanceof Error ? error : new Error("The stream failed", { cause: error });
62
+ }
63
+ return null;
64
+ }
65
+ async processAll(handler) {
66
+ for await (const item of this)
67
+ await handler(item);
68
+ }
69
+ close() {
70
+ if (this.#closed)
71
+ return;
72
+ this.#closed = true;
73
+ this.stop();
74
+ this.#wake();
75
+ }
76
+ isClosed() {
77
+ return this.#closed || (this.#ended && !this.#items.length);
78
+ }
79
+ async *[Symbol.asyncIterator]() {
80
+ let item;
81
+ while ((item = await this.next()) !== null)
82
+ yield item;
83
+ }
84
+ }
85
+ export class CheckpointStream extends Feed {
86
+ }
87
+ export class RestoreStream extends Feed {
88
+ }
89
+ export class ServiceLogStream extends Feed {
90
+ }
91
+ export class SessionKillStream extends Feed {
92
+ }
93
+ export class SpriteListStream extends Feed {
94
+ }
95
+ export class APIError extends Error {
96
+ errorCode;
97
+ statusCode;
98
+ limit;
99
+ windowSeconds;
100
+ retryAfterSeconds;
101
+ currentCount;
102
+ upgradeAvailable;
103
+ upgradeUrl;
104
+ retryAfterHeader;
105
+ rateLimitLimit;
106
+ rateLimitRemaining;
107
+ rateLimitReset;
108
+ constructor(message, options = {}) {
109
+ super(message);
110
+ this.name = "APIError";
111
+ Object.assign(this, options);
112
+ }
113
+ isRateLimitError() {
114
+ return this.statusCode === 429;
115
+ }
116
+ isCreationRateLimited() {
117
+ return this.errorCode === ERR_CODE_CREATION_RATE_LIMITED;
118
+ }
119
+ isConcurrentLimitExceeded() {
120
+ return this.errorCode === ERR_CODE_CONCURRENT_LIMIT_EXCEEDED;
121
+ }
122
+ getRetryAfterSeconds() {
123
+ if (this.retryAfterSeconds !== undefined && this.retryAfterSeconds > 0)
124
+ return this.retryAfterSeconds;
125
+ return this.retryAfterHeader;
126
+ }
127
+ }
128
+ /** The vendor's parser, for callers that read Sprites-shaped answers themselves. */
129
+ export function parseAPIError(statusCode, body, headers = {}) {
130
+ if (statusCode < 400)
131
+ return undefined;
132
+ const header = (name) => {
133
+ const value = headers[name.toLowerCase()] ?? headers[name];
134
+ const number = value ? parseInt(value, 10) : NaN;
135
+ return Number.isNaN(number) ? undefined : number;
136
+ };
137
+ const retryAfterHeader = header("Retry-After");
138
+ let message = "";
139
+ let data = {};
140
+ if (body)
141
+ try {
142
+ data = JSON.parse(body);
143
+ message = data.message || "";
144
+ }
145
+ catch {
146
+ message = body;
147
+ }
148
+ const errorCode = data.error;
149
+ const limit = data.limit;
150
+ const windowSeconds = data.window_seconds;
151
+ const retryAfterSeconds = data.retry_after_seconds;
152
+ const currentCount = data.current_count;
153
+ const upgradeUrl = data.upgrade_url;
154
+ if (statusCode === 429 && message) {
155
+ const details = [];
156
+ if (limit !== undefined && windowSeconds !== undefined)
157
+ details.push(`limit: ${limit} per ${windowSeconds}s`);
158
+ if (currentCount !== undefined)
159
+ details.push(`current: ${currentCount}`);
160
+ const retry = retryAfterSeconds ?? retryAfterHeader;
161
+ if (retry !== undefined)
162
+ details.push(`retry after: ${retry}s`);
163
+ if (upgradeUrl)
164
+ details.push(`upgrade: ${upgradeUrl}`);
165
+ if (details.length)
166
+ message = `${message} (${details.join(", ")})`;
167
+ }
168
+ return new APIError(message || errorCode || `API error (status ${statusCode})`, {
169
+ errorCode,
170
+ statusCode,
171
+ limit,
172
+ windowSeconds,
173
+ retryAfterSeconds,
174
+ currentCount,
175
+ upgradeAvailable: data.upgrade_available,
176
+ upgradeUrl,
177
+ retryAfterHeader,
178
+ rateLimitLimit: header("X-RateLimit-Limit"),
179
+ rateLimitRemaining: header("X-RateLimit-Remaining"),
180
+ rateLimitReset: header("X-RateLimit-Reset"),
181
+ });
182
+ }
183
+ /** Runtime's refusal as the APIError the vendor's client throws; limits on
184
+ * running sandboxes are its concurrent limit, the API's rate limit on a
185
+ * create its creation limit. */
186
+ async function api(run, creating = false) {
187
+ try {
188
+ return await run();
189
+ }
190
+ catch (error) {
191
+ if (!(error instanceof RuntimeError) || error.status < 400)
192
+ throw error;
193
+ const errorCode = ["no_credit_running_limit", "quota_exceeded"].includes(error.code)
194
+ ? ERR_CODE_CONCURRENT_LIMIT_EXCEEDED
195
+ : creating && error.code === "rate_limited"
196
+ ? ERR_CODE_CREATION_RATE_LIMITED
197
+ : error.code;
198
+ const retry = error.retryAfterMs === undefined ? undefined : Math.ceil(error.retryAfterMs / 1000);
199
+ throw Object.assign(new APIError(error.message, {
200
+ errorCode,
201
+ statusCode: error.status,
202
+ retryAfterSeconds: retry,
203
+ retryAfterHeader: retry,
204
+ }), { cause: error });
205
+ }
206
+ }
207
+ /** "30s", "500ms", "2m" or "1h", as Sprites' durations are written. */
208
+ function duration(value, name) {
209
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(value);
210
+ if (!match)
211
+ throw new TypeError(`${name} must be a duration such as "10s"`);
212
+ return Number(match[1]) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[match[2]];
213
+ }
214
+ const SIGNALS = [
215
+ "SIGTERM",
216
+ "SIGKILL",
217
+ "SIGINT",
218
+ "SIGHUP",
219
+ "SIGQUIT",
220
+ "SIGUSR1",
221
+ "SIGUSR2",
222
+ ];
223
+ function signalName(signal) {
224
+ const name = (signal.startsWith("SIG") ? signal : `SIG${signal}`);
225
+ if (!SIGNALS.includes(name))
226
+ throw new TypeError(`Unsupported signal ${signal}`);
227
+ return name;
228
+ }
229
+ /** maxRunAfterDisconnect is left out: a Runtime process runs until it ends
230
+ * or is killed, whoever is connected. */
231
+ const SPAWN_KEYS = [
232
+ "cwd",
233
+ "env",
234
+ "tty",
235
+ "rows",
236
+ "cols",
237
+ "detachable",
238
+ "sessionId",
239
+ "controlMode",
240
+ ];
241
+ const EXEC_KEYS = [...SPAWN_KEYS, "encoding", "maxBuffer", "signal", "timeout"];
7
242
  function validateURLSettings(settings) {
8
243
  only("Sprites URL settings", settings, ["auth"]);
9
244
  if (settings.auth !== "public" && settings.auth !== "sprite")
@@ -48,19 +283,58 @@ async function fileOperation(path, syscall, run) {
48
283
  }
49
284
  }
50
285
  export class ExecError extends Error {
51
- exitCode;
52
- stdout;
53
- stderr;
54
- constructor(message, exitCode, stdout, stderr) {
286
+ result;
287
+ constructor(message, result) {
55
288
  super(message);
56
- this.exitCode = exitCode;
57
- this.stdout = stdout;
58
- this.stderr = stderr;
289
+ this.result = result;
59
290
  this.name = "ExecError";
60
291
  }
292
+ get exitCode() {
293
+ return this.result.exitCode;
294
+ }
295
+ get stdout() {
296
+ return this.result.stdout;
297
+ }
298
+ get stderr() {
299
+ return this.result.stderr;
300
+ }
301
+ }
302
+ /** The vendor's control-mode wire protocol: multiplexed operations over one
303
+ * WebSocket. Runtime's own API multiplexes commands without it. */
304
+ const CONTROL = "Runtime runs each command over its own API; use spawn, exec or execFile";
305
+ export class OpConn extends EventEmitter {
61
306
  }
307
+ refuse("Sprites", "OpConn", OpConn, Object.fromEntries([
308
+ "write",
309
+ "sendEOF",
310
+ "resize",
311
+ "signal",
312
+ "handleData",
313
+ "handleText",
314
+ "complete",
315
+ "close",
316
+ "isClosed",
317
+ "getExitCode",
318
+ ].map((name) => [name, CONTROL])), { sync: true });
319
+ refuse("Sprites", "OpConn", OpConn, { wait: CONTROL });
320
+ export class ControlConnection extends EventEmitter {
321
+ }
322
+ refuse("Sprites", "ControlConnection", ControlConnection, Object.fromEntries(["isActive", "setActive", "clearOpConn", "sendData", "close", "isClosed"].map((name) => [
323
+ name,
324
+ CONTROL,
325
+ ])), { sync: true });
326
+ refuse("Sprites", "ControlConnection", ControlConnection, {
327
+ connect: CONTROL,
328
+ startOp: CONTROL,
329
+ });
62
330
  export class SpritesClient {
63
331
  runtime;
332
+ /** The Runtime API the calls go to. */
333
+ baseURL;
334
+ /** The Runtime key given, if any; otherwise RUNTIME_API_KEY or the saved login is used. */
335
+ token;
336
+ /** Always false: true is refused (control mode). */
337
+ controlMode = false;
64
338
  constructor(token, options = {}) {
65
339
  only("Sprites client", options, [
66
340
  "apiKey",
@@ -82,6 +356,8 @@ export class SpritesClient {
82
356
  baseUrl: options.baseUrl ?? options.baseURL,
83
357
  timeoutMs: options.timeoutMs ?? options.timeout,
84
358
  });
359
+ this.baseURL = this.runtime.transport?.baseUrl ?? options.baseUrl ?? options.baseURL ?? "";
360
+ this.token = options.apiKey ?? token ?? "";
85
361
  }
86
362
  sprite(name) {
87
363
  return new Sprite(name, this);
@@ -105,7 +381,7 @@ export class SpritesClient {
105
381
  validateLabels(o.labels);
106
382
  const c = o.config ?? {};
107
383
  only("Sprites config", c, ["ramMB", "cpus", "region", "storageGB"]);
108
- const s = await create(this.runtime, "sprites", {
384
+ const s = await api(() => create(this.runtime, "sprites", {
109
385
  name,
110
386
  persistent: true,
111
387
  vcpu: c.cpus,
@@ -113,9 +389,10 @@ export class SpritesClient {
113
389
  diskMiB: c.storageGB === undefined ? undefined : Math.ceil((c.storageGB * 1e9) / 1048576),
114
390
  region: region("Sprites", c.region),
115
391
  labels: { "compat.labels": JSON.stringify(o.labels ?? []) },
116
- }, o.environment, undefined, o.waitForCapacity === false ? { waitForCapacityMs: 0 } : {});
392
+ }, o.environment, undefined, o.waitForCapacity === false ? { waitForCapacityMs: 0 } : {}), true);
117
393
  const sprite = new Sprite(name, this);
118
394
  sprite.hydrate(s);
395
+ sprite.environment = { ...o.environment };
119
396
  if (o.urlSettings) {
120
397
  try {
121
398
  await sprite.updateURLSettings(o.urlSettings);
@@ -129,15 +406,15 @@ export class SpritesClient {
129
406
  }
130
407
  async getSprite(name) {
131
408
  const sprite = this.sprite(name);
132
- sprite.hydrate(await lookup(this.runtime, name));
409
+ sprite.hydrate(await api(() => lookup(this.runtime, name)));
133
410
  return sprite;
134
411
  }
135
412
  async deleteSprite(name) {
136
- await destroy(await lookup(this.runtime, name));
413
+ await api(async () => destroy(await lookup(this.runtime, name)));
137
414
  }
138
415
  async listSprites(options = {}) {
139
416
  only("Sprites list", options, ["prefix", "maxResults", "continuationToken", "bulkLoad"]);
140
- const values = (await all(this.runtime, "sprites")).filter((s) => (s.info.name ?? s.id).startsWith(options.prefix ?? ""));
417
+ const values = (await api(() => all(this.runtime, "sprites"))).filter((s) => (s.info.name ?? s.id).startsWith(options.prefix ?? ""));
141
418
  const offset = Number(options.continuationToken ?? 0), limit = options.maxResults ?? 100;
142
419
  return {
143
420
  sprites: values.slice(offset, offset + limit).map((s) => ({
@@ -158,7 +435,7 @@ export class SpritesClient {
158
435
  };
159
436
  }
160
437
  async listAllSprites(prefix) {
161
- return (await all(this.runtime, "sprites"))
438
+ return (await api(() => all(this.runtime, "sprites")))
162
439
  .filter((s) => (s.info.name ?? s.id).startsWith(prefix ?? ""))
163
440
  .map((s) => {
164
441
  const sprite = this.sprite(s.info.name ?? s.id);
@@ -167,25 +444,65 @@ export class SpritesClient {
167
444
  });
168
445
  }
169
446
  async restartSprite(name) {
170
- const s = await lookup(this.runtime, name);
171
- await s.stop();
172
- await s.restart();
173
- return { spriteName: name, machineId: s.id, message: "Restarted" };
447
+ return api(async () => {
448
+ const s = await lookup(this.runtime, name);
449
+ await s.stop();
450
+ await s.restart();
451
+ return { spriteName: name, machineId: s.id, message: "Restarted" };
452
+ });
453
+ }
454
+ /** Its state as Runtime reports it now; `elapsed` is the lookup's milliseconds. */
455
+ async checkSprite(name) {
456
+ const started = performance.now();
457
+ const s = await api(() => lookup(this.runtime, name));
458
+ return {
459
+ spriteName: name,
460
+ spriteId: s.id,
461
+ status: s.state,
462
+ checkedAt: new Date(),
463
+ elapsed: Math.round(performance.now() - started),
464
+ };
465
+ }
466
+ /** Runtime keeps each sandbox's system current itself, so there is nothing
467
+ * to move to: this checks that the Sprite exists, as the vendor's call does. */
468
+ async upgradeSprite(name) {
469
+ await api(() => lookup(this.runtime, name));
470
+ }
471
+ async updateSprite(name, options) {
472
+ return (await this.getSprite(name)).update(options);
174
473
  }
175
474
  updateURLSettings(name, settings) {
176
475
  return this.sprite(name).updateURLSettings(settings);
177
476
  }
178
477
  }
478
+ refuse("Sprites", "SpritesClient", SpritesClient, {
479
+ watchSprites: "watch sandbox state with runtime.events or signed webhooks",
480
+ });
481
+ refuse("Sprites", "SpritesClient", { prototype: SpritesClient }, {
482
+ createToken: "Fly macaroons are not Runtime credentials; create a Runtime API key in the console",
483
+ });
179
484
  export class Sprite {
180
485
  name;
181
486
  client;
182
487
  id;
488
+ /** Runtime does not send the organization's name with a sandbox. */
489
+ organizationName;
183
490
  status;
184
491
  config;
492
+ /** Set by createSprite; Runtime keeps it in the sandbox, not in its listing. */
493
+ environment;
185
494
  createdAt;
186
495
  updatedAt;
187
496
  url;
497
+ urlSettings;
498
+ /** Vendor storage and version fields; Runtime has no equivalent to report. */
499
+ bucketName;
500
+ primaryRegion;
501
+ version;
502
+ environmentVersion;
188
503
  labels = [];
504
+ lastRunningAt;
505
+ lastWarmingAt;
189
506
  constructor(name, client) {
190
507
  this.name = name;
191
508
  this.client = client;
@@ -199,6 +516,7 @@ export class Sprite {
199
516
  region: s.info.region,
200
517
  storageGB: (s.info.diskMiB * 1048576) / 1e9,
201
518
  };
519
+ this.primaryRegion = s.info.region;
202
520
  this.createdAt = new Date(s.info.createdAt);
203
521
  this.updatedAt = this.createdAt;
204
522
  this.labels = JSON.parse(s.info.labels["compat.labels"] ?? "[]");
@@ -209,36 +527,49 @@ export class Sprite {
209
527
  return s;
210
528
  }
211
529
  spawn(command, args = [], options = {}) {
530
+ only("Sprites spawn", options, EXEC_KEYS);
212
531
  return new SpriteCommand(this, command, args, options);
213
532
  }
214
- exec(command, options) {
533
+ exec(command, options = {}) {
534
+ only("Sprites exec", options, EXEC_KEYS);
215
535
  return this.execFile("bash", ["-c", command], options);
216
536
  }
217
- async execFile(file, args = [], options = {}) {
218
- options.signal?.throwIfAborted();
219
- if (options.timeout !== undefined && (!Number.isFinite(options.timeout) || options.timeout < 0))
220
- throw new TypeError("timeout must be a non-negative finite number");
221
- if (options.encoding &&
222
- options.encoding !== "buffer" &&
223
- !Buffer.isEncoding(options.encoding))
224
- throw new TypeError(`Unknown encoding: ${String(options.encoding)}`);
225
- only("Sprites exec", options, [
537
+ execFile(file, args = [], options = {}) {
538
+ only("Sprites exec", options, EXEC_KEYS);
539
+ return this.#execute(file, args, options);
540
+ }
541
+ /** Runtime's commands are HTTP calls already; `input` is written to stdin
542
+ * and stdin closed, and without it stdin is closed at once. */
543
+ execFileHTTP(file, args = [], options = {}) {
544
+ only("Sprites HTTP exec", options, [
226
545
  "cwd",
227
546
  "env",
228
- "tty",
229
- "rows",
230
- "cols",
231
- "detachable",
232
- "sessionId",
233
- "controlMode",
234
547
  "encoding",
235
548
  "maxBuffer",
236
549
  "signal",
237
550
  "timeout",
551
+ "input",
238
552
  ]);
553
+ const { input, ...rest } = options;
554
+ return this.#execute(file, args, rest, input ?? "");
555
+ }
556
+ async #execute(file, args, options, input) {
557
+ options.signal?.throwIfAborted();
558
+ if (options.timeout !== undefined && (!Number.isFinite(options.timeout) || options.timeout < 0))
559
+ throw new TypeError("timeout must be a non-negative finite number");
560
+ if (options.encoding &&
561
+ options.encoding !== "buffer" &&
562
+ !Buffer.isEncoding(options.encoding))
563
+ throw new TypeError(`Unknown encoding: ${String(options.encoding)}`);
239
564
  if (options.controlMode)
240
565
  throw new CompatibilityError("Sprites", "control-mode wire protocol");
241
566
  const cmd = this.spawn(file, args, options);
567
+ if (input !== undefined) {
568
+ if (input.length)
569
+ cmd.stdin.end(input);
570
+ else
571
+ cmd.stdin.end();
572
+ }
242
573
  const out = [], err = [];
243
574
  const maxBuffer = options.maxBuffer || 10 * 1024 * 1024;
244
575
  let stdoutBytes = 0;
@@ -292,7 +623,7 @@ export class Sprite {
292
623
  exitCode,
293
624
  };
294
625
  if (exitCode !== 0)
295
- throw new ExecError(`Command exited with code ${exitCode}`, exitCode, r.stdout, r.stderr);
626
+ throw new ExecError(`Command exited with code ${exitCode}`, r);
296
627
  return r;
297
628
  }
298
629
  finally {
@@ -301,15 +632,14 @@ export class Sprite {
301
632
  options.signal?.removeEventListener("abort", abort);
302
633
  }
303
634
  }
304
- execFileHTTP(file, args, options) {
305
- return this.execFile(file, args, options);
306
- }
307
635
  createSession(command, args = [], options = {}) {
636
+ only("Sprites session", options, SPAWN_KEYS);
308
637
  // The pinned SDK's session wrapper forces a terminal, even when a caller
309
638
  // supplies tty:false. Attaching an existing session detects its own PTY.
310
639
  return this.spawn(command, args, { ...options, detachable: true, tty: true });
311
640
  }
312
641
  attachSession(sessionId, options = {}) {
642
+ only("Sprites session", options, SPAWN_KEYS);
313
643
  return this.spawn("", [], { ...options, sessionId });
314
644
  }
315
645
  async listSessions() {
@@ -336,19 +666,149 @@ export class Sprite {
336
666
  filesystem(workingDir = "/") {
337
667
  return new SpriteFilesystem(() => this.native(), workingDir);
338
668
  }
339
- async createCheckpoint(_comment) {
340
- throw new CompatibilityError("Sprites", "filesystem-only checkpoints; Runtime snapshots also include running memory");
669
+ /** Ends a session: `signal` (SIGTERM by default; "TERM" works too), then
670
+ * SIGKILL if it has not exited within `timeout` ("10s" by default). */
671
+ async killSession(sessionId, signal = "SIGTERM", timeout = "10s") {
672
+ const name = signalName(signal);
673
+ const ms = duration(timeout, "timeout");
674
+ const process = await (await this.native()).processes.get(sessionId);
675
+ const kill = (async function* () {
676
+ try {
677
+ await process.kill(name);
678
+ yield { type: "signal", signal: name };
679
+ try {
680
+ const result = await process.wait({ signal: timeoutSignal(ms) });
681
+ yield { type: "exited", exitCode: result.exitCode ?? undefined };
682
+ }
683
+ catch (error) {
684
+ if (!(error instanceof Error) || !["TimeoutError", "AbortError"].includes(error.name))
685
+ throw error;
686
+ yield { type: "timeout", message: `Still running after ${timeout}` };
687
+ await process.kill("SIGKILL");
688
+ yield { type: "killed", signal: "SIGKILL" };
689
+ }
690
+ }
691
+ catch (error) {
692
+ if (!(error instanceof RuntimeError))
693
+ throw error;
694
+ yield { type: "error", message: error.message };
695
+ return;
696
+ }
697
+ yield { type: "complete" };
698
+ })();
699
+ return new SessionKillStream(kill);
700
+ }
701
+ /** Ports listening in the Sprite now, then each one opened or closed. */
702
+ async watchPorts() {
703
+ const watcher = new PortWatcher(this.client, this.id ?? this.name);
704
+ await watcher.connect();
705
+ return watcher;
341
706
  }
342
- async listCheckpoints() {
343
- throw new CompatibilityError("Sprites", "filesystem-only checkpoints");
707
+ /** Runtime keeps each sandbox's system current itself; this checks it exists. */
708
+ async upgrade() {
709
+ await this.client.upgradeSprite(this.id ?? this.name);
344
710
  }
345
- async getCheckpoint(_id) {
346
- throw new CompatibilityError("Sprites", "filesystem-only checkpoints");
711
+ check() {
712
+ return this.client.checkSprite(this.id ?? this.name);
347
713
  }
714
+ /** A checkpoint is a Runtime disk snapshot: the files, not the running
715
+ * processes. The Sprite is paused for the moment it takes, then woken.
716
+ * Progress arrives as the vendor's stream; a refusal is an `error` message. */
717
+ async createCheckpoint(comment) {
718
+ if (comment !== undefined && typeof comment !== "string")
719
+ throw new TypeError("comment must be a string");
720
+ const s = await this.native();
721
+ const taken = s.snapshot({
722
+ mode: "disk",
723
+ labels: { "compat.sprite": s.id, ...(comment ? { "compat.comment": comment } : {}) },
724
+ });
725
+ taken.catch(() => undefined);
726
+ return new CheckpointStream((async function* () {
727
+ yield { type: "info", data: "Creating checkpoint" };
728
+ try {
729
+ const snapshot = await taken;
730
+ yield { type: "info", data: `Checkpoint ${snapshot.id} created` };
731
+ }
732
+ catch (error) {
733
+ if (!(error instanceof RuntimeError))
734
+ throw error;
735
+ yield { type: "error", error: error.message };
736
+ return;
737
+ }
738
+ yield { type: "complete" };
739
+ })());
740
+ }
741
+ /** This Sprite's disk snapshots, newest first. */
742
+ async listCheckpoints(historyFilter) {
743
+ if (historyFilter !== undefined)
744
+ throw new CompatibilityError("Sprites", "checkpoint history filters");
745
+ const s = await this.native();
746
+ const found = [];
747
+ for await (const snapshot of await this.client.runtime.snapshots.list({
748
+ sandboxId: s.id,
749
+ state: "ready",
750
+ }))
751
+ if (snapshot.mode === "disk" && snapshot.sourceSandboxId === s.id)
752
+ found.push(checkpoint(snapshot));
753
+ return found.sort((a, b) => b.createTime.getTime() - a.createTime.getTime());
754
+ }
755
+ async getCheckpoint(id) {
756
+ const s = await this.native();
757
+ let snapshot;
758
+ try {
759
+ snapshot = await this.client.runtime.snapshots.get(id);
760
+ }
761
+ catch (error) {
762
+ if (error instanceof NotFoundError)
763
+ throw new Error(`Checkpoint not found: ${id}`);
764
+ throw error;
765
+ }
766
+ if (snapshot.mode !== "disk" || snapshot.sourceSandboxId !== s.id)
767
+ throw new Error(`Checkpoint not found: ${id}`);
768
+ return checkpoint(snapshot);
769
+ }
770
+ /** Forwards localhost:`localPort` here to `remotePort` in the Sprite. */
771
+ proxyPort(localPort, remotePort, remoteHost) {
772
+ return proxyPort(this.client, this.id ?? this.name, localPort, remotePort, remoteHost);
773
+ }
774
+ proxyPorts(mappings) {
775
+ return proxyPorts(this.client, this.id ?? this.name, mappings);
776
+ }
777
+ /** Runtime's network rules as Sprites rules: `* deny` closes the internet,
778
+ * allow rules are the only destinations reachable, deny rules are refused. */
779
+ async getNetworkPolicy() {
780
+ const policy = await (await this.native()).network.get();
781
+ if (!policy.internet)
782
+ return { rules: [{ domain: "*", action: "deny" }] };
783
+ const rules = [
784
+ ...policy.allow.map((domain) => ({ domain, action: "allow" })),
785
+ ...policy.deny.map((domain) => ({ domain, action: "deny" })),
786
+ ];
787
+ if (policy.allow.length)
788
+ rules.push({ domain: "*", action: "deny" });
789
+ return { rules };
790
+ }
791
+ /** Replaces the rules, keeping any host:port openings set through Runtime. */
792
+ async updateNetworkPolicy(policy) {
793
+ const rules = networkRules(policy);
794
+ const s = await this.native();
795
+ const current = await s.network.get();
796
+ await s.network.set({ ...rules, connect: current.connect });
797
+ }
798
+ /** Control mode is never on: Runtime refuses `controlMode: true`. */
799
+ useControlMode() {
800
+ return false;
801
+ }
802
+ hasControlConnection() {
803
+ return false;
804
+ }
805
+ /** Nothing to close: no control connection is ever opened. */
806
+ closeControlConnection() { }
348
807
  async updateURLSettings(settings) {
349
808
  validateURLSettings(settings);
350
809
  const p = await (await this.native()).previews.create(8080, { visibility: settings.auth === "public" ? "public" : "private" });
351
810
  this.url = p.urlWithToken ?? p.url;
811
+ this.urlSettings = { auth: settings.auth };
352
812
  }
353
813
  async update(options) {
354
814
  only("Sprites update", options, ["labels", "urlSettings"]);
@@ -368,6 +828,289 @@ export class Sprite {
368
828
  return this;
369
829
  }
370
830
  }
831
+ const SERVICES = "Runtime has no service manager; run it as a process with spawn, or as an image's start command";
832
+ refuse("Sprites", "Sprite", Sprite, {
833
+ restoreCheckpoint: "Runtime has no in-place restore; start a new sandbox from the checkpoint with runtime.sandboxes.create({ snapshot: id })",
834
+ getControlConnection: CONTROL,
835
+ listServices: SERVICES,
836
+ getService: SERVICES,
837
+ createService: SERVICES,
838
+ deleteService: SERVICES,
839
+ startService: SERVICES,
840
+ stopService: SERVICES,
841
+ restartService: SERVICES,
842
+ getServiceLogs: SERVICES,
843
+ signalService: SERVICES,
844
+ getPrivilegesPolicy: "Runtime sets one privilege profile for every sandbox: root through sudo",
845
+ updatePrivilegesPolicy: "Runtime sets one privilege profile for every sandbox: root through sudo",
846
+ deletePrivilegesPolicy: "Runtime sets one privilege profile for every sandbox: root through sudo",
847
+ getResourcesPolicy: "read the sandbox's memory size from runtime.sandboxes.get",
848
+ updateResourcesPolicy: "change memory with the native sandbox's resize(), which restarts its programs",
849
+ deleteResourcesPolicy: "change memory with the native sandbox's resize(), which restarts its programs",
850
+ });
851
+ function checkpoint(snapshot) {
852
+ const labels = (snapshot.labels ?? {});
853
+ return {
854
+ id: snapshot.id,
855
+ createTime: new Date(snapshot.createdAt),
856
+ comment: labels["compat.comment"],
857
+ isAuto: false,
858
+ health: snapshot.state === "ready" ? "" : snapshot.state,
859
+ };
860
+ }
861
+ /** Sprites lets the more specific rule win and reads `rules: []` as no
862
+ * limit; Runtime's allow list is the only destinations reachable and its deny
863
+ * list always wins. A policy whose meaning would differ is refused. */
864
+ function networkRules(policy) {
865
+ if (!policy || typeof policy !== "object" || !Array.isArray(policy.rules))
866
+ throw new TypeError("policy.rules must be an array");
867
+ only("Sprites network policy", policy, ["rules"]);
868
+ const allow = [];
869
+ const deny = [];
870
+ let everything;
871
+ for (const rule of policy.rules) {
872
+ if (!rule || typeof rule !== "object")
873
+ throw new TypeError("Each rule must be an object");
874
+ only("Sprites network rule", rule, ["domain", "action", "include"]);
875
+ if (rule.include !== undefined)
876
+ throw new CompatibilityError("Sprites", `the network preset ${String(rule.include)}; list its domains as allow rules`);
877
+ if (typeof rule.domain !== "string" || !rule.domain.trim())
878
+ throw new TypeError("Each rule needs a domain");
879
+ if (rule.action !== "allow" && rule.action !== "deny")
880
+ throw new TypeError("action must be allow or deny");
881
+ const domain = rule.domain.trim().toLowerCase();
882
+ if (domain === "*") {
883
+ if (everything && everything !== rule.action)
884
+ throw new TypeError("* is both allowed and denied");
885
+ everything = rule.action;
886
+ }
887
+ else
888
+ (rule.action === "allow" ? allow : deny).push(domain);
889
+ }
890
+ const covers = (wide, narrow) => wide === narrow || (wide.startsWith("*.") && narrow.endsWith(wide.slice(1)));
891
+ for (const allowed of allow)
892
+ for (const denied of deny)
893
+ if (covers(denied, allowed))
894
+ throw new CompatibilityError("Sprites", `allowing ${allowed} inside the denied ${denied}; Runtime's deny rules always win`);
895
+ if (everything === "deny" && !allow.length)
896
+ return { internet: false, allow: [], deny };
897
+ return { internet: true, allow: everything === "allow" ? [] : allow, deny };
898
+ }
899
+ /** A local listener that carries each connection to a port in the Sprite,
900
+ * over Runtime's tunnel. Node and Bun. */
901
+ export class ProxySession extends EventEmitter {
902
+ localPort;
903
+ remotePort;
904
+ remoteHost;
905
+ #sprite;
906
+ #forward;
907
+ #closed = false;
908
+ #done;
909
+ #finish;
910
+ constructor(client, spriteName, mapping) {
911
+ super();
912
+ only("Sprites port mapping", mapping, ["localPort", "remotePort", "remoteHost"]);
913
+ for (const [name, port] of [
914
+ ["localPort", mapping.localPort],
915
+ ["remotePort", mapping.remotePort],
916
+ ])
917
+ if (!Number.isInteger(port) ||
918
+ port < 0 ||
919
+ port > 65535 ||
920
+ (name === "remotePort" && port === 0))
921
+ throw new TypeError(`${name} must be a port number`);
922
+ const host = mapping.remoteHost ?? "localhost";
923
+ if (!["localhost", "127.0.0.1", "::1"].includes(host))
924
+ throw new CompatibilityError("Sprites", `proxying to ${host}; forward a port of the Sprite itself (localhost)`);
925
+ this.localPort = mapping.localPort;
926
+ this.remotePort = mapping.remotePort;
927
+ this.remoteHost = host;
928
+ const owner = client instanceof SpritesClient
929
+ ? client
930
+ : new SpritesClient(client.token, { baseURL: client.baseURL });
931
+ this.#sprite = owner.sprite(spriteName);
932
+ this.#done = new Promise((resolve) => (this.#finish = resolve));
933
+ }
934
+ async start() {
935
+ if (this.#closed)
936
+ throw new Error("Proxy session is closed");
937
+ try {
938
+ const s = await ready(await this.#sprite.native());
939
+ this.#forward = await s.forwardPort(this.remotePort, {
940
+ localPort: this.localPort,
941
+ host: "localhost",
942
+ });
943
+ }
944
+ catch (error) {
945
+ if (this.listenerCount("error"))
946
+ this.emit("error", error);
947
+ throw error;
948
+ }
949
+ if (this.#closed)
950
+ await this.#forward.close();
951
+ }
952
+ localAddr() {
953
+ return this.#forward && !this.#closed ? `localhost:${this.#forward.localPort}` : null;
954
+ }
955
+ close() {
956
+ if (this.#closed)
957
+ return;
958
+ this.#closed = true;
959
+ void this.#forward?.close().catch(() => undefined);
960
+ this.#finish();
961
+ this.emit("close");
962
+ }
963
+ wait() {
964
+ return this.#done;
965
+ }
966
+ }
967
+ export class ProxyManager {
968
+ #sessions = [];
969
+ addSession(session) {
970
+ this.#sessions.push(session);
971
+ }
972
+ closeAll() {
973
+ for (const session of this.#sessions)
974
+ session.close();
975
+ }
976
+ async waitAll() {
977
+ await Promise.all(this.#sessions.map((session) => session.wait()));
978
+ }
979
+ }
980
+ export async function proxyPort(client, spriteName, localPort, remotePort, remoteHost) {
981
+ const session = new ProxySession(client, spriteName, { localPort, remotePort, remoteHost });
982
+ await session.start();
983
+ return session;
984
+ }
985
+ /** All or none: a mapping that cannot start closes the ones before it. */
986
+ export async function proxyPorts(client, spriteName, mappings) {
987
+ const sessions = [];
988
+ try {
989
+ for (const mapping of mappings) {
990
+ const session = new ProxySession(client, spriteName, mapping);
991
+ sessions.push(session);
992
+ await session.start();
993
+ }
994
+ return sessions;
995
+ }
996
+ catch (error) {
997
+ for (const session of sessions)
998
+ session.close();
999
+ throw error;
1000
+ }
1001
+ }
1002
+ /** Polls the Sprite's listening TCP sockets once a second with `ss` (as
1003
+ * Sprites' own watcher keeps a connection open, this keeps it awake). */
1004
+ export class PortWatcher extends Feed {
1005
+ #timer;
1006
+ #stopped = false;
1007
+ #sprite;
1008
+ constructor(client, spriteName) {
1009
+ super();
1010
+ this.#sprite = client.sprite(spriteName);
1011
+ }
1012
+ async connect() {
1013
+ const s = await ready(await this.#sprite.native());
1014
+ let known = await listening(s);
1015
+ this.push({ type: "port_list", ports: [...known.values()] });
1016
+ const tick = async () => {
1017
+ if (this.#stopped)
1018
+ return;
1019
+ try {
1020
+ const now = await listening(s);
1021
+ for (const [key, port] of known)
1022
+ if (!now.has(key))
1023
+ this.push({ ...port, type: "port_closed" });
1024
+ for (const [key, port] of now)
1025
+ if (!known.has(key))
1026
+ this.push(port);
1027
+ known = now;
1028
+ }
1029
+ catch (error) {
1030
+ this.end(error);
1031
+ return;
1032
+ }
1033
+ if (!this.#stopped)
1034
+ this.#timer = setTimeout(() => void tick(), 1000);
1035
+ };
1036
+ this.#timer = setTimeout(() => void tick(), 1000);
1037
+ }
1038
+ stop() {
1039
+ this.#stopped = true;
1040
+ if (this.#timer)
1041
+ clearTimeout(this.#timer);
1042
+ }
1043
+ }
1044
+ async function listening(s) {
1045
+ const result = await s.exec(["ss", "-Hltnp"]);
1046
+ if (result.exitCode !== 0)
1047
+ throw new Error(`Cannot list ports in the Sprite: ${result.stderr.trim() || "ss failed"}`);
1048
+ const ports = new Map();
1049
+ for (const line of result.stdout.split("\n")) {
1050
+ const local = line.trim().split(/\s+/)[3];
1051
+ if (!local)
1052
+ continue;
1053
+ const at = local.lastIndexOf(":");
1054
+ const port = Number(local.slice(at + 1));
1055
+ if (!Number.isInteger(port))
1056
+ continue;
1057
+ const address = local
1058
+ .slice(0, at)
1059
+ .replace(/^\[|\]$/g, "")
1060
+ .replace(/%.*$/, "");
1061
+ const pid = Number(/pid=(\d+)/.exec(line)?.[1] ?? 0);
1062
+ ports.set(`${address}:${port}`, { type: "port_opened", port, address, pid });
1063
+ }
1064
+ return ports;
1065
+ }
1066
+ /** Runtime's file watch, one per path, as Sprites' watch events. */
1067
+ export class FilesystemWatcher extends Feed {
1068
+ sandbox;
1069
+ paths;
1070
+ recursive;
1071
+ #handles = [];
1072
+ #stopped = false;
1073
+ #files;
1074
+ constructor(sandbox, paths, workingDir, recursive) {
1075
+ super();
1076
+ this.sandbox = sandbox;
1077
+ this.paths = paths;
1078
+ this.recursive = recursive;
1079
+ this.#files = new GuestFiles(sandbox, workingDir);
1080
+ }
1081
+ async connect() {
1082
+ const s = await this.sandbox();
1083
+ const paths = this.paths.map((path) => this.#files.path(path));
1084
+ try {
1085
+ for (const path of paths) {
1086
+ const handle = await s.files.watch(path, (event) => {
1087
+ if (event.type === "access")
1088
+ return;
1089
+ this.push({
1090
+ type: "event",
1091
+ path: event.path,
1092
+ event: event.type,
1093
+ isDir: event.isDir,
1094
+ timestamp: new Date().toISOString(),
1095
+ });
1096
+ }, { recursive: this.recursive, timeoutMs: 0 });
1097
+ this.#handles.push(handle);
1098
+ if (this.#stopped)
1099
+ void handle.stop().catch(() => undefined);
1100
+ }
1101
+ }
1102
+ catch (error) {
1103
+ this.stop();
1104
+ throw error;
1105
+ }
1106
+ this.push({ type: "subscribed", paths });
1107
+ }
1108
+ stop() {
1109
+ this.#stopped = true;
1110
+ for (const handle of this.#handles.splice(0))
1111
+ void handle.stop().catch(() => undefined);
1112
+ }
1113
+ }
371
1114
  export class SpriteCommand extends EventEmitter {
372
1115
  stdout = new PassThrough();
373
1116
  stderr = new PassThrough();
@@ -378,20 +1121,7 @@ export class SpriteCommand extends EventEmitter {
378
1121
  controller = new AbortController();
379
1122
  constructor(sprite, command, args = [], options = {}) {
380
1123
  super();
381
- only("Sprites spawn", options, [
382
- "cwd",
383
- "env",
384
- "tty",
385
- "rows",
386
- "cols",
387
- "detachable",
388
- "sessionId",
389
- "controlMode",
390
- "encoding",
391
- "maxBuffer",
392
- "signal",
393
- "timeout",
394
- ]);
1124
+ only("Sprites spawn", options, EXEC_KEYS);
395
1125
  if (options.controlMode)
396
1126
  throw new CompatibilityError("Sprites", "control-mode wire protocol");
397
1127
  this.process = (async () => {
@@ -479,9 +1209,11 @@ export class SpriteCommand extends EventEmitter {
479
1209
  }
480
1210
  export class SpriteFilesystem {
481
1211
  get;
1212
+ cwd;
482
1213
  files;
483
1214
  constructor(get, cwd = "/") {
484
1215
  this.get = get;
1216
+ this.cwd = cwd;
485
1217
  this.files = new GuestFiles(get, cwd);
486
1218
  }
487
1219
  async readFile(path, encoding) {
@@ -527,6 +1259,8 @@ export class SpriteFilesystem {
527
1259
  async rm(path, options = {}) {
528
1260
  if (!options.force && !(await this.files.exists(path)))
529
1261
  throw new FilesystemError(`Path not found: ${path}`, "ENOENT", path, "rm");
1262
+ if (options.asRoot)
1263
+ return this.#root(path, "rm", ["rm", "-f", ...(options.recursive ? ["-r"] : []), "--", this.files.path(path)], true);
530
1264
  await fileOperation(path, "rm", () => this.files.remove(path, options.recursive));
531
1265
  }
532
1266
  async stat(path) {
@@ -542,15 +1276,85 @@ export class SpriteFilesystem {
542
1276
  isSymbolicLink: () => e.type === "symlink",
543
1277
  };
544
1278
  }
545
- rename(from, to) {
1279
+ async rename(from, to, options = {}) {
1280
+ if (options.asRoot)
1281
+ return this.#root(from, "rename", ["mv", "-f", "-T", "--", this.files.path(from), this.files.path(to)], true);
546
1282
  return fileOperation(from, "rename", () => this.files.rename(from, to, true));
547
1283
  }
548
- copyFile(from, to, options = {}) {
1284
+ async copyFile(from, to, options = {}) {
1285
+ if (options.asRoot || options.preserveAttrs)
1286
+ return this.#root(from, "copyFile", [
1287
+ "cp",
1288
+ ...(options.recursive ? ["-R"] : []),
1289
+ ...(options.preserveAttrs ? ["--preserve=mode,ownership,timestamps"] : []),
1290
+ "--",
1291
+ this.files.path(from),
1292
+ this.files.path(to),
1293
+ ], options.asRoot === true);
549
1294
  return fileOperation(from, "copyFile", () => this.files.copy(from, to, options.recursive, true));
550
1295
  }
551
- chmod(path, mode, options = {}) {
1296
+ async chmod(path, mode, options = {}) {
1297
+ if (options.asRoot) {
1298
+ if (!Number.isInteger(mode) || mode < 0)
1299
+ throw new TypeError("mode must be a file mode");
1300
+ return this.#root(path, "chmod", [
1301
+ "chmod",
1302
+ ...(options.recursive ? ["-R"] : []),
1303
+ (mode & 0o7777).toString(8),
1304
+ "--",
1305
+ this.files.path(path),
1306
+ ], true);
1307
+ }
552
1308
  return fileOperation(path, "chmod", () => this.files.chmod(path, mode, options.recursive));
553
1309
  }
1310
+ /** By number or name, as the Sprite's user unless `asRoot` (sudo). */
1311
+ async chown(path, options) {
1312
+ only("Sprites chown", options ?? {}, ["uid", "gid", "recursive", "asRoot"]);
1313
+ if (options?.uid === undefined && options?.gid === undefined)
1314
+ throw new TypeError("uid or gid is required");
1315
+ for (const id of [options.uid, options.gid])
1316
+ if (id !== undefined &&
1317
+ !(typeof id === "number" ? Number.isInteger(id) && id >= 0 : /^[A-Za-z0-9._-]+$/.test(id)))
1318
+ throw new TypeError("uid and gid must be ids or user and group names");
1319
+ const owner = `${options.uid ?? ""}${options.gid === undefined ? "" : `:${options.gid}`}`;
1320
+ return this.#root(path, "chown", ["chown", ...(options.recursive ? ["-R"] : []), owner, "--", this.files.path(path)], options.asRoot === true);
1321
+ }
1322
+ /** Changes to these paths until the watcher is closed. */
1323
+ async watch(paths, options = {}) {
1324
+ only("Sprites watch", options, ["recursive"]);
1325
+ const list = Array.isArray(paths) ? paths : [paths];
1326
+ if (!list.length || list.some((path) => typeof path !== "string" || !path))
1327
+ throw new TypeError("paths must name at least one path");
1328
+ const watcher = new FilesystemWatcher(this.get, list, this.cwd, options.recursive === true);
1329
+ await watcher.connect();
1330
+ return watcher;
1331
+ }
1332
+ /** A guest command for what the file API does not offer: as root through
1333
+ * sudo when asked, and its failure as the vendor's FilesystemError. */
1334
+ async #root(path, syscall, argv, asRoot) {
1335
+ const result = await (await this.get()).exec(asRoot ? ["sudo", "-n", "--", ...argv] : argv);
1336
+ if (result.exitCode === 0)
1337
+ return;
1338
+ const message = result.stderr.trim() || `${syscall} failed`;
1339
+ const code = /No such file/i.test(message)
1340
+ ? "ENOENT"
1341
+ : /File exists/i.test(message)
1342
+ ? "EEXIST"
1343
+ : /Not a directory/i.test(message)
1344
+ ? "ENOTDIR"
1345
+ : /Is a directory/i.test(message)
1346
+ ? "EISDIR"
1347
+ : /Directory not empty/i.test(message)
1348
+ ? "ENOTEMPTY"
1349
+ : /Operation not permitted|password is required|sudo:/i.test(message)
1350
+ ? "EPERM"
1351
+ : /Permission denied/i.test(message)
1352
+ ? "EACCES"
1353
+ : /invalid/i.test(message)
1354
+ ? "EINVAL"
1355
+ : "UNKNOWN";
1356
+ throw new FilesystemError(message, code, path, syscall);
1357
+ }
554
1358
  exists(path) {
555
1359
  return this.files.exists(path);
556
1360
  }
@@ -564,25 +1368,3 @@ export class SpriteFilesystem {
564
1368
  return this.writeFile(path, JSON.stringify(data, null, options.spaces));
565
1369
  }
566
1370
  }
567
- export class CheckpointStream {
568
- messages;
569
- index = 0;
570
- constructor(messages) {
571
- this.messages = messages;
572
- }
573
- async next() {
574
- return this.messages[this.index++] ?? null;
575
- }
576
- async processAll(handler) {
577
- for await (const message of this)
578
- await handler(message);
579
- }
580
- close() {
581
- this.index = this.messages.length;
582
- }
583
- async *[Symbol.asyncIterator]() {
584
- let message;
585
- while ((message = await this.next()))
586
- yield message;
587
- }
588
- }