withruntime 0.4.0 → 0.5.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 (87) hide show
  1. package/DAYTONA.md +129 -0
  2. package/E2B.md +2 -2
  3. package/README.md +47 -9
  4. package/VERCEL.md +127 -0
  5. package/dist/ai/index.d.ts +33 -0
  6. package/dist/ai/index.js +38 -0
  7. package/dist/claude-agent-sdk/index.d.ts +7 -0
  8. package/dist/claude-agent-sdk/index.js +65 -0
  9. package/dist/cli.d.ts +6 -0
  10. package/dist/cli.js +939 -102
  11. package/dist/client.d.ts +25 -2
  12. package/dist/client.js +14 -1
  13. package/dist/credentials.d.ts +10 -0
  14. package/dist/credentials.js +56 -1
  15. package/dist/daytona/client.d.ts +44 -0
  16. package/dist/daytona/client.js +40 -0
  17. package/dist/daytona/context.d.ts +26 -0
  18. package/dist/daytona/context.js +22 -0
  19. package/dist/daytona/daytona.d.ts +185 -0
  20. package/dist/daytona/daytona.js +416 -0
  21. package/dist/daytona/errors.d.ts +106 -0
  22. package/dist/daytona/errors.js +182 -0
  23. package/dist/daytona/filesystem.d.ts +84 -0
  24. package/dist/daytona/filesystem.js +206 -0
  25. package/dist/daytona/git.d.ts +33 -0
  26. package/dist/daytona/git.js +128 -0
  27. package/dist/daytona/image.d.ts +45 -0
  28. package/dist/daytona/image.js +140 -0
  29. package/dist/daytona/index.d.ts +50 -0
  30. package/dist/daytona/index.js +50 -0
  31. package/dist/daytona/process.d.ts +86 -0
  32. package/dist/daytona/process.js +339 -0
  33. package/dist/daytona/sandbox.d.ts +196 -0
  34. package/dist/daytona/sandbox.js +448 -0
  35. package/dist/e2b/sandbox.d.ts +22 -2
  36. package/dist/e2b/sandbox.js +39 -7
  37. package/dist/errors.d.ts +10 -4
  38. package/dist/errors.js +26 -6
  39. package/dist/image-context.d.ts +39 -0
  40. package/dist/image-context.js +176 -0
  41. package/dist/index.d.ts +19 -2
  42. package/dist/index.js +17 -2
  43. package/dist/keys.d.ts +43 -0
  44. package/dist/keys.js +196 -0
  45. package/dist/login.js +14 -2
  46. package/dist/openai-agents/index.d.ts +132 -0
  47. package/dist/openai-agents/index.js +791 -0
  48. package/dist/products/audit.d.ts +48 -0
  49. package/dist/products/audit.js +10 -0
  50. package/dist/products/images.d.ts +113 -7
  51. package/dist/products/images.js +186 -27
  52. package/dist/products/index.d.ts +11 -0
  53. package/dist/products/index.js +17 -1
  54. package/dist/products/observability.d.ts +186 -0
  55. package/dist/products/observability.js +117 -0
  56. package/dist/products/secrets.d.ts +40 -0
  57. package/dist/products/secrets.js +24 -0
  58. package/dist/products/switching.d.ts +89 -0
  59. package/dist/products/switching.js +25 -0
  60. package/dist/sandbox.d.ts +26 -1
  61. package/dist/sandbox.js +101 -1
  62. package/dist/ssh.d.ts +21 -0
  63. package/dist/ssh.js +312 -0
  64. package/dist/tar.d.ts +1 -0
  65. package/dist/tar.js +4 -4
  66. package/dist/tools/index.d.ts +62 -0
  67. package/dist/tools/index.js +180 -0
  68. package/dist/transport.d.ts +12 -2
  69. package/dist/transport.js +31 -4
  70. package/dist/tunnel.d.ts +67 -0
  71. package/dist/tunnel.js +311 -0
  72. package/dist/types.d.ts +57 -1
  73. package/dist/vercel/client.d.ts +40 -0
  74. package/dist/vercel/client.js +39 -0
  75. package/dist/vercel/command.d.ts +70 -0
  76. package/dist/vercel/command.js +148 -0
  77. package/dist/vercel/errors.d.ts +53 -0
  78. package/dist/vercel/errors.js +113 -0
  79. package/dist/vercel/filesystem.d.ts +150 -0
  80. package/dist/vercel/filesystem.js +248 -0
  81. package/dist/vercel/index.d.ts +30 -0
  82. package/dist/vercel/index.js +38 -0
  83. package/dist/vercel/sandbox.d.ts +317 -0
  84. package/dist/vercel/sandbox.js +726 -0
  85. package/dist/vercel/snapshot.d.ts +93 -0
  86. package/dist/vercel/snapshot.js +123 -0
  87. package/package.json +56 -4
package/dist/sandbox.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { CommandError, RuntimeError } from "./errors.js";
2
2
  import { Page } from "./page.js";
3
3
  import { sandboxFactories } from "./products/index.js";
4
+ import { Tunnel } from "./tunnel.js";
4
5
  const CHUNK = 1_048_576;
5
6
  const PARALLEL = 4;
6
7
  const enc = (id) => encodeURIComponent(id);
@@ -21,6 +22,7 @@ function commandBody(command, options) {
21
22
  // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
22
23
  export class Sandbox {
23
24
  #info;
25
+ #keepAlive;
24
26
  #t;
25
27
  files;
26
28
  processes;
@@ -79,6 +81,7 @@ export class Sandbox {
79
81
  const out = { stdout: "", stderr: "" };
80
82
  let exit;
81
83
  let processId;
84
+ let dropped = false;
82
85
  for await (const event of this.execStream(command, options)) {
83
86
  if (event.type === "start")
84
87
  processId = event.processId;
@@ -86,6 +89,8 @@ export class Sandbox {
86
89
  out[event.type] += event.data;
87
90
  (event.type === "stdout" ? options.onStdout : options.onStderr)?.(event.data);
88
91
  }
92
+ else if (event.type === "truncated")
93
+ dropped = true;
89
94
  else if (event.type === "exit")
90
95
  exit = event;
91
96
  }
@@ -93,6 +98,7 @@ export class Sandbox {
93
98
  exitCode: exit?.exitCode ?? null,
94
99
  ...out,
95
100
  timedOut: exit?.timedOut ?? false,
101
+ ...truncation(dropped),
96
102
  ...(exit?.durationMs === undefined ? {} : { durationMs: exit.durationMs }),
97
103
  ...(processId ? { processId } : {}),
98
104
  };
@@ -163,7 +169,35 @@ export class Sandbox {
163
169
  });
164
170
  return Terminal.open(socket, options.onData);
165
171
  }
172
+ /** A tunnel into the sandbox: TCP connections to any port on its loopback,
173
+ * and SSH logins, over one authenticated WebSocket. No port is opened to the
174
+ * internet. `tunnel.connect(5432)` gives one connection;
175
+ * `tunnel.forward(5432)` listens locally (Node and Bun). */
176
+ async tunnel() {
177
+ return Tunnel.open(await this.#t.websocket(`/v1/sandboxes/${enc(this.id)}/tunnel`));
178
+ }
179
+ /** Listens on a local port (the same number unless `localPort` says, 0 for
180
+ * any) and forwards each connection to `port` inside the sandbox, until
181
+ * `close()`. Node and Bun. */
182
+ async forwardPort(port, options = {}) {
183
+ const tunnel = await this.tunnel();
184
+ try {
185
+ const forward = await tunnel.forward(port, options);
186
+ return {
187
+ ...forward,
188
+ close: async () => {
189
+ await forward.close();
190
+ tunnel.close();
191
+ },
192
+ };
193
+ }
194
+ catch (error) {
195
+ tunnel.close();
196
+ throw error;
197
+ }
198
+ }
166
199
  async stop(options = {}) {
200
+ this.#keepAlive?.();
167
201
  return this.#lifecycle("stop", options);
168
202
  }
169
203
  async pause(options = {}) {
@@ -178,6 +212,58 @@ export class Sandbox {
178
212
  async extend(seconds, options = {}) {
179
213
  return this.#lifecycle("extend", { ...options, wait: false }, { seconds });
180
214
  }
215
+ /** Changes its name, labels, automatic wake, idle pause or persistence;
216
+ * fields left out stay as they are. `persistent: true` keeps it running
217
+ * while credit lasts (paid only). */
218
+ async update(settings, options = {}) {
219
+ return this.#lifecycle("update", { ...options, wait: false }, { ...settings });
220
+ }
221
+ /** Keeps a running sandbox's lease ahead of now, in the background, until
222
+ * stop() or the returned function ends it: every `everySeconds` (60) it
223
+ * extends the lease so that `marginSeconds` (600) remain, never more than
224
+ * the hour ahead the API allows. Running time is billed as it is used, as
225
+ * for any extension. A paused sandbox is left paused (a request wakes it,
226
+ * unless autoWake is off); a stopped one ends the loop. It does not keep a
227
+ * Node or Bun process alive by itself. */
228
+ keepAlive(options = {}) {
229
+ this.#keepAlive?.();
230
+ const every = Math.max(10, options.everySeconds ?? 60) * 1000;
231
+ const margin = Math.min(3600, Math.max(60, options.marginSeconds ?? 600));
232
+ let ended = false;
233
+ let timer;
234
+ const end = () => {
235
+ ended = true;
236
+ if (timer)
237
+ clearTimeout(timer);
238
+ if (this.#keepAlive === end)
239
+ this.#keepAlive = undefined;
240
+ };
241
+ const tick = async () => {
242
+ if (ended)
243
+ return;
244
+ try {
245
+ await this.refresh();
246
+ if (this.state === "stopped" || this.state === "stopping")
247
+ return end();
248
+ if (this.state === "running") {
249
+ const left = (Date.parse(this.info.expiresAt) - Date.now()) / 1000;
250
+ const need = Math.ceil(margin - left);
251
+ if (need >= 1)
252
+ await this.extend(Math.min(3600, need));
253
+ }
254
+ }
255
+ catch (error) {
256
+ options.onError?.(error);
257
+ }
258
+ if (ended)
259
+ return;
260
+ timer = setTimeout(() => void tick(), every);
261
+ timer.unref?.();
262
+ };
263
+ this.#keepAlive = end;
264
+ void tick();
265
+ return end;
266
+ }
181
267
  /** Days a paused sandbox is kept before deletion (1 to 365). */
182
268
  async setRetention(days, options = {}) {
183
269
  return this.#lifecycle("retention", { ...options, wait: false }, { days });
@@ -240,6 +326,7 @@ export class Sandbox {
240
326
  return this;
241
327
  }
242
328
  async [Symbol.asyncDispose]() {
329
+ this.#keepAlive?.();
243
330
  if (this.#info.state === "stopped")
244
331
  return;
245
332
  await this.stop({ wait: false }).catch(() => undefined);
@@ -248,6 +335,13 @@ export class Sandbox {
248
335
  return this.#info;
249
336
  }
250
337
  }
338
+ /** A streamed command keeps all its output unless the reader fell more than the
339
+ * process's output buffer behind, which the server marks with a "truncated"
340
+ * event. That event does not say which stream lost bytes, so both flags carry
341
+ * it; without one, both are false, as a synchronous result's are. */
342
+ function truncation(dropped) {
343
+ return { stdoutTruncated: dropped, stderrTruncated: dropped };
344
+ }
251
345
  function pick(options) {
252
346
  return {
253
347
  ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
@@ -332,9 +426,12 @@ export class Process {
332
426
  async wait(options = {}) {
333
427
  const out = { stdout: "", stderr: "" };
334
428
  let exit;
429
+ let dropped = false;
335
430
  for await (const event of this.output(options)) {
336
431
  if (event.type === "stdout" || event.type === "stderr")
337
432
  out[event.type] += event.data;
433
+ if (event.type === "truncated")
434
+ dropped = true;
338
435
  if (event.type === "exit")
339
436
  exit = event;
340
437
  }
@@ -342,6 +439,7 @@ export class Process {
342
439
  exitCode: exit?.exitCode ?? null,
343
440
  ...out,
344
441
  timedOut: exit?.timedOut ?? false,
442
+ ...truncation(dropped),
345
443
  processId: this.id,
346
444
  };
347
445
  }
@@ -576,7 +674,9 @@ export class Files {
576
674
  }
577
675
  const { packDirectory } = await import("./tar.js");
578
676
  const archive = await packDirectory(localPath);
579
- const staging = `/tmp/.runtime-upload-${crypto.randomUUID()}.tar.gz`;
677
+ // Large writes go only to /workspace (the server refuses others), so the
678
+ // archive is staged there and removed after unpacking.
679
+ const staging = `/workspace/.runtime-upload-${crypto.randomUUID()}.tar.gz`;
580
680
  await this.write(staging, archive);
581
681
  const result = await new Sandbox(this.t, { id: this.sandboxId }).exec([
582
682
  "sh",
package/dist/ssh.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { Runtime } from "./client.js";
2
+ import type { Sandbox } from "./sandbox.js";
3
+ type Out = {
4
+ json: boolean;
5
+ write: (text: string) => void;
6
+ error: (text: string) => void;
7
+ };
8
+ export declare const SSH_HELP: (me: string) => string;
9
+ /** This machine's key for Runtime SSH logins, made on first use. Only its
10
+ * public half ever leaves the machine, and only into the one login it opens. */
11
+ export declare function sshKey(env: NodeJS.ProcessEnv): Promise<{
12
+ path: string;
13
+ publicKey: string;
14
+ }>;
15
+ /** A sandbox by id or by its name, as `ssh` hands it over (`<id>.runtime`). */
16
+ export declare function resolveSandbox(runtime: Runtime, target: string): Promise<Sandbox>;
17
+ export declare function sshConfig(env: NodeJS.ProcessEnv): Promise<string>;
18
+ export declare function sshCommand(argv: string[], env: NodeJS.ProcessEnv, out: Out, connect: () => Promise<Runtime>, me: string): Promise<number>;
19
+ /** `runtime sandbox port-forward <id> 5432 8080:3000 ...` */
20
+ export declare function portForwardCommand(argv: string[], out: Out, connect: () => Promise<Runtime>, me: string): Promise<number>;
21
+ export {};
package/dist/ssh.js ADDED
@@ -0,0 +1,312 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { realpathSync } from "node:fs";
3
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { isAbsolute, join } from "node:path";
6
+ import { RuntimeError } from "./errors.js";
7
+ import { VERSION } from "./transport.js";
8
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
9
+ export const SSH_HELP = (me) => `${me} sandbox ssh <id|name> [-- <command...>] Log in over SSH (an interactive shell, or run the command)
10
+ ${me} sandbox ssh config [--install] The ssh config for \`ssh <id|name>.runtime\`, also from
11
+ VS Code Remote-SSH and JetBrains Gateway; --install adds
12
+ it to ~/.ssh/config
13
+ ${me} sandbox ssh proxy <id|name>[.runtime] ProxyCommand mode: SSH on stdin and stdout
14
+ ${me} sandbox port-forward <id|name> <port|local:remote>... [--address 127.0.0.1]
15
+ Forward local ports to ports in the sandbox, until Ctrl-C
16
+
17
+ SSH and port forwarding go through Runtime's API with your key: the sandbox opens
18
+ no port to the internet. The login is the sandbox's user, runtime, with sudo.
19
+ A paused sandbox wakes when you connect, like any command.
20
+ `;
21
+ function configDirectory(env) {
22
+ const root = env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
23
+ if (!isAbsolute(root))
24
+ throw new Error("XDG_CONFIG_HOME must be an absolute path.");
25
+ return join(root, "runtime-cloud");
26
+ }
27
+ /** This machine's key for Runtime SSH logins, made on first use. Only its
28
+ * public half ever leaves the machine, and only into the one login it opens. */
29
+ export async function sshKey(env) {
30
+ const dir = join(configDirectory(env), "ssh");
31
+ const path = join(dir, "id_ed25519");
32
+ try {
33
+ return { path, publicKey: (await readFile(`${path}.pub`, "utf8")).trim() };
34
+ }
35
+ catch {
36
+ // Made below.
37
+ }
38
+ await mkdir(dir, { recursive: true, mode: 0o700 });
39
+ await chmod(dir, 0o700);
40
+ const made = spawnSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "runtime-cloud", "-f", path], {
41
+ stdio: ["ignore", "ignore", "pipe"],
42
+ });
43
+ if (made.error || made.status !== 0)
44
+ throw new RuntimeError({
45
+ message: `Could not make an SSH key with ssh-keygen${made.error ? `: ${made.error.message}` : `: ${made.stderr.toString().trim()}`}.`,
46
+ code: "ssh_keygen_failed",
47
+ status: 0,
48
+ hint: "Install OpenSSH (it has ssh and ssh-keygen), or pass --key with a public key file.",
49
+ });
50
+ return { path, publicKey: (await readFile(`${path}.pub`, "utf8")).trim() };
51
+ }
52
+ /** A sandbox by id or by its name, as `ssh` hands it over (`<id>.runtime`). */
53
+ export async function resolveSandbox(runtime, target) {
54
+ const name = target.replace(/\.runtime$/, "");
55
+ if (UUID.test(name))
56
+ return runtime.sandboxes.get(name);
57
+ const page = await runtime.sandboxes.list({ name, limit: 2 });
58
+ if (page.data.length === 1)
59
+ return page.data[0];
60
+ throw new RuntimeError({
61
+ message: page.data.length
62
+ ? `More than one live sandbox is named ${name}.`
63
+ : `No live sandbox is named ${name}.`,
64
+ code: page.data.length ? "ambiguous_name" : "sandbox_not_found",
65
+ status: 0,
66
+ hint: "Use its id: runtime sandbox ls",
67
+ });
68
+ }
69
+ const quote = (text) => /^[A-Za-z0-9_./:@=-]+$/.test(text) ? text : `'${text.replace(/'/g, "'\\''")}'`;
70
+ /** How ssh should start this CLI again: the same program and script, or npx
71
+ * at this version when it ran from npx's temporary cache. */
72
+ function selfCommand(env) {
73
+ const script = process.argv[1];
74
+ const program = script && !/[\\/]_npx[\\/]/.test(script)
75
+ ? [process.execPath, realpathSync(script)]
76
+ : ["npx", "--yes", `withruntime@${VERSION}`];
77
+ const origin = env.RUNTIME_API_URL ? [`RUNTIME_API_URL=${env.RUNTIME_API_URL}`] : [];
78
+ return [...(origin.length ? ["env", ...origin] : []), ...program].map(quote).join(" ");
79
+ }
80
+ export async function sshConfig(env) {
81
+ const { path } = await sshKey(env);
82
+ return `# Runtime Cloud: \`ssh <sandbox id or name>.runtime\`, and the same host in
83
+ # VS Code Remote-SSH or JetBrains Gateway. The connection is Runtime's
84
+ # authenticated tunnel, which already proves which sandbox answers; each login
85
+ # gets a fresh host key, so there is nothing to pin in known_hosts.
86
+ Host *.runtime
87
+ User runtime
88
+ ProxyCommand ${selfCommand(env)} sandbox ssh proxy %h
89
+ IdentityFile "${path}"
90
+ IdentitiesOnly yes
91
+ StrictHostKeyChecking no
92
+ UserKnownHostsFile /dev/null
93
+ LogLevel ERROR
94
+ ServerAliveInterval 30
95
+ `;
96
+ }
97
+ /** Writes the block to its own file and includes it from ~/.ssh/config once. */
98
+ async function installConfig(env) {
99
+ const file = join(configDirectory(env), "ssh", "config");
100
+ await writeFile(file, await sshConfig(env), { mode: 0o600 });
101
+ const sshDir = join(homedir(), ".ssh");
102
+ await mkdir(sshDir, { recursive: true, mode: 0o700 });
103
+ const main = join(sshDir, "config");
104
+ const current = await readFile(main, "utf8").catch(() => "");
105
+ const include = `Include "${file}"`;
106
+ if (!current.split("\n").some((line) => line.trim() === include))
107
+ // First, because ssh reads an Include inside a Host block as part of it.
108
+ await writeFile(main, `${include}\n${current ? `\n${current}` : ""}`, { mode: 0o600 });
109
+ return file;
110
+ }
111
+ /** ProxyCommand mode: ssh speaks on this process's stdin and stdout. */
112
+ async function proxy(runtime, target, publicKey) {
113
+ const sbx = await resolveSandbox(runtime, target);
114
+ const tunnel = await sbx.tunnel();
115
+ const stream = await tunnel.ssh(publicKey).catch((error) => {
116
+ tunnel.close();
117
+ throw error;
118
+ });
119
+ return new Promise(() => {
120
+ // When the sandbox side ends, the session is over: flush what ssh has not
121
+ // read yet, then exit, which is what closes the pipe ssh is reading (an
122
+ // ended process.stdout does not close it on every runtime).
123
+ let leaving = false;
124
+ const leave = (code) => {
125
+ if (leaving)
126
+ return;
127
+ leaving = true;
128
+ tunnel.close();
129
+ process.stdout.write("", () => process.exit(code));
130
+ };
131
+ stream.onData = (data) => void process.stdout.write(data);
132
+ stream.onEnd = () => leave(0);
133
+ stream.onLog = (text) => process.stderr.write(`sandbox sshd: ${text}`);
134
+ stream.onClose = (reason) => {
135
+ if (reason !== "done")
136
+ process.stderr.write(`Runtime: the SSH connection ended (${reason}).\n`);
137
+ leave(reason === "done" ? 0 : 1);
138
+ };
139
+ // Async iteration reads the next chunk only after this one is inside the
140
+ // window: backpressure that behaves the same on Node and Bun.
141
+ void (async () => {
142
+ for await (const data of process.stdin)
143
+ await stream.write(new Uint8Array(data));
144
+ stream.end();
145
+ })().catch(() => stream.close());
146
+ });
147
+ }
148
+ /** Installs the OpenSSH server in a sandbox that lacks it (an image made
149
+ * before it was in the base image, or a custom one), saying so first. */
150
+ async function ensureSshd(sbx, out) {
151
+ const probe = await sbx.exec(["sh", "-c", "command -v sshd || test -x /usr/sbin/sshd"], {
152
+ timeoutMs: 30_000,
153
+ });
154
+ if (probe.exitCode === 0)
155
+ return;
156
+ out.error("This sandbox has no OpenSSH server yet; installing it once...");
157
+ const installed = await sbx.exec([
158
+ "sh",
159
+ "-c",
160
+ "sudo -n apt-get update -qq && sudo -n env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends openssh-server",
161
+ ], { timeoutMs: 300_000 });
162
+ if (installed.exitCode !== 0)
163
+ throw new RuntimeError({
164
+ message: `Installing openssh-server in the sandbox failed: ${(installed.stderr || installed.stdout).trim().slice(-400)}`,
165
+ code: "sshd_missing",
166
+ status: 0,
167
+ hint: "Install an SSH server in the sandbox yourself, or build an image that has one.",
168
+ });
169
+ }
170
+ export async function sshCommand(argv, env, out, connect, me) {
171
+ const dash = argv.indexOf("--");
172
+ const words = dash === -1 ? argv : argv.slice(0, dash);
173
+ const remote = dash === -1 ? [] : argv.slice(dash + 1);
174
+ const keyAt = words.indexOf("--key");
175
+ const keyFile = keyAt === -1 ? undefined : words[keyAt + 1];
176
+ const positional = words.filter((word, i) => !word.startsWith("--") && (keyAt === -1 || i !== keyAt + 1));
177
+ const [first, second] = positional;
178
+ if (!first || first === "help") {
179
+ out.write(SSH_HELP(me));
180
+ return first ? 0 : 64;
181
+ }
182
+ const publicKey = async () => keyFile ? (await readFile(keyFile, "utf8")).trim() : (await sshKey(env)).publicKey;
183
+ if (first === "config") {
184
+ if (words.includes("--install")) {
185
+ const file = await installConfig(env);
186
+ out.write(out.json
187
+ ? JSON.stringify({ installed: file })
188
+ : `Added Include "${file}" to ~/.ssh/config. Connect with: ssh <sandbox id or name>.runtime`);
189
+ }
190
+ else
191
+ out.write(out.json ? JSON.stringify({ config: await sshConfig(env) }) : await sshConfig(env));
192
+ return 0;
193
+ }
194
+ if (first === "proxy") {
195
+ if (!second)
196
+ throw usageError(`Give the host: ${me} sandbox ssh proxy <id>.runtime`, me);
197
+ return proxy(await connect(), second, await publicKey());
198
+ }
199
+ const runtime = await connect();
200
+ const sbx = await resolveSandbox(runtime, first);
201
+ await ensureSshd(sbx, out);
202
+ const key = keyFile ? undefined : await sshKey(env);
203
+ const args = [
204
+ "-o",
205
+ `ProxyCommand=${selfCommand(env)} sandbox ssh proxy${keyFile ? ` --key ${quote(keyFile)}` : ""} %h`,
206
+ ...(key ? ["-i", key.path, "-o", "IdentitiesOnly=yes"] : []),
207
+ "-o",
208
+ "StrictHostKeyChecking=no",
209
+ "-o",
210
+ "UserKnownHostsFile=/dev/null",
211
+ "-o",
212
+ "LogLevel=ERROR",
213
+ "-o",
214
+ "ServerAliveInterval=30",
215
+ ...(remote.length ? [] : ["-t"]),
216
+ `runtime@${sbx.id}.runtime`,
217
+ // ssh joins what follows the host into one remote command line.
218
+ ...remote,
219
+ ];
220
+ return new Promise((resolve, reject) => {
221
+ const child = spawn("ssh", args, { stdio: "inherit" });
222
+ child.on("error", (error) => reject(new RuntimeError({
223
+ message: `Could not run ssh: ${error.message}`,
224
+ code: "ssh_missing",
225
+ status: 0,
226
+ hint: "Install OpenSSH, or use `runtime sandbox shell <id>`, which needs nothing.",
227
+ })));
228
+ child.on("exit", (code) => resolve(code ?? 255));
229
+ });
230
+ }
231
+ function usageError(message, me) {
232
+ return new RuntimeError({
233
+ message,
234
+ code: "usage",
235
+ status: 0,
236
+ hint: `Run \`${me} sandbox ssh help\`.`,
237
+ });
238
+ }
239
+ /** `runtime sandbox port-forward <id> 5432 8080:3000 ...` */
240
+ export async function portForwardCommand(argv, out, connect, me) {
241
+ const at = argv.indexOf("--address");
242
+ const address = at === -1 ? "127.0.0.1" : argv[at + 1];
243
+ const positional = argv.filter((word, i) => !word.startsWith("--") && (at === -1 || i !== at + 1));
244
+ const [target, ...specs] = positional;
245
+ if (!target || !specs.length || !address)
246
+ throw new RuntimeError({
247
+ message: `Give a sandbox and at least one port: ${me} sandbox port-forward <id> 5432 8080:3000`,
248
+ code: "usage",
249
+ status: 0,
250
+ hint: `Run \`${me} sandbox ssh help\`.`,
251
+ });
252
+ const ports = specs.map((spec) => {
253
+ const match = /^(?:(\d{1,5}):)?(\d{1,5})$/.exec(spec);
254
+ const remote = Number(match?.[2]);
255
+ const local = match?.[1] === undefined ? remote : Number(match[1]);
256
+ if (!match || remote < 1 || remote > 65_535 || local > 65_535)
257
+ throw new RuntimeError({
258
+ message: `${spec} is not a port or local:remote pair.`,
259
+ code: "usage",
260
+ status: 0,
261
+ hint: "For example 5432, or 15432:5432 to listen on 15432 here.",
262
+ });
263
+ return { local, remote };
264
+ });
265
+ const sbx = await resolveSandbox(await connect(), target);
266
+ const tunnel = await sbx.tunnel();
267
+ const forwards = [];
268
+ try {
269
+ for (const { local, remote } of ports)
270
+ forwards.push(await tunnel.forward(remote, { localPort: local, host: address }));
271
+ }
272
+ catch (error) {
273
+ tunnel.close();
274
+ throw new RuntimeError({
275
+ message: `Could not listen here: ${error instanceof Error ? error.message : String(error)}`,
276
+ code: "listen_failed",
277
+ status: 0,
278
+ hint: "Pick another local port with local:remote, for example 15432:5432.",
279
+ });
280
+ }
281
+ out.write(out.json
282
+ ? JSON.stringify({
283
+ sandboxId: sbx.id,
284
+ forwarding: forwards.map((f) => ({
285
+ address: f.host,
286
+ localPort: f.localPort,
287
+ port: f.port,
288
+ })),
289
+ })
290
+ : forwards
291
+ .map((f) => `Forwarding ${f.host}:${f.localPort} -> sandbox ${sbx.id} port ${f.port}`)
292
+ .join("\n") + "\nPress Ctrl-C to stop.");
293
+ return new Promise((resolve) => {
294
+ let stopping = false;
295
+ const stop = () => {
296
+ stopping = true;
297
+ process.off("SIGINT", stop);
298
+ void Promise.all(forwards.map((f) => f.close())).then(() => {
299
+ tunnel.close();
300
+ resolve(0);
301
+ });
302
+ };
303
+ process.once("SIGINT", stop);
304
+ void tunnel.closed.then((reason) => {
305
+ if (stopping)
306
+ return;
307
+ process.off("SIGINT", stop);
308
+ out.error(`The tunnel closed: ${reason}`);
309
+ void Promise.all(forwards.map((f) => f.close())).then(() => resolve(1));
310
+ });
311
+ });
312
+ }
package/dist/tar.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export declare function tarHeader(name: string, size: number, mode: number, type: "0" | "5" | "2", link?: string, mtime?: number): Uint8Array;
1
2
  export declare function packDirectory(root: string): Promise<Uint8Array>;
2
3
  /** Unpacks into `target`, refusing any entry that would land outside it. */
3
4
  export declare function unpackArchive(archive: Uint8Array, target: string): Promise<void>;
package/dist/tar.js CHANGED
@@ -4,7 +4,7 @@ import { gunzipSync, gzipSync } from "node:zlib";
4
4
  /* A small ustar writer and reader for directory uploads and downloads, so the
5
5
  SDK needs no dependency. Regular files, directories and symbolic links;
6
6
  names up to 255 bytes through the ustar prefix field. */
7
- function header(name, size, mode, type, link = "", mtime = 0) {
7
+ export function tarHeader(name, size, mode, type, link = "", mtime = 0) {
8
8
  const block = new Uint8Array(512);
9
9
  const bytes = new TextEncoder().encode(name);
10
10
  let prefix = new Uint8Array();
@@ -48,16 +48,16 @@ export async function packDirectory(root) {
48
48
  const info = await lstat(full);
49
49
  const mtime = Math.floor(info.mtimeMs / 1000);
50
50
  if (info.isDirectory()) {
51
- parts.push(header(`${path}/`, 0, info.mode, "5", "", mtime));
51
+ parts.push(tarHeader(`${path}/`, 0, info.mode, "5", "", mtime));
52
52
  await walk(full);
53
53
  }
54
54
  else if (info.isSymbolicLink()) {
55
55
  const { readlink } = await import("node:fs/promises");
56
- parts.push(header(path, 0, info.mode, "2", await readlink(full), mtime));
56
+ parts.push(tarHeader(path, 0, info.mode, "2", await readlink(full), mtime));
57
57
  }
58
58
  else if (info.isFile()) {
59
59
  const data = await readFile(full);
60
- parts.push(header(path, data.length, info.mode, "0", "", mtime), data);
60
+ parts.push(tarHeader(path, data.length, info.mode, "0", "", mtime), data);
61
61
  const pad = (512 - (data.length % 512)) % 512;
62
62
  if (pad)
63
63
  parts.push(new Uint8Array(pad));
@@ -0,0 +1,62 @@
1
+ import type { Sandbox } from "../sandbox.js";
2
+ export type ToolInputSchema = {
3
+ type: "object";
4
+ properties: Record<string, {
5
+ type: "string" | "integer";
6
+ description: string;
7
+ minimum?: number;
8
+ }>;
9
+ required: string[];
10
+ additionalProperties: false;
11
+ };
12
+ export type SandboxTool<Input, Output> = {
13
+ name: string;
14
+ description: string;
15
+ inputSchema: ToolInputSchema;
16
+ execute(input: Input): Promise<Output>;
17
+ };
18
+ export type ExecInput = {
19
+ command: string;
20
+ cwd?: string;
21
+ timeoutSeconds?: number;
22
+ };
23
+ export type ExecOutput = {
24
+ exitCode: number | null;
25
+ stdout: string;
26
+ stderr: string;
27
+ timedOut: boolean;
28
+ };
29
+ export type ListEntry = {
30
+ path: string;
31
+ type: "file" | "directory" | "symlink" | "other";
32
+ size: number;
33
+ };
34
+ export type SandboxToolOptions = {
35
+ /** Where relative paths resolve. Default /workspace. */
36
+ root?: string;
37
+ /** For a command that names no timeout. Default 300. */
38
+ timeoutSeconds?: number;
39
+ /** Characters kept from each of stdout, stderr and a file read; the rest is cut from the front. Default 20 000. */
40
+ maxOutputChars?: number;
41
+ };
42
+ export type SandboxTools = [
43
+ SandboxTool<ExecInput, ExecOutput>,
44
+ SandboxTool<{
45
+ path: string;
46
+ }, string>,
47
+ SandboxTool<{
48
+ path: string;
49
+ content: string;
50
+ }, string>,
51
+ SandboxTool<{
52
+ path?: string;
53
+ depth?: number;
54
+ }, ListEntry[] | {
55
+ error: string;
56
+ }>
57
+ ];
58
+ /** A POSIX path joined and normalized: `..` cannot climb past `/`. */
59
+ export declare function resolvePath(root: string, path: string | undefined): string;
60
+ export declare function clip(text: string, limit: number): string;
61
+ /** runtime_exec, runtime_read_file, runtime_write_file and runtime_list_files, bound to `sandbox`. */
62
+ export declare function sandboxTools(sandbox: Sandbox, options?: SandboxToolOptions): SandboxTools;