withruntime 0.4.0 → 0.5.1
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.
- package/DAYTONA.md +129 -0
- package/E2B.md +3 -3
- package/README.md +47 -9
- package/VERCEL.md +127 -0
- package/dist/ai/index.d.ts +33 -0
- package/dist/ai/index.js +38 -0
- package/dist/claude-agent-sdk/index.d.ts +7 -0
- package/dist/claude-agent-sdk/index.js +65 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +959 -102
- package/dist/client.d.ts +25 -2
- package/dist/client.js +14 -1
- package/dist/credentials.d.ts +10 -0
- package/dist/credentials.js +56 -1
- package/dist/daytona/client.d.ts +44 -0
- package/dist/daytona/client.js +40 -0
- package/dist/daytona/context.d.ts +31 -0
- package/dist/daytona/context.js +25 -0
- package/dist/daytona/daytona.d.ts +185 -0
- package/dist/daytona/daytona.js +416 -0
- package/dist/daytona/errors.d.ts +106 -0
- package/dist/daytona/errors.js +182 -0
- package/dist/daytona/filesystem.d.ts +84 -0
- package/dist/daytona/filesystem.js +206 -0
- package/dist/daytona/git.d.ts +33 -0
- package/dist/daytona/git.js +128 -0
- package/dist/daytona/image.d.ts +45 -0
- package/dist/daytona/image.js +140 -0
- package/dist/daytona/index.d.ts +50 -0
- package/dist/daytona/index.js +50 -0
- package/dist/daytona/process.d.ts +86 -0
- package/dist/daytona/process.js +341 -0
- package/dist/daytona/sandbox.d.ts +196 -0
- package/dist/daytona/sandbox.js +448 -0
- package/dist/e2b/commands.js +6 -2
- package/dist/e2b/sandbox.d.ts +22 -2
- package/dist/e2b/sandbox.js +39 -7
- package/dist/errors.d.ts +10 -4
- package/dist/errors.js +26 -6
- package/dist/image-context.d.ts +39 -0
- package/dist/image-context.js +176 -0
- package/dist/index.d.ts +19 -2
- package/dist/index.js +17 -2
- package/dist/keys.d.ts +43 -0
- package/dist/keys.js +196 -0
- package/dist/login.js +14 -2
- package/dist/openai-agents/index.d.ts +132 -0
- package/dist/openai-agents/index.js +791 -0
- package/dist/products/audit.d.ts +48 -0
- package/dist/products/audit.js +10 -0
- package/dist/products/images.d.ts +113 -7
- package/dist/products/images.js +186 -27
- package/dist/products/index.d.ts +11 -0
- package/dist/products/index.js +17 -1
- package/dist/products/observability.d.ts +186 -0
- package/dist/products/observability.js +117 -0
- package/dist/products/secrets.d.ts +40 -0
- package/dist/products/secrets.js +24 -0
- package/dist/products/switching.d.ts +96 -0
- package/dist/products/switching.js +25 -0
- package/dist/sandbox.d.ts +26 -1
- package/dist/sandbox.js +101 -1
- package/dist/ssh.d.ts +21 -0
- package/dist/ssh.js +324 -0
- package/dist/tar.d.ts +1 -0
- package/dist/tar.js +4 -4
- package/dist/tools/index.d.ts +62 -0
- package/dist/tools/index.js +180 -0
- package/dist/transport.d.ts +12 -2
- package/dist/transport.js +31 -4
- package/dist/tunnel.d.ts +70 -0
- package/dist/tunnel.js +316 -0
- package/dist/types.d.ts +57 -1
- package/dist/vercel/client.d.ts +40 -0
- package/dist/vercel/client.js +39 -0
- package/dist/vercel/command.d.ts +70 -0
- package/dist/vercel/command.js +148 -0
- package/dist/vercel/errors.d.ts +53 -0
- package/dist/vercel/errors.js +113 -0
- package/dist/vercel/filesystem.d.ts +150 -0
- package/dist/vercel/filesystem.js +248 -0
- package/dist/vercel/index.d.ts +30 -0
- package/dist/vercel/index.js +38 -0
- package/dist/vercel/sandbox.d.ts +317 -0
- package/dist/vercel/sandbox.js +726 -0
- package/dist/vercel/snapshot.d.ts +93 -0
- package/dist/vercel/snapshot.js +123 -0
- 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
|
-
|
|
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,324 @@
|
|
|
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
|
+
/* A connection the sandbox refused is closed at once, which a browser or
|
|
269
|
+
curl reports only as an empty reply, so say why here, once per reason. */
|
|
270
|
+
const said = new Set();
|
|
271
|
+
const refused = (remote) => (error) => {
|
|
272
|
+
const line = `Port ${remote}: ${error.message}${error.hint ? ` ${error.hint}` : ""}`;
|
|
273
|
+
if (said.has(line))
|
|
274
|
+
return;
|
|
275
|
+
said.add(line);
|
|
276
|
+
out.error(out.json
|
|
277
|
+
? JSON.stringify({ port: remote, error: { code: error.code, message: error.message } })
|
|
278
|
+
: line);
|
|
279
|
+
};
|
|
280
|
+
try {
|
|
281
|
+
for (const { local, remote } of ports)
|
|
282
|
+
forwards.push(await tunnel.forward(remote, { localPort: local, host: address, onError: refused(remote) }));
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
tunnel.close();
|
|
286
|
+
throw new RuntimeError({
|
|
287
|
+
message: `Could not listen here: ${error instanceof Error ? error.message : String(error)}`,
|
|
288
|
+
code: "listen_failed",
|
|
289
|
+
status: 0,
|
|
290
|
+
hint: "Pick another local port with local:remote, for example 15432:5432.",
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
out.write(out.json
|
|
294
|
+
? JSON.stringify({
|
|
295
|
+
sandboxId: sbx.id,
|
|
296
|
+
forwarding: forwards.map((f) => ({
|
|
297
|
+
address: f.host,
|
|
298
|
+
localPort: f.localPort,
|
|
299
|
+
port: f.port,
|
|
300
|
+
})),
|
|
301
|
+
})
|
|
302
|
+
: forwards
|
|
303
|
+
.map((f) => `Forwarding ${f.host}:${f.localPort} -> sandbox ${sbx.id} port ${f.port}`)
|
|
304
|
+
.join("\n") + "\nPress Ctrl-C to stop.");
|
|
305
|
+
return new Promise((resolve) => {
|
|
306
|
+
let stopping = false;
|
|
307
|
+
const stop = () => {
|
|
308
|
+
stopping = true;
|
|
309
|
+
process.off("SIGINT", stop);
|
|
310
|
+
void Promise.all(forwards.map((f) => f.close())).then(() => {
|
|
311
|
+
tunnel.close();
|
|
312
|
+
resolve(0);
|
|
313
|
+
});
|
|
314
|
+
};
|
|
315
|
+
process.once("SIGINT", stop);
|
|
316
|
+
void tunnel.closed.then((reason) => {
|
|
317
|
+
if (stopping)
|
|
318
|
+
return;
|
|
319
|
+
process.off("SIGINT", stop);
|
|
320
|
+
out.error(`The tunnel closed: ${reason}`);
|
|
321
|
+
void Promise.all(forwards.map((f) => f.close())).then(() => resolve(1));
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
}
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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;
|