withruntime 0.8.3 → 0.9.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 (92) hide show
  1. package/COMPATIBILITY.md +145 -0
  2. package/E2B.md +23 -13
  3. package/README.md +8 -5
  4. package/README.src.md +8 -5
  5. package/compatibility-lock.json +281 -0
  6. package/dist/ai-harness/index.js +2 -2
  7. package/dist/api-defaults.d.ts +17 -0
  8. package/dist/api-defaults.js +22 -0
  9. package/dist/blaxel/preview.d.ts +1 -1
  10. package/dist/blaxel/preview.js +1 -1
  11. package/dist/blaxel/process.d.ts +1 -1
  12. package/dist/blaxel/process.js +11 -2
  13. package/dist/cli-words.d.ts +27 -0
  14. package/dist/cli-words.js +135 -0
  15. package/dist/cli.js +162 -31
  16. package/dist/client.d.ts +4 -1
  17. package/dist/client.js +8 -2
  18. package/dist/cloudflare/index.d.ts +294 -0
  19. package/dist/cloudflare/index.js +554 -0
  20. package/dist/cloudflare/worker.d.ts +415 -0
  21. package/dist/cloudflare/worker.js +297 -0
  22. package/dist/codesandbox/guest-service.d.ts +31 -0
  23. package/dist/codesandbox/guest-service.js +453 -0
  24. package/dist/codesandbox/index.d.ts +386 -0
  25. package/dist/codesandbox/index.js +1313 -0
  26. package/dist/compat/core.d.ts +39 -0
  27. package/dist/compat/core.js +199 -0
  28. package/dist/compat/files.d.ts +26 -0
  29. package/dist/compat/files.js +115 -0
  30. package/dist/compat/shell.d.ts +4 -0
  31. package/dist/compat/shell.js +165 -0
  32. package/dist/daytona/context.js +2 -1
  33. package/dist/daytona/process.d.ts +1 -1
  34. package/dist/daytona/process.js +27 -4
  35. package/dist/e2b/code-interpreter.d.ts +4 -5
  36. package/dist/e2b/code-interpreter.js +54 -56
  37. package/dist/e2b/commands.d.ts +5 -4
  38. package/dist/e2b/commands.js +37 -29
  39. package/dist/e2b/errors.js +1 -1
  40. package/dist/e2b/filesystem.d.ts +8 -6
  41. package/dist/e2b/filesystem.js +127 -28
  42. package/dist/e2b/sandbox.d.ts +4 -3
  43. package/dist/e2b/sandbox.js +10 -10
  44. package/dist/errors.d.ts +8 -0
  45. package/dist/errors.js +15 -2
  46. package/dist/freestyle/index.d.ts +301 -0
  47. package/dist/freestyle/index.js +310 -0
  48. package/dist/index.d.ts +1 -0
  49. package/dist/mcp.js +2 -2
  50. package/dist/modal/index.d.ts +205 -0
  51. package/dist/modal/index.js +485 -0
  52. package/dist/network-cli.js +1 -1
  53. package/dist/openai-agents/index.js +9 -26
  54. package/dist/products/audit.d.ts +3 -0
  55. package/dist/products/index.d.ts +2 -0
  56. package/dist/products/index.js +2 -0
  57. package/dist/products/interpreter.d.ts +11 -4
  58. package/dist/products/interpreter.js +33 -9
  59. package/dist/products/jobs.d.ts +1 -1
  60. package/dist/products/jobs.js +5 -4
  61. package/dist/products/limits.d.ts +10 -0
  62. package/dist/products/previews.d.ts +10 -2
  63. package/dist/products/previews.js +3 -2
  64. package/dist/products/tailscale.d.ts +57 -0
  65. package/dist/products/tailscale.js +17 -0
  66. package/dist/products/watch.d.ts +25 -4
  67. package/dist/products/watch.js +75 -14
  68. package/dist/runloop/index.d.ts +340 -0
  69. package/dist/runloop/index.js +438 -0
  70. package/dist/runloop/pagination.d.ts +46 -0
  71. package/dist/runloop/pagination.js +82 -0
  72. package/dist/runloop/snapshots.d.ts +52 -0
  73. package/dist/runloop/snapshots.js +244 -0
  74. package/dist/sandbox.d.ts +40 -9
  75. package/dist/sandbox.js +273 -76
  76. package/dist/snapshots.d.ts +11 -1
  77. package/dist/snapshots.js +11 -1
  78. package/dist/sprites/index.d.ts +251 -0
  79. package/dist/sprites/index.js +552 -0
  80. package/dist/tar.d.ts +8 -1
  81. package/dist/tar.js +185 -35
  82. package/dist/tools/index.js +2 -1
  83. package/dist/transport.d.ts +20 -2
  84. package/dist/transport.js +48 -11
  85. package/dist/types.d.ts +52 -3
  86. package/dist/vercel/command.js +12 -1
  87. package/dist/vercel/errors.d.ts +2 -2
  88. package/dist/vercel/errors.js +12 -1
  89. package/dist/vercel/filesystem.d.ts +21 -18
  90. package/dist/vercel/filesystem.js +95 -48
  91. package/dist/vercel/sandbox.js +5 -5
  92. package/package.json +45 -3
package/dist/tar.js CHANGED
@@ -1,6 +1,8 @@
1
- import { chmod, lstat, mkdir, readdir, readFile, realpath, symlink, unlink, writeFile, } from "node:fs/promises";
1
+ import { chmod, lstat, mkdir, mkdtemp, open, readdir, readFile, realpath, rename, rm, symlink, unlink, } from "node:fs/promises";
2
2
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
- import { gunzipSync, gzipSync } from "node:zlib";
3
+ import { Readable } from "node:stream";
4
+ import { createGunzip, gzipSync } from "node:zlib";
5
+ import { RuntimeError } from "./errors.js";
4
6
  /* A small ustar writer and reader for directory uploads and downloads, so the
5
7
  SDK needs no dependency. Regular files, directories and symbolic links;
6
8
  names up to 255 bytes through the ustar prefix field. */
@@ -111,34 +113,119 @@ async function linkStaysInside(root, directory, link) {
111
113
  }
112
114
  return true;
113
115
  }
114
- /** Unpacks into `target`, refusing any entry that would land outside it. */
115
- export async function unpackArchive(archive, target) {
116
- const data = gunzipSync(archive);
117
- await mkdir(resolve(target), { recursive: true });
118
- const root = await realpath(resolve(target));
119
- const text = (start, length) => {
120
- const slice = data.subarray(start, start + length);
116
+ /** A folder that did not arrive whole: tar in the sandbox stopped part way
117
+ * (a file it may not read, one that changed as it was read), which ends the
118
+ * gzip stream short, or the connection was lost (ARCHITECTURE.md section 10,
119
+ * "Folders over HTTP"). */
120
+ function cutShort(cause) {
121
+ return new RuntimeError({
122
+ message: "The folder's archive arrived cut short: tar in the sandbox stopped part way, or the connection was lost. Nothing was written.",
123
+ hint: "Try again. If it fails the same way, a file in the folder cannot be read by the sandbox user or changes as it is read.",
124
+ code: "download_incomplete",
125
+ status: 0,
126
+ ...(cause === undefined ? {} : { cause }),
127
+ });
128
+ }
129
+ /** The gzip stream's bytes, decompressed as they arrive. */
130
+ function gunzipped(source) {
131
+ const input = Readable.from(source, { objectMode: false });
132
+ const output = createGunzip();
133
+ input.on("error", (error) => output.destroy(error));
134
+ input.pipe(output);
135
+ output.on("close", () => input.destroy());
136
+ return output;
137
+ }
138
+ /** Exact byte counts from a stream of chunks, holding at most one chunk. */
139
+ class Bytes {
140
+ #chunks;
141
+ #held = new Uint8Array(0);
142
+ constructor(source) {
143
+ this.#chunks = source[Symbol.asyncIterator]();
144
+ }
145
+ async #more() {
146
+ let next;
147
+ try {
148
+ next = await this.#chunks.next();
149
+ }
150
+ catch (error) {
151
+ throw error instanceof RuntimeError ? error : cutShort(error);
152
+ }
153
+ if (next.done)
154
+ return false;
155
+ this.#held = this.#held.length ? Buffer.concat([this.#held, next.value]) : next.value;
156
+ return true;
157
+ }
158
+ /** `n` bytes, or undefined at a clean end before any of them. */
159
+ async exactly(n) {
160
+ while (this.#held.length < n) {
161
+ if (await this.#more())
162
+ continue;
163
+ if (this.#held.length === 0)
164
+ return undefined;
165
+ throw cutShort();
166
+ }
167
+ const out = this.#held.subarray(0, n);
168
+ this.#held = this.#held.subarray(n);
169
+ return out;
170
+ }
171
+ /** The next `n` bytes as they arrive. */
172
+ async *take(n) {
173
+ while (n > 0) {
174
+ if (this.#held.length === 0 && !(await this.#more()))
175
+ throw cutShort();
176
+ const out = this.#held.subarray(0, n);
177
+ this.#held = this.#held.subarray(out.length);
178
+ n -= out.length;
179
+ yield out;
180
+ }
181
+ }
182
+ /** Reads to the end, so the gzip stream's own check has run. */
183
+ async drain() {
184
+ this.#held = new Uint8Array(0);
185
+ while (await this.#more())
186
+ this.#held = new Uint8Array(0);
187
+ }
188
+ }
189
+ /** Unpacks a tar stream into `root`, a fresh folder: files and folders as they
190
+ * arrive, links noted to make at the end. Throws unless the archive's end
191
+ * arrived. */
192
+ async function unpackEntries(bytes, root) {
193
+ const links = [];
194
+ const decode = (slice) => {
121
195
  const end = slice.indexOf(0);
122
196
  return new TextDecoder().decode(end < 0 ? slice : slice.subarray(0, end));
123
197
  };
124
- const links = [];
125
198
  let longName;
126
199
  let longLink;
127
- for (let offset = 0; offset + 512 <= data.length;) {
128
- if (data.subarray(offset, offset + 512).every((byte) => byte === 0))
129
- break;
130
- const size = parseInt(text(offset + 124, 12).trim() || "0", 8);
131
- const type = String.fromCharCode(data[offset + 156] ?? 48);
132
- const prefix = text(offset + 345, 155);
133
- let name = longName ?? (prefix ? `${prefix}/${text(offset, 100)}` : text(offset, 100));
134
- const link = longLink ?? text(offset + 157, 100);
200
+ for (;;) {
201
+ const header = await bytes.exactly(512);
202
+ if (!header)
203
+ throw cutShort();
204
+ if (header.every((byte) => byte === 0)) {
205
+ await bytes.drain();
206
+ return links;
207
+ }
208
+ const text = (start, length) => decode(header.subarray(start, start + length));
209
+ const size = parseInt(text(124, 12).trim() || "0", 8);
210
+ const type = String.fromCharCode(header[156] ?? 48);
211
+ const prefix = text(345, 155);
212
+ let name = longName ?? (prefix ? `${prefix}/${text(0, 100)}` : text(0, 100));
213
+ const link = longLink ?? text(157, 100);
135
214
  longName = undefined;
136
215
  longLink = undefined;
137
- const mode = parseInt(text(offset + 100, 8).trim() || "644", 8);
138
- const body = data.subarray(offset + 512, offset + 512 + size);
139
- offset += 512 + Math.ceil(size / 512) * 512;
216
+ const mode = parseInt(text(100, 8).trim() || "644", 8);
217
+ const padding = Math.ceil(size / 512) * 512 - size;
218
+ const skip = async () => {
219
+ for await (const _ of bytes.take(size + padding))
220
+ ;
221
+ };
140
222
  if (type === "L" || type === "K") {
141
- const value = new TextDecoder().decode(body).replace(/\0.*$/s, "");
223
+ const parts = [];
224
+ for await (const part of bytes.take(size))
225
+ parts.push(Uint8Array.from(part));
226
+ for await (const _ of bytes.take(padding))
227
+ ;
228
+ const value = new TextDecoder().decode(Buffer.concat(parts)).replace(/\0.*$/s, "");
142
229
  if (type === "L")
143
230
  longName = value;
144
231
  else
@@ -146,29 +233,92 @@ export async function unpackArchive(archive, target) {
146
233
  continue;
147
234
  }
148
235
  name = name.replace(/^\.\//, "");
149
- if (!name || name === "." || name === "./")
236
+ if (!name || name === "." || name === "./") {
237
+ await skip();
150
238
  continue;
239
+ }
151
240
  const destination = resolve(root, name);
152
241
  await assertPlain(root, destination, name);
153
- if (type === "5")
242
+ if (type === "5") {
154
243
  await mkdir(destination, { recursive: true });
155
- else if (type === "2")
244
+ await skip();
245
+ }
246
+ else if (type === "2") {
156
247
  links.push({ destination, link, name });
248
+ await skip();
249
+ }
157
250
  else if (type === "0" || type === "\0" || type === "7") {
158
251
  await mkdir(dirname(destination), { recursive: true });
159
- await writeFile(destination, body);
252
+ const file = await open(destination, "w");
253
+ try {
254
+ for await (const part of bytes.take(size))
255
+ await file.write(part);
256
+ }
257
+ finally {
258
+ await file.close();
259
+ }
160
260
  await chmod(destination, mode & 0o777);
261
+ for await (const _ of bytes.take(padding))
262
+ ;
161
263
  }
264
+ else
265
+ await skip();
162
266
  }
163
- for (const { destination, link, name } of links) {
164
- await assertPlain(root, destination, name);
165
- await mkdir(dirname(destination), { recursive: true });
166
- await symlink(link, destination).catch(() => undefined);
267
+ }
268
+ /** Moves what was unpacked in `from` into `to`, merging with what is there
269
+ * and replacing files of the same name, never through a link in `to`. */
270
+ async function merge(from, to, root) {
271
+ for (const name of await readdir(from)) {
272
+ const source = join(from, name);
273
+ const destination = join(to, name);
274
+ await assertPlain(root, destination, relative(root, destination));
275
+ const [kind, there] = await Promise.all([
276
+ lstat(source),
277
+ lstat(destination).catch(() => undefined),
278
+ ]);
279
+ if (kind.isDirectory() && there?.isDirectory())
280
+ await merge(source, destination, root);
281
+ else
282
+ await rename(source, destination);
167
283
  }
168
- for (const { destination, link, name } of links) {
169
- if (await linkStaysInside(root, dirname(destination), link))
170
- continue;
171
- await unlink(destination).catch(() => undefined);
172
- throw new Error(`Refusing an archive link that leads outside the target: ${name} -> ${link}`);
284
+ }
285
+ /** Unpacks a gzipped tar that arrives as a stream into `target`, refusing any
286
+ * entry that would land outside it, holding no more than a chunk at a time.
287
+ * It unpacks into a folder beside the target and moves it into place only
288
+ * once the whole archive arrived, so an archive cut short leaves nothing
289
+ * behind that could pass for the folder. A target that exists is merged
290
+ * into, files of the same name replaced. */
291
+ export async function unpackStream(source, target) {
292
+ const final = resolve(target);
293
+ await mkdir(dirname(final), { recursive: true });
294
+ const staging = await mkdtemp(`${final}.runtime-partial-`);
295
+ const stream = gunzipped(source);
296
+ try {
297
+ const root = await realpath(staging);
298
+ const links = await unpackEntries(new Bytes(stream), root);
299
+ for (const { destination, link, name } of links) {
300
+ await assertPlain(root, destination, name);
301
+ await mkdir(dirname(destination), { recursive: true });
302
+ await symlink(link, destination).catch(() => undefined);
303
+ }
304
+ for (const { destination, link, name } of links) {
305
+ if (await linkStaysInside(root, dirname(destination), link))
306
+ continue;
307
+ await unlink(destination).catch(() => undefined);
308
+ throw new Error(`Refusing an archive link that leads outside the target: ${name} -> ${link}`);
309
+ }
310
+ const there = await lstat(final).catch(() => undefined);
311
+ if (!there)
312
+ await rename(staging, final);
313
+ else
314
+ await merge(root, await realpath(final), await realpath(final));
315
+ }
316
+ finally {
317
+ stream.destroy();
318
+ await rm(staging, { recursive: true, force: true });
173
319
  }
174
320
  }
321
+ /** Unpacks a whole gzipped tar held in memory, by the same rules. */
322
+ export function unpackArchive(archive, target) {
323
+ return unpackStream([archive], target);
324
+ }
@@ -10,6 +10,7 @@
10
10
  * chooses commands and paths, never the sandbox, the account or the key.
11
11
  * Relative paths are under /workspace, and output is capped so one noisy
12
12
  * command cannot flood the model's context. */
13
+ import { TOOL_EXEC_TIMEOUT_SECONDS } from "../api-defaults.js";
13
14
  import { NotFoundError } from "../errors.js";
14
15
  const WORKSPACE = "/workspace";
15
16
  /** A POSIX path joined and normalized: `..` cannot climb past `/`. */
@@ -34,7 +35,7 @@ export function clip(text, limit) {
34
35
  /** runtime_exec, runtime_read_file, runtime_write_file and runtime_list_files, bound to `sandbox`. */
35
36
  export function sandboxTools(sandbox, options = {}) {
36
37
  const root = resolvePath("/", options.root ?? WORKSPACE);
37
- const timeoutSeconds = options.timeoutSeconds ?? 300;
38
+ const timeoutSeconds = options.timeoutSeconds ?? TOOL_EXEC_TIMEOUT_SECONDS;
38
39
  const limit = options.maxOutputChars ?? 20_000;
39
40
  const inFilesApi = (path) => path.startsWith(`${WORKSPACE}/`);
40
41
  return [
@@ -1,17 +1,35 @@
1
1
  import { RuntimeError } from "./errors.js";
2
- export declare const VERSION = "0.8.3";
2
+ export declare const VERSION = "0.9.0";
3
3
  export declare const DEFAULT_BASE_URL = "https://api.withruntime.com";
4
+ /** Runtime's API as code inside a Runtime sandbox reaches it: the sandbox's
5
+ * own host sends each request on to DEFAULT_BASE_URL over HTTPS. The API runs
6
+ * on that host, whose addresses a sandbox cannot reach directly. Plain HTTP
7
+ * because the hop never leaves the machine: it goes from the program to the
8
+ * guest's own proxy and over the sandbox's private channel to its host. */
9
+ export declare const SANDBOX_BASE_URL = "http://runtime.internal";
10
+ /** True in a Runtime sandbox. Node and Bun only; a browser is never one. */
11
+ export declare function inRuntimeSandbox(marker?: string): boolean;
12
+ /** Where calls go when no base URL is given: RUNTIME_API_URL, then Runtime's
13
+ * public API, which `reachable` turns into runtime.internal in a sandbox. */
14
+ export declare function defaultBaseUrl(env?: Record<string, string | undefined>, inSandbox?: typeof inRuntimeSandbox): string;
15
+ /** The origin calls for `origin` are sent to from here. In a sandbox the
16
+ * public API is its own host, which it cannot reach directly, so calls for it
17
+ * go to runtime.internal; every other origin is left as it is. */
18
+ export declare function reachable(origin: string, inSandbox?: typeof inRuntimeSandbox): string;
4
19
  export type RequestOptions = {
5
20
  /** Sent as Idempotency-Key. Omit it: the SDK makes one per call and keeps
6
21
  * it across its own retries, so a retried write never happens twice. Pass
7
22
  * your own only to retry a call yourself after the process restarted. */
8
23
  idempotencyKey?: string;
9
24
  signal?: AbortSignal;
10
- /** Deadline for this call, retries included. */
25
+ /** Deadline for this call, retries included. 0 disables this deadline;
26
+ * an explicit signal can still cancel the call. */
11
27
  timeoutMs?: number;
12
28
  };
13
29
  export type Query = Record<string, string | number | boolean | readonly string[] | undefined>;
14
30
  export type Call = RequestOptions & {
31
+ /** Internal protocol hook: successful headers arrived, before reading the body. */
32
+ onResponse?: (response: Response) => void;
15
33
  method: "GET" | "POST" | "PUT" | "DELETE";
16
34
  path: string;
17
35
  query?: Query;
package/dist/transport.js CHANGED
@@ -1,17 +1,52 @@
1
- import { ConnectionError, errorFor, RuntimeError, WAITS_FOR_ROOM } from "./errors.js";
1
+ import { ConnectionError, DELIBERATE, errorFor, RuntimeError, WAITS_FOR_ROOM } from "./errors.js";
2
2
  import { describeRoute, envFetch, openWebSocket } from "./proxy.js";
3
- export const VERSION = "0.8.3";
3
+ export const VERSION = "0.9.0";
4
4
  export const DEFAULT_BASE_URL = "https://api.withruntime.com";
5
+ /** Runtime's API as code inside a Runtime sandbox reaches it: the sandbox's
6
+ * own host sends each request on to DEFAULT_BASE_URL over HTTPS. The API runs
7
+ * on that host, whose addresses a sandbox cannot reach directly. Plain HTTP
8
+ * because the hop never leaves the machine: it goes from the program to the
9
+ * guest's own proxy and over the sandbox's private channel to its host. */
10
+ export const SANDBOX_BASE_URL = "http://runtime.internal";
11
+ /** Every Runtime sandbox has this file; guest-net.py keeps it current. */
12
+ const SANDBOX_MARKER = "/run/runtime/environment.json";
13
+ /** True in a Runtime sandbox. Node and Bun only; a browser is never one. */
14
+ export function inRuntimeSandbox(marker = SANDBOX_MARKER) {
15
+ try {
16
+ const host = globalThis.process;
17
+ return host?.getBuiltinModule?.("node:fs")?.existsSync?.(marker) === true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ /** Where calls go when no base URL is given: RUNTIME_API_URL, then Runtime's
24
+ * public API, which `reachable` turns into runtime.internal in a sandbox. */
25
+ export function defaultBaseUrl(env = processEnv(), inSandbox = inRuntimeSandbox) {
26
+ return reachable(env.RUNTIME_API_URL || DEFAULT_BASE_URL, inSandbox);
27
+ }
28
+ /** The origin calls for `origin` are sent to from here. In a sandbox the
29
+ * public API is its own host, which it cannot reach directly, so calls for it
30
+ * go to runtime.internal; every other origin is left as it is. */
31
+ export function reachable(origin, inSandbox = inRuntimeSandbox) {
32
+ return apiOrigin(origin) === DEFAULT_BASE_URL && inSandbox() ? SANDBOX_BASE_URL : origin;
33
+ }
34
+ function processEnv() {
35
+ return (globalThis.process?.env ?? {});
36
+ }
5
37
  export function apiOrigin(value) {
6
38
  const url = new URL(value);
7
- const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
39
+ // runtime.internal is reserved and never resolves outside a sandbox, so a
40
+ // key sent there in plain HTTP never leaves the sandbox's host.
41
+ const local = ["localhost", "127.0.0.1", "[::1]", "runtime.internal"].includes(url.hostname);
8
42
  if ((url.protocol !== "https:" && !(local && url.protocol === "http:")) ||
43
+ (url.hostname === "runtime.internal" && (url.protocol !== "http:" || url.port !== "")) ||
9
44
  url.username ||
10
45
  url.password ||
11
46
  url.search ||
12
47
  url.hash ||
13
48
  url.pathname !== "/")
14
- throw new Error("Use an HTTPS API origin (or http://localhost for tests).");
49
+ throw new Error("Use an HTTPS API origin (or http://runtime.internal inside a sandbox, http://localhost for tests).");
15
50
  return url.origin;
16
51
  }
17
52
  export function encodeQuery(query) {
@@ -30,10 +65,6 @@ export function encodeQuery(query) {
30
65
  const text = params.toString();
31
66
  return text ? `?${text}` : "";
32
67
  }
33
- /** 503s that are a deliberate state, not a passing one: a product switched
34
- * off here, or paused on purpose. Retrying cannot change them, so they fail
35
- * at once with their own words. host_unavailable and busy are still retried. */
36
- const DELIBERATE = new Set(["fork_unavailable", "previews_unavailable", "unavailable"]);
37
68
  const sleep = (ms, signal) => new Promise((resolve, reject) => {
38
69
  const timer = setTimeout(done, ms);
39
70
  function done() {
@@ -99,7 +130,7 @@ export class Transport {
99
130
  throw missingKey();
100
131
  else
101
132
  this.#apiKey = options.apiKey;
102
- this.baseUrl = apiOrigin(options.baseUrl ?? DEFAULT_BASE_URL);
133
+ this.baseUrl = apiOrigin(options.baseUrl ? reachable(options.baseUrl) : defaultBaseUrl());
103
134
  // The environment's proxy (HTTPS_PROXY, NO_PROXY) on Node and Bun alike;
104
135
  // a fetch passed in is used as it is.
105
136
  this.#fetch = options.fetch ?? envFetch;
@@ -147,7 +178,8 @@ export class Transport {
147
178
  const roomMs = Math.max(0, call.waitForCapacityMs ?? 0);
148
179
  const roomUntil = performance.now() + roomMs;
149
180
  let roomAttempt = 0;
150
- const deadline = AbortSignal.timeout((call.timeoutMs ?? this.#timeoutMs) + roomMs);
181
+ const timeoutMs = call.timeoutMs ?? this.#timeoutMs;
182
+ const deadline = timeoutMs === 0 ? new AbortController().signal : AbortSignal.timeout(timeoutMs + roomMs);
151
183
  const signal = call.signal ? AbortSignal.any([call.signal, deadline]) : deadline;
152
184
  const url = `${this.baseUrl}${call.path}${encodeQuery(call.query)}`;
153
185
  const body = call.bytes ?? (call.body === undefined ? undefined : JSON.stringify(call.body));
@@ -211,8 +243,10 @@ export class Transport {
211
243
  return new Response(text, { headers: response.headers });
212
244
  late = { status: failed, text };
213
245
  }
214
- else if (response.ok)
246
+ else if (response.ok) {
247
+ call.onResponse?.(response);
215
248
  return response;
249
+ }
216
250
  const status = late?.status ?? response.status;
217
251
  // Runtime never answers 407: it is a proxy refusing the tunnel, which
218
252
  // Bun's fetch hands back as a response rather than an error.
@@ -336,12 +370,14 @@ export class Transport {
336
370
  let buffer = "";
337
371
  try {
338
372
  for (;;) {
373
+ call.signal?.throwIfAborted();
339
374
  const { value, done } = await reader.read();
340
375
  if (done)
341
376
  break;
342
377
  buffer += value;
343
378
  let newline = buffer.indexOf("\n");
344
379
  while (newline >= 0) {
380
+ call.signal?.throwIfAborted();
345
381
  const line = buffer.slice(0, newline).trim();
346
382
  buffer = buffer.slice(newline + 1);
347
383
  if (line)
@@ -349,6 +385,7 @@ export class Transport {
349
385
  newline = buffer.indexOf("\n");
350
386
  }
351
387
  }
388
+ call.signal?.throwIfAborted();
352
389
  if (buffer.trim())
353
390
  yield JSON.parse(buffer);
354
391
  }
package/dist/types.d.ts CHANGED
@@ -39,8 +39,14 @@ export type SandboxInfo = {
39
39
  heldMicros: number;
40
40
  simulated?: boolean;
41
41
  replayed?: boolean;
42
+ /** The names of its own environment variables (`env`), on a create, an
43
+ * update and a read of one sandbox. Values are never shown. */
44
+ envNames?: string[];
42
45
  /** On a create from an image with a start command: whether it started,
43
- * became ready (readyMs), timed out, or exited first. */
46
+ * became ready (readyMs), timed out, or exited first. The create's answer
47
+ * alone carries it, and the Sandbox that create returned keeps it through
48
+ * later reads; `sandboxes.get` and `list` have none, because the API keeps
49
+ * no record of it. */
44
50
  start?: {
45
51
  state: "started" | "ready" | "timeout" | "exited" | "not_running";
46
52
  processId?: string;
@@ -54,6 +60,11 @@ export type SandboxInfo = {
54
60
  export type CreateSandbox = {
55
61
  name?: string;
56
62
  labels?: Record<string, string>;
63
+ /** Environment variables for every command, process, terminal, SSH session
64
+ * and the image's start command in this sandbox, under each command's own
65
+ * `env`. At most 32 variables and 32 KiB. Values are never shown again;
66
+ * answers list only `envNames`. Copies made by fork() keep them. */
67
+ env?: Record<string, string>;
57
68
  /** Omit to use the free trial while it lasts, then prepaid credit. */
58
69
  funding?: "trial" | "paid";
59
70
  region?: string;
@@ -103,6 +114,13 @@ export type CreateSandbox = {
103
114
  path: string;
104
115
  mode?: "rw" | "snapshot";
105
116
  }[];
117
+ /** Paid only: join your Tailscale network once running. `authKeySecret`
118
+ * names a job secret holding the auth key. */
119
+ tailscale?: {
120
+ authKeySecret: string;
121
+ hostname?: string;
122
+ tags?: string[];
123
+ };
106
124
  };
107
125
  export type CommandResult = {
108
126
  exitCode: number | null;
@@ -124,8 +142,8 @@ export type ExecOptions = {
124
142
  /** Default 60 000, or 24 hours when the output streams (onStdout, onStderr or
125
143
  * execStream); up to 24 hours. A timeout is a result (timedOut), not an error. */
126
144
  timeoutMs?: number;
127
- onStdout?: (text: string) => void;
128
- onStderr?: (text: string) => void;
145
+ onStdout?: (text: string) => unknown;
146
+ onStderr?: (text: string) => unknown;
129
147
  /** Throw CommandError when the exit code is not 0. */
130
148
  check?: boolean;
131
149
  signal?: AbortSignal;
@@ -141,6 +159,7 @@ export type ProcessInfo = {
141
159
  pty: boolean;
142
160
  stdinOpen: boolean;
143
161
  stdinOffset: number;
162
+ outputEncoding?: "utf8" | "base64";
144
163
  startedAt: string;
145
164
  endedAt: string | null;
146
165
  timeoutMs: number | null;
@@ -155,6 +174,7 @@ export type OutputEvent = {
155
174
  type: "stdout" | "stderr";
156
175
  data: string;
157
176
  offset: number;
177
+ base64?: string;
158
178
  } | {
159
179
  type: "exit";
160
180
  exitCode: number | null;
@@ -177,6 +197,13 @@ export type OutputEvent = {
177
197
  requestId?: string;
178
198
  };
179
199
  };
200
+ export type BinaryOutputEvent = Exclude<OutputEvent, {
201
+ type: "stdout" | "stderr";
202
+ }> | {
203
+ type: "stdout" | "stderr";
204
+ data: Uint8Array;
205
+ offset: number;
206
+ };
180
207
  export type FileEntry = {
181
208
  name: string;
182
209
  path: string;
@@ -184,6 +211,11 @@ export type FileEntry = {
184
211
  size: number;
185
212
  mode: string;
186
213
  modifiedAt: string;
214
+ uid?: number;
215
+ gid?: number;
216
+ owner?: string;
217
+ group?: string;
218
+ symlinkTarget?: string;
187
219
  };
188
220
  /** GET /v1/usage. Money is integer microdollars in strings (1,000,000 = $1),
189
221
  * exact past 2^53: available = credited - spent - expired - held. */
@@ -226,6 +258,9 @@ export type FeedbackKind = "bug" | "missing_feature" | "competitor_gap" | "migra
226
258
  export type SandboxSettings = {
227
259
  name?: string;
228
260
  labels?: Record<string, string>;
261
+ /** Changes its environment: a value sets a variable, null removes it, and
262
+ * the rest stay. Commands started afterwards get the change. */
263
+ env?: Record<string, string | null>;
229
264
  /** A request to a paused sandbox wakes it. */
230
265
  autoWake?: boolean;
231
266
  /** Pause after this many seconds with no activity, counted from now; 0
@@ -236,6 +271,20 @@ export type SandboxSettings = {
236
271
  /** Lifetime cap in microdollars; null removes it. */
237
272
  maxTotalCostMicros?: number | null;
238
273
  };
274
+ /** A deleted sandbox, as `delete()` answers it the first time and every time
275
+ * after. It reads nowhere else afterwards. */
276
+ export type DeletedSandbox = {
277
+ id: string;
278
+ kind: "sandbox";
279
+ name: string | null;
280
+ labels: Record<string, string>;
281
+ status: "deleted";
282
+ /** "stopping" until its machine has stopped, usually well under a second. */
283
+ state: string;
284
+ deletedAt: string;
285
+ endedAt: string | null;
286
+ replayed?: boolean;
287
+ };
239
288
  /** How `sandbox.keepAlive()` extends the lease. */
240
289
  export type KeepAliveOptions = {
241
290
  /** How often it checks, in seconds. Default 60. */
@@ -47,18 +47,25 @@ export class Command {
47
47
  logs(opts = {}) {
48
48
  const abort = new AbortController();
49
49
  const forward = () => abort.abort();
50
- opts.signal?.addEventListener("abort", forward, { once: true });
50
+ if (opts.signal?.aborted)
51
+ abort.abort();
52
+ else
53
+ opts.signal?.addEventListener("abort", forward, { once: true });
51
54
  const lines = this.init.lines;
52
55
  const process = this.init.process;
53
56
  const sandboxName = this.init.sandboxName;
54
57
  async function* read() {
55
58
  try {
59
+ if (abort.signal.aborted)
60
+ return;
56
61
  if (lines) {
57
62
  yield* lines;
58
63
  return;
59
64
  }
60
65
  try {
61
66
  for await (const event of process.output({ signal: abort.signal })) {
67
+ if (event.type === "truncated")
68
+ throw new StreamError("output_truncated", "Some command output is no longer available.", process.id);
62
69
  if (event.type === "stdout" || event.type === "stderr")
63
70
  yield { stream: event.type, data: event.data };
64
71
  if (event.type === "exit")
@@ -68,6 +75,8 @@ export class Command {
68
75
  catch (error) {
69
76
  if (abort.signal.aborted)
70
77
  return;
78
+ if (error instanceof StreamError)
79
+ throw error;
71
80
  const translated = translate(error, sandboxName);
72
81
  throw translated instanceof Error && translated !== error
73
82
  ? translated
@@ -94,6 +103,8 @@ export class Command {
94
103
  let exit;
95
104
  await guard(async () => {
96
105
  for await (const event of this.init.process.output(params.signal ? { signal: params.signal } : {})) {
106
+ if (event.type === "truncated")
107
+ throw new StreamError("output_truncated", "Some command output is no longer available.", this.init.process.id);
97
108
  if (event.type === "stdout" || event.type === "stderr")
98
109
  lines.push({ stream: event.type, data: event.data });
99
110
  else if (event.type === "exit")
@@ -44,8 +44,8 @@ export declare function translate(error: unknown, sandboxName?: string): unknown
44
44
  /** Runs `work`, turning any Runtime error into Vercel's. */
45
45
  export declare function guard<T>(work: () => Promise<T>, sandboxName?: string): Promise<T>;
46
46
  /** A node:fs-style error, as Vercel's `sandbox.fs` throws. */
47
- export declare function fsError(code: "ENOENT" | "EEXIST" | "EISDIR" | "ENOTDIR", syscall: string, path: string): Error & {
48
- code: "ENOENT" | "EEXIST" | "EISDIR" | "ENOTDIR";
47
+ export declare function fsError(code: "ENOENT" | "EEXIST" | "EISDIR" | "ENOTDIR" | "EACCES" | "ENOTEMPTY" | "EINVAL", syscall: string, path: string): Error & {
48
+ code: "ENOENT" | "EEXIST" | "ENOTDIR" | "EISDIR" | "EACCES" | "ENOTEMPTY" | "EINVAL";
49
49
  errno: number;
50
50
  syscall: string;
51
51
  path: string;
@@ -102,8 +102,19 @@ export function fsError(code, syscall, path) {
102
102
  EEXIST: "file already exists",
103
103
  EISDIR: "illegal operation on a directory",
104
104
  ENOTDIR: "not a directory",
105
+ EACCES: "permission denied",
106
+ ENOTEMPTY: "directory not empty",
107
+ EINVAL: "invalid argument",
108
+ }[code];
109
+ const errno = {
110
+ ENOENT: -2,
111
+ EEXIST: -17,
112
+ EISDIR: -21,
113
+ ENOTDIR: -20,
114
+ EACCES: -13,
115
+ ENOTEMPTY: -39,
116
+ EINVAL: -22,
105
117
  }[code];
106
- const errno = { ENOENT: -2, EEXIST: -17, EISDIR: -21, ENOTDIR: -20 }[code];
107
118
  return Object.assign(new Error(`${code}: ${text}, ${syscall} '${path}'`), {
108
119
  code,
109
120
  errno,