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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/** Four tools bound to one Runtime sandbox, in no framework's format: a name,
|
|
2
|
+
* a description, a JSON Schema and a function. The adapters for the Vercel AI
|
|
3
|
+
* SDK (and Mastra), the Claude Agent SDK and the OpenAI Agents SDK wrap these,
|
|
4
|
+
* and any other framework can too:
|
|
5
|
+
*
|
|
6
|
+
* import { sandboxTools } from "withruntime/tools";
|
|
7
|
+
* for (const t of sandboxTools(sbx)) register(t.name, t.description, t.inputSchema, t.execute);
|
|
8
|
+
*
|
|
9
|
+
* The application creates the sandbox and binds the tools to it: the model
|
|
10
|
+
* chooses commands and paths, never the sandbox, the account or the key.
|
|
11
|
+
* Relative paths are under /workspace, and output is capped so one noisy
|
|
12
|
+
* command cannot flood the model's context. */
|
|
13
|
+
import { NotFoundError } from "../errors.js";
|
|
14
|
+
const WORKSPACE = "/workspace";
|
|
15
|
+
/** A POSIX path joined and normalized: `..` cannot climb past `/`. */
|
|
16
|
+
export function resolvePath(root, path) {
|
|
17
|
+
const joined = (path ?? ".").startsWith("/") ? path : `${root}/${path ?? "."}`;
|
|
18
|
+
const parts = [];
|
|
19
|
+
for (const part of joined.split("/")) {
|
|
20
|
+
if (!part || part === ".")
|
|
21
|
+
continue;
|
|
22
|
+
if (part === "..")
|
|
23
|
+
parts.pop();
|
|
24
|
+
else
|
|
25
|
+
parts.push(part);
|
|
26
|
+
}
|
|
27
|
+
return `/${parts.join("/")}`;
|
|
28
|
+
}
|
|
29
|
+
export function clip(text, limit) {
|
|
30
|
+
return text.length <= limit
|
|
31
|
+
? text
|
|
32
|
+
: `[${text.length - limit} earlier characters omitted]\n${text.slice(-limit)}`;
|
|
33
|
+
}
|
|
34
|
+
/** runtime_exec, runtime_read_file, runtime_write_file and runtime_list_files, bound to `sandbox`. */
|
|
35
|
+
export function sandboxTools(sandbox, options = {}) {
|
|
36
|
+
const root = resolvePath("/", options.root ?? WORKSPACE);
|
|
37
|
+
const timeoutSeconds = options.timeoutSeconds ?? 300;
|
|
38
|
+
const limit = options.maxOutputChars ?? 20_000;
|
|
39
|
+
const inFilesApi = (path) => path.startsWith(`${WORKSPACE}/`);
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
name: "runtime_exec",
|
|
43
|
+
description: "Run a shell command (bash) in the sandbox and return its exit code, stdout and stderr.",
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
command: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "The command line, run under `bash -c`, for example `pytest -q`.",
|
|
50
|
+
},
|
|
51
|
+
cwd: {
|
|
52
|
+
type: "string",
|
|
53
|
+
description: "Directory to run in. Relative paths are under /workspace, the default.",
|
|
54
|
+
},
|
|
55
|
+
timeoutSeconds: {
|
|
56
|
+
type: "integer",
|
|
57
|
+
minimum: 1,
|
|
58
|
+
description: "Seconds before the command is stopped and reported as timed out.",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
required: ["command"],
|
|
62
|
+
additionalProperties: false,
|
|
63
|
+
},
|
|
64
|
+
async execute({ command, cwd, timeoutSeconds: seconds }) {
|
|
65
|
+
const result = await sandbox.exec(command, {
|
|
66
|
+
cwd: resolvePath(root, cwd),
|
|
67
|
+
timeoutMs: (seconds ?? timeoutSeconds) * 1000,
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
exitCode: result.exitCode,
|
|
71
|
+
stdout: clip(result.stdout, limit),
|
|
72
|
+
stderr: clip(result.stderr, limit),
|
|
73
|
+
timedOut: result.timedOut,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "runtime_read_file",
|
|
79
|
+
description: "Read a text file from the sandbox.",
|
|
80
|
+
inputSchema: {
|
|
81
|
+
type: "object",
|
|
82
|
+
properties: {
|
|
83
|
+
path: {
|
|
84
|
+
type: "string",
|
|
85
|
+
description: "The file's path. Relative paths are under /workspace.",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
required: ["path"],
|
|
89
|
+
additionalProperties: false,
|
|
90
|
+
},
|
|
91
|
+
async execute({ path }) {
|
|
92
|
+
const target = resolvePath(root, path);
|
|
93
|
+
if (inFilesApi(target)) {
|
|
94
|
+
try {
|
|
95
|
+
return clip(await sandbox.files.readText(target), limit);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error instanceof NotFoundError)
|
|
99
|
+
return `No such file: ${target}`;
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const result = await sandbox.exec(["cat", "--", target]);
|
|
104
|
+
return clip(result.exitCode === 0 ? result.stdout : result.stderr, limit);
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "runtime_write_file",
|
|
109
|
+
description: "Create or replace a text file in the sandbox, making parent directories.",
|
|
110
|
+
inputSchema: {
|
|
111
|
+
type: "object",
|
|
112
|
+
properties: {
|
|
113
|
+
path: {
|
|
114
|
+
type: "string",
|
|
115
|
+
description: "The file's path. Relative paths are under /workspace.",
|
|
116
|
+
},
|
|
117
|
+
content: { type: "string", description: "The file's full new content." },
|
|
118
|
+
},
|
|
119
|
+
required: ["path", "content"],
|
|
120
|
+
additionalProperties: false,
|
|
121
|
+
},
|
|
122
|
+
async execute({ path, content }) {
|
|
123
|
+
const target = resolvePath(root, path);
|
|
124
|
+
if (inFilesApi(target))
|
|
125
|
+
await sandbox.files.write(target, content);
|
|
126
|
+
else {
|
|
127
|
+
const result = await sandbox.exec(["sh", "-c", 'mkdir -p -- "$(dirname -- "$1")" && cat > "$1"', "sh", target], { stdin: content });
|
|
128
|
+
if (result.exitCode !== 0)
|
|
129
|
+
return `Could not write ${target}: ${result.stderr.trim()}`;
|
|
130
|
+
}
|
|
131
|
+
return `Wrote ${new TextEncoder().encode(content).length} bytes to ${target}`;
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "runtime_list_files",
|
|
136
|
+
description: "List a directory in the sandbox: each entry's path, type and size.",
|
|
137
|
+
inputSchema: {
|
|
138
|
+
type: "object",
|
|
139
|
+
properties: {
|
|
140
|
+
path: {
|
|
141
|
+
type: "string",
|
|
142
|
+
description: "The directory. Relative paths are under /workspace, the default.",
|
|
143
|
+
},
|
|
144
|
+
depth: {
|
|
145
|
+
type: "integer",
|
|
146
|
+
minimum: 1,
|
|
147
|
+
description: "How many levels below it to include; 1 lists only its children.",
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
required: [],
|
|
151
|
+
additionalProperties: false,
|
|
152
|
+
},
|
|
153
|
+
async execute({ path, depth }) {
|
|
154
|
+
const levels = String(Math.max(1, Math.min(depth ?? 1, 10)));
|
|
155
|
+
const result = await sandbox.exec([
|
|
156
|
+
"sh",
|
|
157
|
+
"-c",
|
|
158
|
+
'find "$1" -mindepth 1 -maxdepth "$2" -printf "%y\\t%s\\t%p\\n" | sort -k3 | head -n 1000',
|
|
159
|
+
"sh",
|
|
160
|
+
resolvePath(root, path),
|
|
161
|
+
levels,
|
|
162
|
+
]);
|
|
163
|
+
if (result.exitCode !== 0 && !result.stdout)
|
|
164
|
+
return { error: result.stderr.trim() || `find exited with ${result.exitCode}` };
|
|
165
|
+
const kinds = {
|
|
166
|
+
f: "file",
|
|
167
|
+
d: "directory",
|
|
168
|
+
l: "symlink",
|
|
169
|
+
};
|
|
170
|
+
return result.stdout
|
|
171
|
+
.split("\n")
|
|
172
|
+
.filter(Boolean)
|
|
173
|
+
.map((line) => {
|
|
174
|
+
const [kind = "", size = "0", ...rest] = line.split("\t");
|
|
175
|
+
return { path: rest.join("\t"), type: kinds[kind] ?? "other", size: Number(size) || 0 };
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
}
|
package/dist/transport.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { RuntimeError } from "./errors.js";
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.5.1";
|
|
3
3
|
export declare const DEFAULT_BASE_URL = "https://api.withruntime.com";
|
|
4
4
|
export type RequestOptions = {
|
|
5
5
|
/** Sent as Idempotency-Key. Omit it: the SDK makes one per call and keeps
|
|
@@ -25,6 +25,11 @@ export type Call = RequestOptions & {
|
|
|
25
25
|
* reads and for writes (the key makes them safe); false only for calls
|
|
26
26
|
* the server cannot deduplicate. */
|
|
27
27
|
retry?: boolean;
|
|
28
|
+
/** How long to keep retrying, with the same key and input, a refusal that
|
|
29
|
+
* clears when a sandbox stops or a host frees room (WAITS_FOR_ROOM): a
|
|
30
|
+
* full trial, a full quota, a full region. Then the refusal is thrown as
|
|
31
|
+
* it came. Only a create sets it; 0 or absent fails at once. */
|
|
32
|
+
waitForCapacityMs?: number;
|
|
28
33
|
};
|
|
29
34
|
export declare function apiOrigin(value: string): string;
|
|
30
35
|
export declare function encodeQuery(query: Query | undefined): string;
|
|
@@ -39,6 +44,8 @@ export declare function encodeQuery(query: Query | undefined): string;
|
|
|
39
44
|
export declare class Transport {
|
|
40
45
|
#private;
|
|
41
46
|
readonly baseUrl: string;
|
|
47
|
+
/** What `sandboxes.create` waits for room by default. */
|
|
48
|
+
readonly waitForCapacityMs: number;
|
|
42
49
|
constructor(options: {
|
|
43
50
|
/** A key, or where to find one when the first call is made. */
|
|
44
51
|
apiKey: string | (() => Promise<string>);
|
|
@@ -49,10 +56,13 @@ export declare class Transport {
|
|
|
49
56
|
client?: string;
|
|
50
57
|
/** Calls read whole that may be in flight at once. Default 32. */
|
|
51
58
|
maxConnections?: number;
|
|
59
|
+
/** How long a create waits for room. Default 120 000; 0 fails at once. */
|
|
60
|
+
waitForCapacityMs?: number;
|
|
52
61
|
});
|
|
53
62
|
/** Sends a call and returns the raw Response once it succeeded. Retries
|
|
54
63
|
* transport failures, 429, 502, 503 and 504 with backoff and jitter, with
|
|
55
|
-
* the same key every time
|
|
64
|
+
* the same key every time, and waits out a full house for a call that sets
|
|
65
|
+
* waitForCapacityMs. */
|
|
56
66
|
send(call: Call): Promise<Response>;
|
|
57
67
|
json<T>(call: Call): Promise<T>;
|
|
58
68
|
bytes(call: Call): Promise<Uint8Array>;
|
package/dist/transport.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { ConnectionError, errorFor, RuntimeError } from "./errors.js";
|
|
1
|
+
import { ConnectionError, errorFor, RuntimeError, WAITS_FOR_ROOM } from "./errors.js";
|
|
2
2
|
import { describeRoute, envFetch, openWebSocket } from "./proxy.js";
|
|
3
|
-
export const VERSION = "0.
|
|
3
|
+
export const VERSION = "0.5.1";
|
|
4
4
|
export const DEFAULT_BASE_URL = "https://api.withruntime.com";
|
|
5
5
|
export function apiOrigin(value) {
|
|
6
6
|
const url = new URL(value);
|
|
@@ -90,6 +90,8 @@ export class Transport {
|
|
|
90
90
|
#maxRetries;
|
|
91
91
|
#client;
|
|
92
92
|
#slots;
|
|
93
|
+
/** What `sandboxes.create` waits for room by default. */
|
|
94
|
+
waitForCapacityMs;
|
|
93
95
|
constructor(options) {
|
|
94
96
|
if (typeof options.apiKey === "function")
|
|
95
97
|
this.#findKey = options.apiKey;
|
|
@@ -106,6 +108,7 @@ export class Transport {
|
|
|
106
108
|
this.#maxRetries = options.maxRetries ?? 4;
|
|
107
109
|
this.#client = options.client ?? `sdk-js/${VERSION}`;
|
|
108
110
|
this.#slots = slots(Math.max(1, options.maxConnections ?? 32));
|
|
111
|
+
this.waitForCapacityMs = Math.max(0, options.waitForCapacityMs ?? 120_000);
|
|
109
112
|
}
|
|
110
113
|
/** The key, found once on first use when it was not given. */
|
|
111
114
|
async #key() {
|
|
@@ -133,12 +136,18 @@ export class Transport {
|
|
|
133
136
|
}
|
|
134
137
|
/** Sends a call and returns the raw Response once it succeeded. Retries
|
|
135
138
|
* transport failures, 429, 502, 503 and 504 with backoff and jitter, with
|
|
136
|
-
* the same key every time
|
|
139
|
+
* the same key every time, and waits out a full house for a call that sets
|
|
140
|
+
* waitForCapacityMs. */
|
|
137
141
|
async send(call) {
|
|
138
142
|
const apiKey = await this.#key();
|
|
139
143
|
const write = call.method !== "GET";
|
|
140
144
|
const key = write ? (call.idempotencyKey ?? crypto.randomUUID()) : undefined;
|
|
141
|
-
|
|
145
|
+
// Time spent waiting for room is added to the call's deadline, not taken
|
|
146
|
+
// from it: once admitted, a create still has its whole timeoutMs.
|
|
147
|
+
const roomMs = Math.max(0, call.waitForCapacityMs ?? 0);
|
|
148
|
+
const roomUntil = performance.now() + roomMs;
|
|
149
|
+
let roomAttempt = 0;
|
|
150
|
+
const deadline = AbortSignal.timeout((call.timeoutMs ?? this.#timeoutMs) + roomMs);
|
|
142
151
|
const signal = call.signal ? AbortSignal.any([call.signal, deadline]) : deadline;
|
|
143
152
|
const url = `${this.baseUrl}${call.path}${encodeQuery(call.query)}`;
|
|
144
153
|
const body = call.bytes ?? (call.body === undefined ? undefined : JSON.stringify(call.body));
|
|
@@ -213,6 +222,19 @@ export class Transport {
|
|
|
213
222
|
parsed = { error: { message: text.slice(0, 500) || `HTTP ${response.status}` } };
|
|
214
223
|
}
|
|
215
224
|
const error = errorFor(response.status, parsed, key);
|
|
225
|
+
// A full house: wait for a slot or for room, then send the same call
|
|
226
|
+
// again. A request that can never fit (a fork of more copies than the
|
|
227
|
+
// trial runs) is not waited for.
|
|
228
|
+
if (retry &&
|
|
229
|
+
roomMs > 0 &&
|
|
230
|
+
WAITS_FOR_ROOM.has(error.code) &&
|
|
231
|
+
error.details?.field !== "count") {
|
|
232
|
+
const left = roomUntil - performance.now();
|
|
233
|
+
if (left <= 0)
|
|
234
|
+
throw error;
|
|
235
|
+
await sleep(Math.min(left, error.retryAfterMs ?? roomBackoff(roomAttempt++)), signal);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
216
238
|
const retryable = [429, 502, 503, 504].includes(response.status) && !DELIBERATE.has(error.code);
|
|
217
239
|
if (!retry || !retryable || attempt >= this.#maxRetries)
|
|
218
240
|
throw error;
|
|
@@ -292,3 +314,8 @@ export function missingKey() {
|
|
|
292
314
|
function backoff(attempt) {
|
|
293
315
|
return Math.min(8000, 250 * 2 ** attempt) * (0.5 + Math.random());
|
|
294
316
|
}
|
|
317
|
+
/** Between tries for room: 0.5 s, 1 s, 2 s, 4 s, then every 8 s, jittered, so
|
|
318
|
+
* a queue of CI jobs spreads out instead of knocking at once. */
|
|
319
|
+
function roomBackoff(attempt) {
|
|
320
|
+
return Math.min(8000, 500 * 2 ** attempt) * (0.75 + Math.random() * 0.5);
|
|
321
|
+
}
|
package/dist/tunnel.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { RuntimeError } from "./errors.js";
|
|
2
|
+
/** One TCP connection (or SSH login) inside a tunnel. Set the handlers before
|
|
3
|
+
* data can arrive, which is as soon as the promise that gave you this resolves. */
|
|
4
|
+
export declare class TunnelStream {
|
|
5
|
+
#private;
|
|
6
|
+
private readonly tunnel;
|
|
7
|
+
readonly id: number;
|
|
8
|
+
/** Bytes from the sandbox side. */
|
|
9
|
+
onData?: (data: Uint8Array) => void;
|
|
10
|
+
/** The sandbox side will send nothing more; you may still write. */
|
|
11
|
+
onEnd?: () => void;
|
|
12
|
+
/** The connection is gone. `reason` is "done" after a clean finish. */
|
|
13
|
+
onClose?: (reason: string) => void;
|
|
14
|
+
/** sshd's own error output, for an SSH login that fails. */
|
|
15
|
+
onLog?: (text: string) => void;
|
|
16
|
+
constructor(tunnel: Tunnel, id: number);
|
|
17
|
+
/** Sends bytes. Resolves once they are within the tunnel's window, so
|
|
18
|
+
* awaiting each write is enough backpressure. */
|
|
19
|
+
write(data: Uint8Array): Promise<void>;
|
|
20
|
+
/** No more bytes from this side; the sandbox side reads end of input. */
|
|
21
|
+
end(): void;
|
|
22
|
+
/** Drops the connection at once. After both sides have ended, the sandbox
|
|
23
|
+
* finishes it by itself, so a close then would only race the last bytes. */
|
|
24
|
+
close(): void;
|
|
25
|
+
/** @internal */
|
|
26
|
+
receive(kind: number, payload: Uint8Array): void;
|
|
27
|
+
/** @internal */
|
|
28
|
+
gone(reason: string): void;
|
|
29
|
+
}
|
|
30
|
+
/** A local port forwarded into the sandbox. */
|
|
31
|
+
export type PortForward = {
|
|
32
|
+
/** The local address and port that forward (the port the system chose, if you passed 0). */
|
|
33
|
+
readonly host: string;
|
|
34
|
+
readonly localPort: number;
|
|
35
|
+
/** The port inside the sandbox. */
|
|
36
|
+
readonly port: number;
|
|
37
|
+
/** Stops listening and closes every forwarded connection. */
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
};
|
|
40
|
+
export declare class Tunnel {
|
|
41
|
+
#private;
|
|
42
|
+
private readonly socket;
|
|
43
|
+
readonly closed: Promise<string>;
|
|
44
|
+
private constructor();
|
|
45
|
+
/** Waits for the server's ready message. */
|
|
46
|
+
static open(socket: WebSocket): Promise<Tunnel>;
|
|
47
|
+
/** A TCP connection to `port` on the sandbox's loopback (127.0.0.1, then ::1). */
|
|
48
|
+
connect(port: number): Promise<TunnelStream>;
|
|
49
|
+
/** An SSH login as the sandbox's user, authorized by this one public key
|
|
50
|
+
* (the text of an OpenSSH `.pub` file). Speak SSH over the stream. */
|
|
51
|
+
ssh(publicKey: string): Promise<TunnelStream>;
|
|
52
|
+
/** Listens on a local port and carries each connection to `port` in the
|
|
53
|
+
* sandbox. Node and Bun only. A connection the sandbox cannot open, such as
|
|
54
|
+
* one to a port nothing listens on, is closed at once; `onError` says why. */
|
|
55
|
+
forward(port: number, options?: {
|
|
56
|
+
localPort?: number;
|
|
57
|
+
host?: string;
|
|
58
|
+
onError?: (error: RuntimeError) => void;
|
|
59
|
+
}): Promise<PortForward>;
|
|
60
|
+
/** Closes the tunnel and every connection in it. */
|
|
61
|
+
close(): void;
|
|
62
|
+
/** @internal Data for one stream, within the window. */
|
|
63
|
+
send(id: number, data: Uint8Array): Promise<void>;
|
|
64
|
+
/** @internal End or close, in order behind any data still waiting. */
|
|
65
|
+
control(kind: number, id: number): Promise<void>;
|
|
66
|
+
/** @internal */
|
|
67
|
+
forget(id: number): void;
|
|
68
|
+
private receive;
|
|
69
|
+
private end;
|
|
70
|
+
}
|
package/dist/tunnel.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { RuntimeError } from "./errors.js";
|
|
2
|
+
/* A tunnel into a sandbox: one WebSocket carrying many TCP connections to
|
|
3
|
+
ports on the sandbox's loopback, and SSH logins. The server end and the
|
|
4
|
+
protocol are documented in packages/cloud/src/api/tunnels.ts. */
|
|
5
|
+
const OPEN = 0x6f;
|
|
6
|
+
const DATA = 0x64;
|
|
7
|
+
const END = 0x65;
|
|
8
|
+
const CLOSE = 0x63;
|
|
9
|
+
const ACK = 0x61;
|
|
10
|
+
const LOG = 0x6c;
|
|
11
|
+
/** Data this side may send past the sandbox's last report of what it took. */
|
|
12
|
+
const WINDOW = 1_048_576;
|
|
13
|
+
const CHUNK = 65_536;
|
|
14
|
+
function frame(kind, stream, payload = new Uint8Array()) {
|
|
15
|
+
const out = new Uint8Array(5 + payload.length);
|
|
16
|
+
out[0] = kind;
|
|
17
|
+
new DataView(out.buffer).setUint32(1, stream);
|
|
18
|
+
out.set(payload, 5);
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
/** One TCP connection (or SSH login) inside a tunnel. Set the handlers before
|
|
22
|
+
* data can arrive, which is as soon as the promise that gave you this resolves. */
|
|
23
|
+
export class TunnelStream {
|
|
24
|
+
tunnel;
|
|
25
|
+
id;
|
|
26
|
+
/** Bytes from the sandbox side. */
|
|
27
|
+
onData;
|
|
28
|
+
/** The sandbox side will send nothing more; you may still write. */
|
|
29
|
+
onEnd;
|
|
30
|
+
/** The connection is gone. `reason` is "done" after a clean finish. */
|
|
31
|
+
onClose;
|
|
32
|
+
/** sshd's own error output, for an SSH login that fails. */
|
|
33
|
+
onLog;
|
|
34
|
+
#ended = false;
|
|
35
|
+
#remoteEnded = false;
|
|
36
|
+
#closed = false;
|
|
37
|
+
constructor(tunnel, id) {
|
|
38
|
+
this.tunnel = tunnel;
|
|
39
|
+
this.id = id;
|
|
40
|
+
}
|
|
41
|
+
/** Sends bytes. Resolves once they are within the tunnel's window, so
|
|
42
|
+
* awaiting each write is enough backpressure. */
|
|
43
|
+
write(data) {
|
|
44
|
+
if (this.#ended || this.#closed)
|
|
45
|
+
return Promise.resolve();
|
|
46
|
+
return this.tunnel.send(this.id, data);
|
|
47
|
+
}
|
|
48
|
+
/** No more bytes from this side; the sandbox side reads end of input. */
|
|
49
|
+
end() {
|
|
50
|
+
if (this.#ended || this.#closed)
|
|
51
|
+
return;
|
|
52
|
+
this.#ended = true;
|
|
53
|
+
void this.tunnel.control(END, this.id);
|
|
54
|
+
}
|
|
55
|
+
/** Drops the connection at once. After both sides have ended, the sandbox
|
|
56
|
+
* finishes it by itself, so a close then would only race the last bytes. */
|
|
57
|
+
close() {
|
|
58
|
+
if (this.#closed)
|
|
59
|
+
return;
|
|
60
|
+
const finishing = this.#ended && this.#remoteEnded;
|
|
61
|
+
this.#closed = true;
|
|
62
|
+
if (!finishing)
|
|
63
|
+
void this.tunnel.control(CLOSE, this.id);
|
|
64
|
+
this.tunnel.forget(this.id);
|
|
65
|
+
}
|
|
66
|
+
/** @internal */
|
|
67
|
+
receive(kind, payload) {
|
|
68
|
+
if (kind === DATA)
|
|
69
|
+
this.onData?.(payload);
|
|
70
|
+
else if (kind === LOG)
|
|
71
|
+
this.onLog?.(new TextDecoder().decode(payload));
|
|
72
|
+
else if (kind === END) {
|
|
73
|
+
this.#remoteEnded = true;
|
|
74
|
+
this.onEnd?.();
|
|
75
|
+
}
|
|
76
|
+
else if (kind === CLOSE)
|
|
77
|
+
this.gone(new TextDecoder().decode(payload) || "closed");
|
|
78
|
+
}
|
|
79
|
+
/** @internal */
|
|
80
|
+
gone(reason) {
|
|
81
|
+
if (this.#closed)
|
|
82
|
+
return;
|
|
83
|
+
this.#closed = true;
|
|
84
|
+
this.tunnel.forget(this.id);
|
|
85
|
+
this.onClose?.(reason);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export class Tunnel {
|
|
89
|
+
socket;
|
|
90
|
+
closed;
|
|
91
|
+
#streams = new Map();
|
|
92
|
+
#opening = new Map();
|
|
93
|
+
#next = 1;
|
|
94
|
+
#sent = 0;
|
|
95
|
+
#credited = 0;
|
|
96
|
+
#waiting = [];
|
|
97
|
+
#sending = Promise.resolve();
|
|
98
|
+
#over;
|
|
99
|
+
constructor(socket, closed) {
|
|
100
|
+
this.socket = socket;
|
|
101
|
+
this.closed = closed;
|
|
102
|
+
}
|
|
103
|
+
/** Waits for the server's ready message. */
|
|
104
|
+
static open(socket) {
|
|
105
|
+
socket.binaryType = "arraybuffer";
|
|
106
|
+
let settle;
|
|
107
|
+
const closed = new Promise((resolve) => (settle = resolve));
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
let tunnel;
|
|
110
|
+
let failure;
|
|
111
|
+
socket.onmessage = (event) => {
|
|
112
|
+
if (typeof event.data === "string") {
|
|
113
|
+
const message = JSON.parse(event.data);
|
|
114
|
+
if (message.type === "ready" && !tunnel) {
|
|
115
|
+
tunnel = new Tunnel(socket, closed);
|
|
116
|
+
resolve(tunnel);
|
|
117
|
+
}
|
|
118
|
+
else if (message.type === "error") {
|
|
119
|
+
failure = new RuntimeError({
|
|
120
|
+
message: message.error?.message ?? "The tunnel failed.",
|
|
121
|
+
code: message.error?.code ?? "tunnel_failed",
|
|
122
|
+
status: 0,
|
|
123
|
+
...(message.error?.hint ? { hint: message.error.hint } : {}),
|
|
124
|
+
...(message.error?.requestId ? { requestId: message.error.requestId } : {}),
|
|
125
|
+
});
|
|
126
|
+
if (!tunnel)
|
|
127
|
+
reject(failure);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else
|
|
131
|
+
tunnel?.receive(new Uint8Array(event.data));
|
|
132
|
+
};
|
|
133
|
+
socket.onclose = () => {
|
|
134
|
+
const reason = failure?.message ?? "The tunnel closed.";
|
|
135
|
+
tunnel?.end(reason);
|
|
136
|
+
settle(reason);
|
|
137
|
+
if (!tunnel)
|
|
138
|
+
reject(new RuntimeError({
|
|
139
|
+
message: "The tunnel could not be opened: check the key, that the sandbox is running, and that your organization has fewer than 16 tunnels open.",
|
|
140
|
+
code: "tunnel_refused",
|
|
141
|
+
status: 0,
|
|
142
|
+
}));
|
|
143
|
+
};
|
|
144
|
+
socket.onerror = () => undefined;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** A TCP connection to `port` on the sandbox's loopback (127.0.0.1, then ::1). */
|
|
148
|
+
connect(port) {
|
|
149
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535)
|
|
150
|
+
return Promise.reject(new RuntimeError({
|
|
151
|
+
message: "port must be 1 to 65535.",
|
|
152
|
+
code: "invalid_request",
|
|
153
|
+
status: 0,
|
|
154
|
+
}));
|
|
155
|
+
return this.#open(`tcp ${port}`, port);
|
|
156
|
+
}
|
|
157
|
+
/** An SSH login as the sandbox's user, authorized by this one public key
|
|
158
|
+
* (the text of an OpenSSH `.pub` file). Speak SSH over the stream. */
|
|
159
|
+
ssh(publicKey) {
|
|
160
|
+
return this.#open(`ssh ${publicKey.trim()}`);
|
|
161
|
+
}
|
|
162
|
+
/** Listens on a local port and carries each connection to `port` in the
|
|
163
|
+
* sandbox. Node and Bun only. A connection the sandbox cannot open, such as
|
|
164
|
+
* one to a port nothing listens on, is closed at once; `onError` says why. */
|
|
165
|
+
async forward(port, options = {}) {
|
|
166
|
+
const net = await import("node:net");
|
|
167
|
+
const host = options.host ?? "127.0.0.1";
|
|
168
|
+
const sockets = new Set();
|
|
169
|
+
// Half-open: a client that ends its input still gets the answer.
|
|
170
|
+
const server = net.createServer({ allowHalfOpen: true }, (socket) => {
|
|
171
|
+
sockets.add(socket);
|
|
172
|
+
socket.pause();
|
|
173
|
+
socket.on("error", () => undefined);
|
|
174
|
+
socket.on("close", () => sockets.delete(socket));
|
|
175
|
+
this.connect(port).then((stream) => {
|
|
176
|
+
stream.onData = (data) => void socket.write(data);
|
|
177
|
+
stream.onEnd = () => void socket.end();
|
|
178
|
+
// "done" follows the last data; ending lets the socket flush it.
|
|
179
|
+
stream.onClose = (reason) => void (reason === "done" ? socket.end() : socket.destroy());
|
|
180
|
+
socket.on("data", (data) => {
|
|
181
|
+
socket.pause();
|
|
182
|
+
void stream.write(new Uint8Array(data)).then(() => socket.resume());
|
|
183
|
+
});
|
|
184
|
+
socket.on("end", () => stream.end());
|
|
185
|
+
socket.on("close", () => stream.close());
|
|
186
|
+
socket.resume();
|
|
187
|
+
}, (error) => {
|
|
188
|
+
socket.destroy();
|
|
189
|
+
if (error instanceof RuntimeError)
|
|
190
|
+
options.onError?.(error);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
await new Promise((resolve, reject) => {
|
|
194
|
+
server.once("error", reject);
|
|
195
|
+
server.listen(options.localPort ?? port, host, () => resolve());
|
|
196
|
+
});
|
|
197
|
+
const address = server.address();
|
|
198
|
+
return {
|
|
199
|
+
host,
|
|
200
|
+
localPort: typeof address === "object" && address ? address.port : (options.localPort ?? port),
|
|
201
|
+
port,
|
|
202
|
+
close: () => new Promise((resolve) => {
|
|
203
|
+
for (const socket of sockets)
|
|
204
|
+
socket.destroy();
|
|
205
|
+
server.close(() => resolve());
|
|
206
|
+
}),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/** Closes the tunnel and every connection in it. */
|
|
210
|
+
close() {
|
|
211
|
+
this.socket.close(1000);
|
|
212
|
+
}
|
|
213
|
+
#open(target, port) {
|
|
214
|
+
if (this.#over)
|
|
215
|
+
return Promise.reject(new RuntimeError({ message: this.#over, code: "tunnel_closed", status: 0 }));
|
|
216
|
+
const id = this.#next++;
|
|
217
|
+
return new Promise((resolve, reject) => {
|
|
218
|
+
this.#opening.set(id, {
|
|
219
|
+
resolve,
|
|
220
|
+
reject: (error) => reject(port !== undefined && error.message === "closed"
|
|
221
|
+
? new RuntimeError({
|
|
222
|
+
message: `Nothing is listening on port ${port} in the sandbox.`,
|
|
223
|
+
code: "port_closed",
|
|
224
|
+
status: 0,
|
|
225
|
+
hint: "Start the server with spawn (`runtime sandbox spawn <id> -- <command>`, sbx.spawn()), which keeps it running: a process started by exec ends with its command. Let it listen on 127.0.0.1, ::1 or every address.",
|
|
226
|
+
})
|
|
227
|
+
: error.message === "reserved"
|
|
228
|
+
? new RuntimeError({
|
|
229
|
+
message: `Port ${port} is the sandbox's own outbound proxy, which a tunnel does not reach.`,
|
|
230
|
+
code: "port_reserved",
|
|
231
|
+
status: 0,
|
|
232
|
+
})
|
|
233
|
+
: error.message === "no-sshd"
|
|
234
|
+
? new RuntimeError({
|
|
235
|
+
message: "The sandbox has no OpenSSH server (sshd).",
|
|
236
|
+
code: "sshd_missing",
|
|
237
|
+
status: 0,
|
|
238
|
+
hint: "Install it once: sudo apt-get install -y openssh-server",
|
|
239
|
+
})
|
|
240
|
+
: new RuntimeError({
|
|
241
|
+
message: `The sandbox refused the connection (${error.message}).`,
|
|
242
|
+
code: "tunnel_refused",
|
|
243
|
+
status: 0,
|
|
244
|
+
})),
|
|
245
|
+
});
|
|
246
|
+
this.#write(frame(OPEN, id, new TextEncoder().encode(target)));
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/** @internal Data for one stream, within the window. */
|
|
250
|
+
send(id, data) {
|
|
251
|
+
const sending = this.#sending.then(async () => {
|
|
252
|
+
for (let at = 0; at < data.length && !this.#over; at += CHUNK) {
|
|
253
|
+
const piece = data.subarray(at, at + CHUNK);
|
|
254
|
+
while (this.#sent + piece.length - this.#credited > WINDOW && !this.#over)
|
|
255
|
+
await new Promise((resolve) => this.#waiting.push(resolve));
|
|
256
|
+
if (this.#over)
|
|
257
|
+
return;
|
|
258
|
+
this.#sent += piece.length;
|
|
259
|
+
this.#write(frame(DATA, id, piece));
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
this.#sending = sending.catch(() => undefined);
|
|
263
|
+
return sending;
|
|
264
|
+
}
|
|
265
|
+
/** @internal End or close, in order behind any data still waiting. */
|
|
266
|
+
control(kind, id) {
|
|
267
|
+
const sending = this.#sending.then(() => this.#write(frame(kind, id)));
|
|
268
|
+
this.#sending = sending.catch(() => undefined);
|
|
269
|
+
return sending;
|
|
270
|
+
}
|
|
271
|
+
/** @internal */
|
|
272
|
+
forget(id) {
|
|
273
|
+
this.#streams.delete(id);
|
|
274
|
+
}
|
|
275
|
+
#write(bytes) {
|
|
276
|
+
if (!this.#over && this.socket.readyState === 1)
|
|
277
|
+
this.socket.send(bytes);
|
|
278
|
+
}
|
|
279
|
+
receive(message) {
|
|
280
|
+
if (message.length < 5)
|
|
281
|
+
return;
|
|
282
|
+
const kind = message[0];
|
|
283
|
+
const view = new DataView(message.buffer, message.byteOffset, message.length);
|
|
284
|
+
const id = view.getUint32(1);
|
|
285
|
+
const payload = message.subarray(5);
|
|
286
|
+
if (kind === ACK) {
|
|
287
|
+
this.#credited = Math.max(this.#credited, Number(view.getBigUint64(5)));
|
|
288
|
+
for (const wake of this.#waiting.splice(0))
|
|
289
|
+
wake();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const opening = this.#opening.get(id);
|
|
293
|
+
if (opening) {
|
|
294
|
+
this.#opening.delete(id);
|
|
295
|
+
if (kind === OPEN) {
|
|
296
|
+
const stream = new TunnelStream(this, id);
|
|
297
|
+
this.#streams.set(id, stream);
|
|
298
|
+
opening.resolve(stream);
|
|
299
|
+
}
|
|
300
|
+
else
|
|
301
|
+
opening.reject(new Error(new TextDecoder().decode(payload) || "closed"));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
this.#streams.get(id)?.receive(kind, payload);
|
|
305
|
+
}
|
|
306
|
+
end(reason) {
|
|
307
|
+
this.#over = reason;
|
|
308
|
+
for (const wake of this.#waiting.splice(0))
|
|
309
|
+
wake();
|
|
310
|
+
for (const { reject } of this.#opening.values())
|
|
311
|
+
reject(new Error(reason));
|
|
312
|
+
this.#opening.clear();
|
|
313
|
+
for (const stream of [...this.#streams.values()])
|
|
314
|
+
stream.gone(reason);
|
|
315
|
+
}
|
|
316
|
+
}
|