withruntime 0.9.0 → 0.11.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 (124) hide show
  1. package/BLAXEL.md +4 -4
  2. package/DAYTONA.md +52 -40
  3. package/E2B.md +65 -62
  4. package/README.md +15 -7
  5. package/README.src.md +15 -7
  6. package/VERCEL.md +34 -22
  7. package/compatibility-lock.json +16 -16
  8. package/dist/blaxel/errors.js +1 -1
  9. package/dist/blaxel/lookup.d.ts +3 -2
  10. package/dist/blaxel/lookup.js +3 -2
  11. package/dist/blaxel/pagination.d.ts +2 -2
  12. package/dist/blaxel/pagination.js +45 -17
  13. package/dist/blaxel/preview.d.ts +2 -1
  14. package/dist/blaxel/preview.js +15 -13
  15. package/dist/blaxel/process.js +56 -10
  16. package/dist/blaxel/sandbox.d.ts +2 -0
  17. package/dist/blaxel/sandbox.js +2 -0
  18. package/dist/blaxel/snapshot.js +3 -5
  19. package/dist/cli.d.ts +2 -0
  20. package/dist/cli.js +262 -56
  21. package/dist/client.d.ts +41 -3
  22. package/dist/client.js +67 -11
  23. package/dist/cloudflare/identity.d.ts +7 -0
  24. package/dist/cloudflare/identity.js +13 -0
  25. package/dist/cloudflare/index.d.ts +9 -0
  26. package/dist/cloudflare/index.js +47 -9
  27. package/dist/cloudflare/worker.d.ts +4 -0
  28. package/dist/cloudflare/worker.js +166 -16
  29. package/dist/codesandbox/guest-service.js +57 -20
  30. package/dist/codesandbox/index.js +42 -8
  31. package/dist/compat/core.d.ts +9 -8
  32. package/dist/compat/core.js +60 -21
  33. package/dist/compat/files.js +22 -4
  34. package/dist/compat/lease.d.ts +36 -0
  35. package/dist/compat/lease.js +87 -0
  36. package/dist/credentials.d.ts +6 -1
  37. package/dist/credentials.js +107 -38
  38. package/dist/daytona/computer-use.d.ts +162 -0
  39. package/dist/daytona/computer-use.js +324 -0
  40. package/dist/daytona/context.d.ts +10 -0
  41. package/dist/daytona/context.js +24 -0
  42. package/dist/daytona/daytona.d.ts +20 -0
  43. package/dist/daytona/daytona.js +134 -20
  44. package/dist/daytona/filesystem.d.ts +17 -4
  45. package/dist/daytona/filesystem.js +129 -38
  46. package/dist/daytona/index.d.ts +3 -27
  47. package/dist/daytona/index.js +2 -7
  48. package/dist/daytona/process.d.ts +63 -6
  49. package/dist/daytona/process.js +224 -23
  50. package/dist/daytona/sandbox.d.ts +20 -6
  51. package/dist/daytona/sandbox.js +55 -32
  52. package/dist/e2b/client.d.ts +5 -0
  53. package/dist/e2b/client.js +6 -2
  54. package/dist/e2b/commands.d.ts +43 -12
  55. package/dist/e2b/commands.js +217 -64
  56. package/dist/e2b/errors.d.ts +7 -0
  57. package/dist/e2b/errors.js +20 -0
  58. package/dist/e2b/filesystem.d.ts +2 -1
  59. package/dist/e2b/filesystem.js +43 -6
  60. package/dist/e2b/index.d.ts +3 -2
  61. package/dist/e2b/index.js +2 -1
  62. package/dist/e2b/pty.d.ts +24 -0
  63. package/dist/e2b/pty.js +83 -0
  64. package/dist/e2b/sandbox.d.ts +17 -6
  65. package/dist/e2b/sandbox.js +239 -43
  66. package/dist/e2b/users.d.ts +30 -0
  67. package/dist/e2b/users.js +185 -0
  68. package/dist/file-source.d.ts +16 -0
  69. package/dist/file-source.js +162 -0
  70. package/dist/freestyle/index.d.ts +9 -1
  71. package/dist/freestyle/index.js +38 -5
  72. package/dist/index.d.ts +3 -1
  73. package/dist/login.js +16 -10
  74. package/dist/mcp.d.ts +2 -1
  75. package/dist/mcp.js +6 -1
  76. package/dist/modal/index.js +2 -1
  77. package/dist/network-cli.js +139 -43
  78. package/dist/openai-agents/index.js +9 -45
  79. package/dist/page.d.ts +1 -1
  80. package/dist/page.js +2 -2
  81. package/dist/products/browser.d.ts +60 -0
  82. package/dist/products/browser.js +37 -0
  83. package/dist/products/desktop.d.ts +2 -2
  84. package/dist/products/desktop.js +2 -2
  85. package/dist/products/images.d.ts +3 -1
  86. package/dist/products/images.js +14 -2
  87. package/dist/products/index.d.ts +14 -0
  88. package/dist/products/index.js +12 -0
  89. package/dist/products/network-products.d.ts +7 -0
  90. package/dist/products/network-products.js +3 -0
  91. package/dist/products/observability.d.ts +44 -0
  92. package/dist/products/observability.js +6 -0
  93. package/dist/products/previews.d.ts +8 -2
  94. package/dist/products/previews.js +11 -3
  95. package/dist/products/private-network.d.ts +22 -0
  96. package/dist/products/private-network.js +10 -0
  97. package/dist/products/volumes.d.ts +42 -0
  98. package/dist/products/volumes.js +36 -0
  99. package/dist/products/watch.js +61 -8
  100. package/dist/proxy.js +3 -1
  101. package/dist/runloop/index.d.ts +13 -27
  102. package/dist/runloop/index.js +149 -52
  103. package/dist/sandbox.d.ts +59 -8
  104. package/dist/sandbox.js +479 -68
  105. package/dist/snapshots.d.ts +9 -4
  106. package/dist/sprites/index.js +69 -33
  107. package/dist/tar.d.ts +3 -1
  108. package/dist/tar.js +140 -14
  109. package/dist/transport.d.ts +4 -7
  110. package/dist/transport.js +205 -122
  111. package/dist/types.d.ts +5 -2
  112. package/dist/vercel/command.d.ts +1 -1
  113. package/dist/vercel/command.js +3 -2
  114. package/dist/vercel/errors.d.ts +1 -1
  115. package/dist/vercel/errors.js +7 -1
  116. package/dist/vercel/index.d.ts +1 -7
  117. package/dist/vercel/index.js +1 -3
  118. package/dist/vercel/sandbox-user.d.ts +70 -0
  119. package/dist/vercel/sandbox-user.js +206 -0
  120. package/dist/vercel/sandbox.d.ts +55 -20
  121. package/dist/vercel/sandbox.js +353 -99
  122. package/dist/vercel/snapshot.d.ts +3 -2
  123. package/dist/vercel/snapshot.js +9 -5
  124. package/package.json +2 -1
@@ -1,8 +1,10 @@
1
+ import { extensionSeconds, LeaseKeeper, LEASE_MAX_SECONDS } from "../compat/lease.js";
1
2
  import { RuntimeError } from "../errors.js";
2
3
  import { clientFor, signalOf, } from "./client.js";
3
4
  import { Command, CommandFinished, exitCodeOf } from "./command.js";
4
5
  import { APIError, guard, NotSupportedError, translate } from "./errors.js";
5
6
  import { FileSystem } from "./filesystem.js";
7
+ import { rootOrThrow, SandboxUser, SandboxUserAlreadyExistsError, validateName, } from "./sandbox-user.js";
6
8
  import { Snapshot } from "./snapshot.js";
7
9
  /* Vercel Sandbox's `Sandbox` over Runtime's. Every call is one or a few calls
8
10
  of the withruntime SDK; nothing here speaks HTTP. What Runtime cannot do the
@@ -14,7 +16,8 @@ export const MEMORY_MIB_PER_VCPU = 2048;
14
16
  /** Vercel's default timeout, 5 minutes. */
15
17
  export const DEFAULT_TIMEOUT_MS = 300_000;
16
18
  const MIN_LEASE_SECONDS = 60;
17
- const MAX_LEASE_SECONDS = 3600;
19
+ /** Extend a long timeout's lease when less than this is left. */
20
+ const LEASE_MARGIN_MS = 600_000;
18
21
  /** Commands without a timeout run until the sandbox ends: Runtime's longest. */
19
22
  const LONGEST_MS = 86_400_000;
20
23
  /** Vercel's working directory; `/workspace` on Runtime. */
@@ -27,15 +30,20 @@ const LEGACY_RUNTIMES = new Set(["node22", "node24", "node26", "python3.13"]);
27
30
  /** Vercel regions in the United States, where Runtime runs. */
28
31
  const US_REGIONS = new Set(["iad1", "sfo1", "cle1", "pdx1"]);
29
32
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
30
- function leaseSeconds(timeoutMs) {
33
+ function checkTimeout(timeoutMs) {
31
34
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
32
35
  throw new RangeError(`timeout must be a positive number of milliseconds, not ${timeoutMs}.`);
33
- const seconds = Math.ceil(timeoutMs / 1000);
34
- if (seconds > MAX_LEASE_SECONDS)
35
- throw new NotSupportedError(`A sandbox timeout of ${timeoutMs} ms (over one hour)`, "Runtime leases last up to an hour (3_600_000 ms); call sandbox.extendTimeout(ms) before it ends to keep going, as often as needed.");
36
- return Math.max(seconds, MIN_LEASE_SECONDS);
36
+ return Math.max(timeoutMs, MIN_LEASE_SECONDS * 1000);
37
+ }
38
+ /** The first lease for a timeout: all of it, or the hour a lease may run. */
39
+ function leaseSeconds(timeoutMs) {
40
+ return Math.min(Math.ceil(checkTimeout(timeoutMs) / 1000), LEASE_MAX_SECONDS);
37
41
  }
38
42
  function refuseCreate(params) {
43
+ if (params.keepLastSnapshots !== undefined)
44
+ throw new NotSupportedError("Snapshot count and eviction policies (keepLastSnapshots)", "Omit it; manage saved snapshots explicitly with Snapshot.list and snapshot.delete.");
45
+ if (params.ports !== undefined)
46
+ validatePorts(params.ports);
39
47
  if (params.mounts && Object.keys(params.mounts).length)
40
48
  throw new NotSupportedError("Vercel Drives (mounts)", "Use a Runtime volume: withruntime: { create: { volumes: [{ volumeId, path }] } } (runtime.volumes creates one).");
41
49
  if (params.networkId !== undefined)
@@ -45,6 +53,11 @@ function refuseCreate(params) {
45
53
  if (params.failoverRegions?.length)
46
54
  throw new NotSupportedError("Failover regions (failoverRegions)", "Remove it: Runtime runs in one US region.");
47
55
  }
56
+ function validatePorts(ports) {
57
+ if (!Array.isArray(ports) ||
58
+ ports.some((port) => !Number.isSafeInteger(port) || port < 1 || port > 65_535))
59
+ throw new RangeError("ports must contain only integers from 1 to 65535.");
60
+ }
48
61
  /** Vercel's network policy as Runtime's rules. Header injection and
49
62
  * forwarding rules have no Runtime counterpart and are refused. */
50
63
  export function networkRules(policy) {
@@ -131,6 +144,12 @@ export class Sandbox {
131
144
  #persistent;
132
145
  #routes = new Map();
133
146
  #onResume;
147
+ #keeper;
148
+ /** Epoch ms the current session started, as the timeout counts from it. */
149
+ #sessionStart;
150
+ /** Vercel's timeout, when this object was given one or extended it. */
151
+ #timeoutMs;
152
+ #defaultUser;
134
153
  #linked;
135
154
  #deleted = false;
136
155
  /** Use Sandbox.create, Sandbox.get or Sandbox.getOrCreate. */
@@ -140,6 +159,17 @@ export class Sandbox {
140
159
  this.#env = { ...resolved.params.env };
141
160
  this.#persistent = resolved.params.persistent ?? resolved.runtime.info.onLeaseEnd !== "stop";
142
161
  this.#onResume = resolved.params.onResume;
162
+ const timeout = resolved.params.timeout;
163
+ this.#timeoutMs = timeout === undefined ? undefined : checkTimeout(timeout);
164
+ this.#sessionStart =
165
+ timeout === undefined
166
+ ? Date.parse(this.#rt.info.expiresAt) - this.#rt.info.timeoutSeconds * 1000
167
+ : Date.now();
168
+ this.#keeper = new LeaseKeeper({
169
+ sandbox: () => this.#rt,
170
+ until: () => this.#until(),
171
+ marginMs: () => Math.min(LEASE_MARGIN_MS, this.timeout / 2),
172
+ });
143
173
  this.fs = new FileSystem({
144
174
  name: this.name,
145
175
  files: async () => (await this.#live()).files,
@@ -204,9 +234,14 @@ export class Sandbox {
204
234
  get status() {
205
235
  return statusOf(this.#rt.state);
206
236
  }
207
- /** Milliseconds. */
237
+ /** Milliseconds from the start of the session: the timeout given at
238
+ * create, with every extension, or the lease of a sandbox found by name. */
208
239
  get timeout() {
209
- return this.#rt.info.timeoutSeconds * 1000;
240
+ return this.#timeoutMs ?? this.#rt.info.timeoutSeconds * 1000;
241
+ }
242
+ /** Epoch ms the session should end: its start plus the timeout. */
243
+ #until() {
244
+ return this.#sessionStart + this.timeout;
210
245
  }
211
246
  get expiresAt() {
212
247
  return new Date(this.#rt.info.expiresAt);
@@ -227,6 +262,8 @@ export class Sandbox {
227
262
  refuseCreate(params);
228
263
  const client = clientFor(params);
229
264
  const persistent = params.persistent ?? true;
265
+ if (params.snapshotExpiration !== undefined && persistent)
266
+ retentionDays(params.snapshotExpiration);
230
267
  const vcpus = params.resources?.vcpus ?? DEFAULT_VCPUS;
231
268
  const source = await guard(() => resolveImage(client, params));
232
269
  const input = {
@@ -240,11 +277,18 @@ export class Sandbox {
240
277
  ...params.withruntime?.create,
241
278
  };
242
279
  const runtime = await guard(() => client.sandboxes.create(input, signalOf(params)));
243
- const sandbox = new Sandbox({ runtime, client, params: { ...params, persistent } });
280
+ const sandbox = new Sandbox({
281
+ runtime,
282
+ client,
283
+ params: { ...params, persistent, timeout: params.timeout ?? DEFAULT_TIMEOUT_MS },
284
+ });
244
285
  try {
245
286
  await sandbox.#setUp(params);
287
+ // Starts renewing at once when the timeout runs past the first lease.
288
+ await sandbox.#keeper.check();
246
289
  }
247
290
  catch (error) {
291
+ sandbox.#keeper.end();
248
292
  await runtime.stop({ wait: false }).catch(() => undefined);
249
293
  throw translate(error, sandbox.name);
250
294
  }
@@ -300,15 +344,16 @@ export class Sandbox {
300
344
  /** A sandbox by name (or Runtime id), woken if it is paused unless
301
345
  * `resume: false`. */
302
346
  static async get(params) {
347
+ params.signal?.throwIfAborted();
303
348
  const client = clientFor(params);
304
- const runtime = await guard(() => find(client, params.name), params.name);
349
+ const runtime = await guard(() => find(client, params.name, params), params.name);
305
350
  const sandbox = new Sandbox({
306
351
  runtime,
307
352
  client,
308
353
  params: params.onResume ? { onResume: params.onResume } : {},
309
354
  });
310
355
  if (params.resume !== false)
311
- await sandbox.#live();
356
+ await sandbox.#live(params);
312
357
  return sandbox;
313
358
  }
314
359
  /** The named sandbox, or a new one with that name when there is none. */
@@ -329,9 +374,22 @@ export class Sandbox {
329
374
  /** A new sandbox copied from a running one, memory and all (a Runtime
330
375
  * fork). `sourceSandbox` is its name. */
331
376
  static async fork(params) {
332
- const unsupported = ["resources", "ports", "timeout", "networkPolicy", "image", "tags", "env"].find((field) => params[field] !== undefined);
377
+ const unsupported = [
378
+ "resources",
379
+ "ports",
380
+ "timeout",
381
+ "networkPolicy",
382
+ "image",
383
+ "tags",
384
+ "env",
385
+ "persistent",
386
+ "snapshotExpiration",
387
+ "keepLastSnapshots",
388
+ "onResume",
389
+ "runtime",
390
+ ].find((field) => params[field] !== undefined);
333
391
  if (unsupported)
334
- throw new NotSupportedError(`Overriding ${unsupported} on a fork`, "Fork without it; the copy keeps the source's machine, lease and rules.");
392
+ throw new NotSupportedError(`Overriding ${unsupported} on a fork`, "Omit the override to use Runtime's existing fork behavior.");
335
393
  refuseCreate(params);
336
394
  const source = await Sandbox.get({ ...params, name: params.sourceSandbox });
337
395
  const copy = await guard(() => source.#rt.fork({ ...(params.name ? { name: params.name } : {}), ...signalOf(params) }), params.sourceSandbox);
@@ -342,22 +400,27 @@ export class Sandbox {
342
400
  });
343
401
  }
344
402
  /** Running and stopped (paused) sandboxes, oldest first, with Vercel's
345
- * paginator: `for await (const s of await Sandbox.list())`. */
403
+ * paginator: `for await (const s of await Sandbox.list())`. A name prefix,
404
+ * creation-time bounds (`since`, `until`, epoch ms), `sortBy` and
405
+ * `sortOrder`, and a `cursor` from an earlier page are applied here, over
406
+ * every sandbox the key sees. */
346
407
  static async list(params = {}) {
347
- for (const field of ["since", "until", "cursor", "sortBy", "sortOrder", "namePrefix"])
348
- if (params[field] !== undefined)
349
- throw new NotSupportedError(`Listing sandboxes by ${field}`, "List them all (oldest first) and filter the result yourself.");
408
+ params.signal?.throwIfAborted();
350
409
  const client = clientFor(params);
410
+ const local = ["namePrefix", "sortBy", "sortOrder", "cursor", "since", "until"].some((field) => params[field] !== undefined);
411
+ if (local)
412
+ return listHere(client, params);
351
413
  const page = await guard(() => client.sandboxes.list({
352
414
  ...(params.tags ? { labels: params.tags } : {}),
353
415
  ...(params.limit ? { limit: Math.min(params.limit, 100) } : {}),
354
- }));
355
- return paginate(page);
416
+ }, signalOf(params)));
417
+ return paginate(page, params.signal);
356
418
  }
357
419
  // ---- commands ---------------------------------------------------------
358
420
  /** Wakes the sandbox if it was paused (a stopped persistent sandbox, in
359
421
  * Vercel's words), as Vercel resumes one on the next call. */
360
- async #live() {
422
+ async #live(opts = {}) {
423
+ opts.signal?.throwIfAborted();
361
424
  if (this.#deleted)
362
425
  throw new APIError(new Response(null, { status: 410 }), {
363
426
  message: `Sandbox ${this.name} was deleted.`,
@@ -365,35 +428,48 @@ export class Sandbox {
365
428
  });
366
429
  const state = this.#rt.state;
367
430
  if (state === "paused" || state === "pausing")
368
- await this.#wake();
431
+ await this.#wake(opts);
432
+ await guard(() => this.#keeper.check(), this.name);
369
433
  return this.#rt;
370
434
  }
371
- async #wake() {
372
- await guard(() => this.#rt.wake({}), this.name);
435
+ /** A woken sandbox starts a new session, whose timeout counts from now,
436
+ * as Vercel's resumed sessions do. */
437
+ async #wake(opts = {}) {
438
+ await guard(() => this.#rt.wake(signalOf(opts)), this.name);
439
+ if (this.#timeoutMs !== undefined)
440
+ this.#sessionStart = Date.now();
373
441
  await this.#onResume?.(this);
374
442
  }
375
443
  /** Runs `work` on the live sandbox; if the lease paused it meanwhile, wakes
376
444
  * it and runs `work` once more. */
377
- async #withResume(work) {
378
- const runtime = await this.#live();
445
+ async #withResume(work, opts = {}) {
446
+ opts.signal?.throwIfAborted();
447
+ const runtime = await this.#live(opts);
379
448
  try {
380
449
  return await work(runtime);
381
450
  }
382
451
  catch (error) {
383
452
  if (!(error instanceof RuntimeError && error.code === "sandbox_paused"))
384
453
  throw translate(error, this.name);
385
- await guard(() => runtime.refresh(), this.name);
386
- await this.#wake();
454
+ await guard(() => runtime.refresh(signalOf(opts)), this.name);
455
+ await this.#wake(opts);
387
456
  return guard(() => work(this.#rt), this.name);
388
457
  }
389
458
  }
390
- async #linkHome(text) {
459
+ async #linkHome(text, opts = {}) {
460
+ opts.signal?.throwIfAborted();
391
461
  if (this.#linked || !text.includes(HOME))
392
462
  return this.#linked;
393
463
  this.#linked = (async () => {
394
- await this.#rt.exec(HOME_LINK).catch(() => undefined);
464
+ await this.#rt.exec(HOME_LINK, signalOf(opts)).catch(() => opts.signal?.throwIfAborted());
395
465
  })();
396
- return this.#linked;
466
+ try {
467
+ await this.#linked;
468
+ }
469
+ catch (error) {
470
+ this.#linked = undefined;
471
+ throw error;
472
+ }
397
473
  }
398
474
  async runCommand(commandOrParams, args, opts = {}) {
399
475
  const params = typeof commandOrParams === "string"
@@ -406,18 +482,19 @@ export class Sandbox {
406
482
  ];
407
483
  const cwd = params.cwd === undefined ? RUNTIME_HOME : toRuntimePath(params.cwd);
408
484
  const env = { ...this.#env, ...params.env };
409
- await this.#live();
410
- await this.#linkHome(`${argv.join(" ")}\n${params.cwd ?? ""}\n${Object.values(env).join("\n")}`);
485
+ await this.#live(params);
486
+ await this.#linkHome(`${argv.join(" ")}\n${params.cwd ?? ""}\n${Object.values(env).join("\n")}`, params);
411
487
  const timeoutMs = params.timeoutMs ?? LONGEST_MS;
412
488
  const common = {
413
489
  cwd,
414
490
  ...(Object.keys(env).length ? { env } : {}),
415
491
  timeoutMs,
492
+ ...signalOf(params),
416
493
  };
417
494
  const startedAt = Date.now();
418
495
  const vercelCwd = params.cwd === undefined ? HOME : toVercelPath(params.cwd);
419
496
  if (params.detached) {
420
- const process = await this.#withResume((runtime) => runtime.spawn(argv, common));
497
+ const process = await this.#withResume((runtime) => runtime.spawn(argv, common), params);
421
498
  const command = new Command({
422
499
  id: process.id,
423
500
  cwd: vercelCwd,
@@ -438,8 +515,7 @@ export class Sandbox {
438
515
  ...common,
439
516
  onStdout: record("stdout", params.stdout),
440
517
  onStderr: record("stderr", params.stderr),
441
- ...signalOf(params),
442
- }));
518
+ }), params);
443
519
  return new CommandFinished({
444
520
  id: result.processId ?? `cmd_${startedAt}`,
445
521
  cwd: vercelCwd,
@@ -449,8 +525,8 @@ export class Sandbox {
449
525
  }, exitCodeOf(result.exitCode, result.timedOut), result.durationMs ?? Date.now() - startedAt);
450
526
  }
451
527
  /** A command started earlier, by its cmdId. */
452
- async getCommand(cmdId, _opts = {}) {
453
- const process = await this.#withResume((runtime) => runtime.processes.get(cmdId));
528
+ async getCommand(cmdId, opts = {}) {
529
+ const process = await this.#withResume((runtime) => runtime.processes.get(cmdId, signalOf(opts)), opts);
454
530
  return new Command({
455
531
  id: process.id,
456
532
  cwd: process.info.cwd,
@@ -460,9 +536,9 @@ export class Sandbox {
460
536
  });
461
537
  }
462
538
  // ---- files ------------------------------------------------------------
463
- async mkDir(path, _opts = {}) {
539
+ async mkDir(path, opts = {}) {
464
540
  const target = toRuntimePath(path);
465
- await this.#withResume((runtime) => runtime.files.mkdir(target, { parents: true }));
541
+ await this.#withResume((runtime) => runtime.files.mkdir(target, { parents: true, ...signalOf(opts) }), opts);
466
542
  }
467
543
  /** The file's bytes as a Node stream, or null when there is no file. */
468
544
  async readFile(file, opts = {}) {
@@ -476,7 +552,7 @@ export class Sandbox {
476
552
  async readFileToBuffer(file, opts = {}) {
477
553
  const target = toRuntimePath(file.path, file.cwd);
478
554
  try {
479
- return Buffer.from(await this.#withResume((runtime) => runtime.files.read(target, signalOf(opts))));
555
+ return Buffer.from(await this.#withResume((runtime) => runtime.files.read(target, signalOf(opts)), opts));
480
556
  }
481
557
  catch (error) {
482
558
  if (error instanceof APIError && error.response.status === 404)
@@ -489,39 +565,42 @@ export class Sandbox {
489
565
  const buffer = await this.readFileToBuffer(src, opts);
490
566
  if (buffer === null)
491
567
  return null;
568
+ opts.signal?.throwIfAborted();
492
569
  const { mkdir, writeFile } = await import("node:fs/promises");
493
570
  const paths = await import("node:path");
494
571
  const target = paths.resolve(dst.cwd ?? process.cwd(), dst.path);
572
+ opts.signal?.throwIfAborted();
495
573
  if (opts.mkdirRecursive)
496
574
  await mkdir(paths.dirname(target), { recursive: true });
497
- await writeFile(target, buffer);
575
+ await writeFile(target, buffer, signalOf(opts));
498
576
  return target;
499
577
  }
500
578
  /** Writes files, making their directories. Relative paths land in the
501
579
  * working directory; `mode` sets permissions. */
502
- async writeFiles(files, _opts = {}) {
580
+ async writeFiles(files, opts = {}) {
503
581
  const modes = [];
504
582
  await this.#withResume(async (runtime) => {
505
583
  for (const file of files) {
506
584
  const target = toRuntimePath(file.path);
507
- await runtime.files.write(target, file.content);
585
+ await runtime.files.write(target, file.content, signalOf(opts));
508
586
  if (file.mode !== undefined)
509
587
  modes.push(file.mode.toString(8), target);
510
588
  }
511
589
  if (!modes.length)
512
590
  return;
513
591
  const script = 'while [ "$#" -gt 0 ]; do chmod "$1" "$2" || exit 1; shift 2; done';
514
- const result = await runtime.exec(["sh", "-c", script, "sh", ...modes]);
592
+ const result = await runtime.exec(["sh", "-c", script, "sh", ...modes], signalOf(opts));
515
593
  if (result.exitCode !== 0)
516
594
  throw new APIError(new Response(null, { status: 400 }), {
517
595
  message: `Setting file modes failed: ${result.stderr.trim()}`,
518
596
  sandboxName: this.name,
519
597
  });
520
- });
598
+ }, opts);
521
599
  }
522
600
  // ---- ports ------------------------------------------------------------
523
- async #share(port) {
524
- const preview = await guard(() => this.#rt.previews.create(port, { visibility: "public" }), this.name);
601
+ async #share(port, opts = {}) {
602
+ opts.signal?.throwIfAborted();
603
+ const preview = await guard(() => this.#rt.previews.create(port, { visibility: "public" }, signalOf(opts)), this.name);
525
604
  this.#routes.set(port, preview.url.replace(/\/$/, ""));
526
605
  }
527
606
  /** The public address of a port listed in `ports`, such as
@@ -545,6 +624,8 @@ export class Sandbox {
545
624
  else
546
625
  await guard(() => this.#rt.stop(signalOf(opts)), this.name);
547
626
  }
627
+ if (!this.#persistent)
628
+ this.#keeper.end();
548
629
  return { name: this.name, status: this.status };
549
630
  }
550
631
  /** Ends the sandbox for good. */
@@ -555,87 +636,191 @@ export class Sandbox {
555
636
  if (this.#rt.state !== "stopped")
556
637
  await guard(() => this.#rt.stop({ wait: false, ...signalOf(opts) }), this.name);
557
638
  this.#deleted = true;
639
+ this.#keeper.end();
558
640
  }
559
641
  async [Symbol.asyncDispose]() {
560
642
  if (this.#deleted)
561
643
  return;
562
644
  await this.stop().catch(() => undefined);
563
645
  }
564
- /** Moves the end `duration` milliseconds later (at most an hour ahead of
565
- * now on Runtime). */
646
+ /** Moves the end `duration` milliseconds later. The lease moves at once
647
+ * as far as it may (an hour ahead of now); past that it is extended while
648
+ * this object lives. */
566
649
  async extendTimeout(duration, opts = {}) {
567
650
  if (!Number.isFinite(duration) || duration <= 0)
568
651
  throw new RangeError(`duration must be a positive number of milliseconds, not ${duration}.`);
569
- await this.#withResume((runtime) => runtime.extend(Math.ceil(duration / 1000), signalOf(opts)));
652
+ await this.#withResume(async (runtime) => {
653
+ this.#timeoutMs = this.timeout + duration;
654
+ await this.#leaseToTimeout(runtime, opts);
655
+ }, opts);
656
+ }
657
+ /** Extends the lease now toward the timeout, an hour ahead at most. */
658
+ async #leaseToTimeout(runtime, opts) {
659
+ const seconds = extensionSeconds(Date.parse(runtime.info.expiresAt), this.#until(), Date.now(), Infinity);
660
+ if (seconds >= 1)
661
+ await runtime.extend(seconds, signalOf(opts));
570
662
  }
571
663
  /** Replaces the network rules; returns the policy given. */
572
- async updateNetworkPolicy(networkPolicy, _opts = {}) {
664
+ async updateNetworkPolicy(networkPolicy, opts = {}) {
665
+ opts.signal?.throwIfAborted();
573
666
  const rules = networkRules(networkPolicy);
574
- await this.#withResume((runtime) => runtime.network.set(rules));
667
+ await this.#withResume((runtime) => runtime.network.set(rules, signalOf(opts)), opts);
575
668
  return networkPolicy;
576
669
  }
577
670
  /** Changes what Runtime can change on a sandbox: `timeout`, `ports`,
578
671
  * `networkPolicy` and `snapshotExpiration`. Anything else is refused before
579
672
  * anything changes. */
580
- async update(params, _opts = {}) {
673
+ async update(params, opts = {}) {
674
+ opts.signal?.throwIfAborted();
581
675
  const allowed = new Set(["timeout", "ports", "networkPolicy", "snapshotExpiration"]);
582
676
  const other = Object.keys(params).find((key) => params[key] !== undefined && !allowed.has(key));
583
677
  if (other)
584
678
  throw new NotSupportedError(`Changing ${other} on an existing sandbox`, "Create a new sandbox with it (Runtime cannot change a sandbox's machine, persistence, tags or region after creation).");
679
+ const rules = params.networkPolicy === undefined ? undefined : networkRules(params.networkPolicy);
680
+ const retention = params.snapshotExpiration === undefined
681
+ ? undefined
682
+ : retentionDays(params.snapshotExpiration);
683
+ if (params.ports !== undefined)
684
+ validatePorts(params.ports);
685
+ const wanted = params.ports === undefined ? undefined : new Set(params.ports);
585
686
  if (params.timeout !== undefined) {
586
- const current = this.timeout;
587
- if (params.timeout < current)
588
- throw new NotSupportedError("Shortening a sandbox's timeout", "Runtime leases only move later. Call stop() when the work is done.");
589
- if (params.timeout > current)
590
- await this.extendTimeout(params.timeout - current);
687
+ const timeout = checkTimeout(params.timeout);
688
+ // A shorter timeout is kept when the lease has not reached it yet: the
689
+ // lease then stops being extended there. Runtime cannot end a lease
690
+ // earlier than it was given.
691
+ if (this.#sessionStart + timeout < Date.parse(this.#rt.info.expiresAt) - 1000)
692
+ throw new NotSupportedError("A timeout that ends earlier than the current lease", `The lease runs to ${this.#rt.info.expiresAt}; call stop() when the work is done, or pass a timeout that ends after it.`);
693
+ const longer = timeout > this.timeout;
694
+ this.#timeoutMs = timeout;
695
+ if (longer)
696
+ await this.#withResume((runtime) => this.#leaseToTimeout(runtime, opts), opts);
591
697
  }
592
- if (params.networkPolicy !== undefined)
593
- await this.updateNetworkPolicy(params.networkPolicy);
594
- if (params.snapshotExpiration !== undefined)
595
- await guard(() => this.#rt.setRetention(retentionDays(params.snapshotExpiration)), this.name);
596
- if (params.ports !== undefined) {
597
- const wanted = new Set(params.ports);
698
+ if (rules !== undefined)
699
+ await this.#withResume((runtime) => runtime.network.set(rules, signalOf(opts)), opts);
700
+ if (retention !== undefined)
701
+ await guard(() => this.#rt.setRetention(retention, signalOf(opts)), this.name);
702
+ if (wanted !== undefined) {
598
703
  for (const port of [...this.#routes.keys()])
599
704
  if (!wanted.has(port)) {
600
- await guard(() => this.#rt.previews.delete(port), this.name);
705
+ await guard(() => this.#rt.previews.delete(port, signalOf(opts)), this.name);
601
706
  this.#routes.delete(port);
602
707
  }
603
708
  for (const port of wanted)
604
709
  if (!this.#routes.has(port))
605
- await this.#share(port);
710
+ await this.#share(port, opts);
606
711
  }
607
712
  }
608
- /** Keeps the sandbox's whole machine as a Runtime snapshot (files, memory
609
- * and running processes), then stops the sandbox, as Vercel does. Start
713
+ /** Keeps the sandbox's filesystem as a disk-only snapshot, then stops
714
+ * the source. Restoring it starts fresh processes, as Vercel does. Start
610
715
  * from it with `Sandbox.create({ source: { type: "snapshot", snapshotId } })`. */
611
716
  async snapshot(opts = {}) {
717
+ opts.signal?.throwIfAborted();
718
+ const retention = opts.expiration === undefined ? undefined : retentionDays(opts.expiration);
612
719
  const taken = await this.#withResume((runtime) => runtime.snapshot({
613
- ...(opts.expiration !== undefined ? { retentionDays: retentionDays(opts.expiration) } : {}),
614
- }));
615
- await guard(() => this.#rt.stop({ wait: false }), this.name);
720
+ mode: "disk",
721
+ ...(retention !== undefined ? { retentionDays: retention } : {}),
722
+ ...signalOf(opts),
723
+ }), opts);
724
+ try {
725
+ await guard(() => this.#rt.stop(signalOf(opts)), this.name);
726
+ if (this.#rt.state !== "stopped")
727
+ await guard(() => this.#rt.waitFor("stopped", signalOf(opts)), this.name);
728
+ }
729
+ catch (error) {
730
+ const recovery = { snapshotId: taken.id, sourceSandboxId: this.#rt.id };
731
+ if (error instanceof APIError) {
732
+ const json = error.json;
733
+ error.json = {
734
+ ...json,
735
+ error: {
736
+ ...json?.error,
737
+ details: { ...(json?.error?.details ?? {}), ...recovery },
738
+ },
739
+ };
740
+ }
741
+ else if (error instanceof Error && Object.isExtensible(error)) {
742
+ Object.assign(error, recovery);
743
+ }
744
+ throw error;
745
+ }
746
+ if (this.#rt.state !== "stopped") {
747
+ const code = "snapshot_source_stop_timeout";
748
+ const message = `Snapshot ${taken.id} was saved, but sandbox ${this.name} did not finish stopping.`;
749
+ const error = new APIError(new Response(null, { status: 409 }), {
750
+ message,
751
+ sandboxName: this.name,
752
+ json: {
753
+ error: { code, message, details: { snapshotId: taken.id, sourceSandboxId: this.#rt.id } },
754
+ },
755
+ });
756
+ error.code = code;
757
+ throw error;
758
+ }
616
759
  return new Snapshot(taken, this.#client);
617
760
  }
618
761
  // ---- what Runtime does differently -----------------------------------
762
+ /** Refused: Vercel hands back a token scoped to this one shell, and
763
+ * Runtime's terminal opens with the account key, which must not leave the
764
+ * server. */
619
765
  openInteractive() {
620
766
  return Promise.reject(new NotSupportedError("Vercel's interactive shell (openInteractive)", "Use `await sandbox.withruntime.terminal({ cols, rows, onData })`, or `npx withruntime sandbox shell <id>`."));
621
767
  }
622
- getDefaultUser() {
623
- return Promise.resolve({ username: "runtime", group: "runtime" });
624
- }
625
- createUser() {
626
- return usersRefused();
627
- }
628
- asUser() {
629
- throw usersError();
630
- }
631
- createGroup() {
632
- return usersRefused();
768
+ /** The user commands run as, `runtime`, and its group. */
769
+ getDefaultUser(_opts = {}) {
770
+ this.#defaultUser ??= Promise.resolve({ username: "runtime", group: "runtime" });
771
+ return this.#defaultUser;
772
+ }
773
+ /** Makes a Linux user with its own home, as Vercel does: `useradd -m`,
774
+ * then the home given to the user and the sandbox's group, mode 770. */
775
+ async createUser(username, opts = {}) {
776
+ validateName(username, "username");
777
+ const { group } = await this.getDefaultUser(opts);
778
+ const signal = opts.signal;
779
+ const useradd = await this.runCommand({
780
+ cmd: "useradd",
781
+ args: ["-m", "-s", "/bin/bash", username],
782
+ sudo: true,
783
+ ...(signal ? { signal } : {}),
784
+ });
785
+ if (useradd.exitCode !== 0) {
786
+ if (useradd.exitCode === 9)
787
+ throw new SandboxUserAlreadyExistsError(username);
788
+ throw new Error(`Failed to create user "${username}": ${await useradd.stderr()}`);
789
+ }
790
+ const home = `/home/${username}`;
791
+ await rootOrThrow(this, "chown", [`${username}:${group}`, home], signal, () => `Failed to set ownership on ${home}`);
792
+ await rootOrThrow(this, "chmod", ["770", home], signal, () => `Failed to set permissions on ${home}`);
793
+ return new SandboxUser({ sandbox: this, username });
794
+ }
795
+ /** A handle for an existing user, such as `root`. */
796
+ asUser(username) {
797
+ validateName(username, "username");
798
+ return new SandboxUser({ sandbox: this, username });
799
+ }
800
+ /** Makes a group and its shared directory, `/shared/<group>`, setgid 2770. */
801
+ async createGroup(groupname, opts = {}) {
802
+ validateName(groupname, "group name");
803
+ const { username } = await this.getDefaultUser(opts);
804
+ const sharedDir = `/shared/${groupname}`;
805
+ const steps = [
806
+ ["groupadd", [groupname], `Failed to create group "${groupname}"`],
807
+ ["mkdir", ["-p", sharedDir], `Failed to create shared directory ${sharedDir}`],
808
+ ["chown", [`${username}:${groupname}`, sharedDir], `Failed to set ownership on ${sharedDir}`],
809
+ ["chmod", ["2770", sharedDir], `Failed to set permissions on ${sharedDir}`],
810
+ ];
811
+ for (const [cmd, args, what] of steps)
812
+ await rootOrThrow(this, cmd, args, opts.signal, () => what);
813
+ return { groupname, sharedDir };
633
814
  }
634
- addUserToGroup() {
635
- return usersRefused();
815
+ async addUserToGroup(username, groupname, opts = {}) {
816
+ validateName(username, "username");
817
+ validateName(groupname, "group name");
818
+ await rootOrThrow(this, "usermod", ["-aG", groupname, username], opts.signal, () => `Failed to add "${username}" to group "${groupname}"`);
636
819
  }
637
- removeUserFromGroup() {
638
- return usersRefused();
820
+ async removeUserFromGroup(username, groupname, opts = {}) {
821
+ validateName(username, "username");
822
+ validateName(groupname, "group name");
823
+ await rootOrThrow(this, "gpasswd", ["-d", username, groupname], opts.signal, () => `Failed to remove "${username}" from group "${groupname}"`);
639
824
  }
640
825
  currentSession() {
641
826
  throw new NotSupportedError("Vercel sessions (currentSession)", "A Runtime sandbox is its own session: call the methods on the sandbox.");
@@ -647,15 +832,11 @@ export class Sandbox {
647
832
  return Promise.reject(new NotSupportedError("Listing a sandbox's snapshots from the sandbox", "Use Snapshot.list({ name }) or runtime.snapshots.list({ sandboxId })."));
648
833
  }
649
834
  }
650
- function usersError() {
651
- return new NotSupportedError("Extra Linux users and groups (createUser, asUser, groups)", "Commands run as the sandbox owner with passwordless sudo; run `sudo useradd ...` and `sudo -u <user> ...` with runCommand.");
652
- }
653
- function usersRefused() {
654
- return Promise.reject(usersError());
655
- }
656
835
  /** Milliseconds as whole days for Runtime's retention: 1 to 365, and 0 (no
657
836
  * expiration) as 365. */
658
837
  export function retentionDays(ms) {
838
+ if (!Number.isFinite(ms) || ms < 0)
839
+ throw new RangeError(`Snapshot expiration must be a nonnegative finite number of milliseconds, not ${ms}.`);
659
840
  if (ms === 0)
660
841
  return 365;
661
842
  return Math.min(365, Math.max(1, Math.ceil(ms / 86_400_000)));
@@ -671,11 +852,13 @@ async function pipe(command, params) {
671
852
  }
672
853
  /** The newest live sandbox with this name, or the sandbox with this Runtime
673
854
  * id. A 404 when there is none. */
674
- async function find(client, name) {
855
+ async function find(client, name, opts = {}) {
856
+ opts.signal?.throwIfAborted();
675
857
  if (UUID.test(name))
676
- return client.sandboxes.get(name);
677
- const page = await client.sandboxes.list({ name });
858
+ return client.sandboxes.get(name, signalOf(opts));
859
+ const page = await client.sandboxes.list({ name }, signalOf(opts));
678
860
  const all = await page.toArray(1000);
861
+ opts.signal?.throwIfAborted();
679
862
  const found = all.filter((one) => one.state !== "stopped").at(-1);
680
863
  if (!found)
681
864
  throw new RuntimeError({
@@ -703,24 +886,95 @@ function itemOf(runtime) {
703
886
  tags: { ...info.labels },
704
887
  };
705
888
  }
889
+ const CURSOR = /^rt\.([0-9]+)$/;
890
+ /** Sandbox.list with a name prefix, time bounds, sorting or a cursor:
891
+ * applied here over every sandbox the key sees, since Runtime's list filters
892
+ * only by labels and name. The cursor is this adapter's own, an offset into
893
+ * the same query. */
894
+ async function listHere(client, params) {
895
+ let offset = 0;
896
+ if (params.cursor !== undefined) {
897
+ const match = CURSOR.exec(params.cursor);
898
+ if (!match)
899
+ throw new RangeError(`The cursor "${params.cursor}" was not made by Sandbox.list here; pass pagination.next from an earlier page of the same query.`);
900
+ offset = Number(match[1]);
901
+ }
902
+ if (params.sortBy !== undefined &&
903
+ !["createdAt", "name", "statusUpdatedAt"].includes(params.sortBy))
904
+ throw new RangeError("sortBy must be createdAt, name or statusUpdatedAt.");
905
+ if (params.sortOrder !== undefined && !["asc", "desc"].includes(params.sortOrder))
906
+ throw new RangeError("sortOrder must be asc or desc.");
907
+ const page = await guard(() => client.sandboxes.list(params.tags ? { labels: params.tags } : {}, signalOf(params)));
908
+ const all = (await guard(() => page.toArray(10_000))).map(itemOf);
909
+ params.signal?.throwIfAborted();
910
+ const time = (value) => value === undefined ? undefined : value instanceof Date ? value.getTime() : value;
911
+ const since = time(params.since);
912
+ const until = time(params.until);
913
+ const items = all.filter((one) => (params.namePrefix === undefined || one.name.startsWith(params.namePrefix)) &&
914
+ (since === undefined || one.createdAt >= since) &&
915
+ (until === undefined || one.createdAt <= until));
916
+ const by = params.sortBy ?? "createdAt";
917
+ const key = (one) => by === "name" ? one.name : by === "statusUpdatedAt" ? one.updatedAt : one.createdAt;
918
+ const direction = params.sortOrder === "desc" ? -1 : 1;
919
+ items.sort((a, b) => {
920
+ const [x, y] = [key(a), key(b)];
921
+ return (x < y ? -1 : x > y ? 1 : 0) * direction;
922
+ });
923
+ const size = Math.max(1, Math.min(params.limit ?? 50, 100));
924
+ const pageAt = (from) => {
925
+ const sandboxes = items.slice(from, from + size);
926
+ return {
927
+ sandboxes,
928
+ pagination: {
929
+ count: sandboxes.length,
930
+ next: from + size < items.length ? `rt.${from + size}` : null,
931
+ },
932
+ };
933
+ };
934
+ return Object.assign(pageAt(offset), {
935
+ async *pages() {
936
+ for (let from = offset; from === offset || from < items.length; from += size) {
937
+ params.signal?.throwIfAborted();
938
+ yield pageAt(from);
939
+ }
940
+ },
941
+ async *[Symbol.asyncIterator]() {
942
+ for (const item of items.slice(offset)) {
943
+ params.signal?.throwIfAborted();
944
+ yield item;
945
+ }
946
+ },
947
+ async toArray() {
948
+ params.signal?.throwIfAborted();
949
+ return items.slice(offset);
950
+ },
951
+ });
952
+ }
706
953
  /** Vercel's paginator over a Runtime page: the first page's fields, async
707
954
  * iteration over every item, `pages()` and `toArray()`. */
708
- function paginate(first) {
955
+ function paginate(first, signal) {
709
956
  const toPage = (page) => ({
710
957
  sandboxes: page.data.map(itemOf),
711
958
  pagination: { count: page.data.length, next: page.nextCursor },
712
959
  });
713
960
  return Object.assign(toPage(first), {
714
961
  async *pages() {
715
- for await (const page of first.pages())
962
+ for await (const page of first.pages()) {
963
+ signal?.throwIfAborted();
716
964
  yield toPage(page);
965
+ }
717
966
  },
718
967
  async *[Symbol.asyncIterator]() {
719
- for await (const item of first)
968
+ for await (const item of first) {
969
+ signal?.throwIfAborted();
720
970
  yield itemOf(item);
971
+ }
721
972
  },
722
973
  async toArray() {
723
- return (await first.toArray()).map(itemOf);
974
+ signal?.throwIfAborted();
975
+ const items = await first.toArray();
976
+ signal?.throwIfAborted();
977
+ return items.map(itemOf);
724
978
  },
725
979
  });
726
980
  }