withruntime 0.5.1 → 0.6.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/LICENSE +55 -0
- package/README.md +8 -4
- package/dist/ai-harness/index.d.ts +194 -0
- package/dist/ai-harness/index.js +504 -0
- package/dist/cli-extras.d.ts +24 -0
- package/dist/cli-extras.js +205 -0
- package/dist/cli.d.ts +20 -0
- package/dist/cli.js +403 -28
- package/dist/e2b/filesystem.d.ts +26 -1
- package/dist/e2b/filesystem.js +30 -2
- package/dist/e2b/index.d.ts +1 -1
- package/dist/e2b/index.js +1 -1
- package/dist/errors.js +2 -0
- package/dist/identity.d.ts +27 -0
- package/dist/identity.js +56 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +7 -0
- package/dist/mcp.js +16 -3
- package/dist/network-cli.d.ts +15 -0
- package/dist/network-cli.js +323 -0
- package/dist/products/desktop.d.ts +41 -4
- package/dist/products/desktop.js +73 -3
- package/dist/products/index.d.ts +12 -0
- package/dist/products/index.js +12 -0
- package/dist/products/interpreter.d.ts +5 -3
- package/dist/products/interpreter.js +2 -1
- package/dist/products/mcp.d.ts +79 -0
- package/dist/products/mcp.js +37 -0
- package/dist/products/mounts.d.ts +49 -0
- package/dist/products/mounts.js +21 -0
- package/dist/products/network-products.d.ts +167 -0
- package/dist/products/network-products.js +82 -0
- package/dist/products/network.d.ts +1 -1
- package/dist/products/sso.d.ts +44 -0
- package/dist/products/sso.js +8 -0
- package/dist/products/volumes.d.ts +68 -5
- package/dist/products/volumes.js +52 -4
- package/dist/products/watch.d.ts +91 -0
- package/dist/products/watch.js +135 -0
- package/dist/sandbox.d.ts +32 -2
- package/dist/sandbox.js +93 -16
- package/dist/snapshots.d.ts +11 -1
- package/dist/transport.d.ts +1 -1
- package/dist/transport.js +4 -1
- package/dist/wireguard.d.ts +7 -0
- package/dist/wireguard.js +13 -0
- package/package.json +49 -18
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/** Runtime Cloud sandboxes for the Vercel AI SDK's harnesses (`@ai-sdk/harness`).
|
|
2
|
+
*
|
|
3
|
+
* `createRuntimeSandbox()` is a `HarnessV1SandboxProvider`, like
|
|
4
|
+
* `@ai-sdk/sandbox-vercel`'s `createVercelSandbox()`: a `HarnessAgent` runs its
|
|
5
|
+
* harness (Claude Code, Codex, OpenCode, Pi, ...) in a Runtime microVM, and
|
|
6
|
+
* the agent definition does not change:
|
|
7
|
+
*
|
|
8
|
+
* import { HarnessAgent } from "@ai-sdk/harness/agent";
|
|
9
|
+
* import { claudeCode } from "@ai-sdk/harness-claude-code";
|
|
10
|
+
* import { createRuntimeSandbox } from "withruntime/ai-harness";
|
|
11
|
+
*
|
|
12
|
+
* const agent = new HarnessAgent({
|
|
13
|
+
* harness: claudeCode,
|
|
14
|
+
* sandbox: createRuntimeSandbox({ ports: [4000], create: { funding: "trial" } }),
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* Commands run under `bash -c` as the sandbox user, streamed. Exposed ports
|
|
18
|
+
* are Runtime previews, private by default: the endpoint carries the preview
|
|
19
|
+
* token. The key comes from RUNTIME_API_KEY or this machine's `runtime login`.
|
|
20
|
+
* Needs `@ai-sdk/harness` 1.x beside this package. */
|
|
21
|
+
import { HarnessCapabilityUnsupportedError, HarnessSandboxAuthenticationError, } from "@ai-sdk/harness";
|
|
22
|
+
import { Runtime } from "../client.js";
|
|
23
|
+
import { AuthenticationError, NotFoundError, PermissionDeniedError, RuntimeError, } from "../errors.js";
|
|
24
|
+
export const PROVIDER_ID = "runtime-sandbox";
|
|
25
|
+
/** The preview token's header. The edge also takes it as `runtime_preview_token` in the query. */
|
|
26
|
+
export const PREVIEW_TOKEN_HEADER = "x-runtime-preview-token";
|
|
27
|
+
const TOKEN_QUERY = "runtime_preview_token";
|
|
28
|
+
const SESSION_NAME_PREFIX = "ai-harness-";
|
|
29
|
+
const MAX_COMMAND_MS = 86_400_000;
|
|
30
|
+
const DEFAULT_COMMAND_MS = 3_600_000;
|
|
31
|
+
/** Exit codes for a command that ended without one, as coreutils `timeout` and SIGKILL give. */
|
|
32
|
+
const TIMED_OUT_EXIT = 124;
|
|
33
|
+
const KILLED_EXIT = 137;
|
|
34
|
+
/** A harness sandbox provider backed by Runtime Cloud. Construct it once, at
|
|
35
|
+
* module scope; nothing is created until a session is. */
|
|
36
|
+
export function createRuntimeSandbox(settings = {}) {
|
|
37
|
+
return new RuntimeSandboxProvider(settings);
|
|
38
|
+
}
|
|
39
|
+
/** The sandbox name a harness session id maps to: `ai-harness-<id>`, or a
|
|
40
|
+
* digest of the id when it would not make a valid name. */
|
|
41
|
+
export async function sessionSandboxName(sessionId) {
|
|
42
|
+
const name = `${SESSION_NAME_PREFIX}${sessionId}`;
|
|
43
|
+
if (/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(name))
|
|
44
|
+
return name;
|
|
45
|
+
const { createHash } = await import("node:crypto");
|
|
46
|
+
return `${SESSION_NAME_PREFIX}${createHash("sha256").update(sessionId).digest("hex").slice(0, 40)}`;
|
|
47
|
+
}
|
|
48
|
+
export class RuntimeSandboxProvider {
|
|
49
|
+
settings;
|
|
50
|
+
specificationVersion = "harness-sandbox-v1";
|
|
51
|
+
providerId = PROVIDER_ID;
|
|
52
|
+
#runtime;
|
|
53
|
+
constructor(settings = {}) {
|
|
54
|
+
this.settings = settings;
|
|
55
|
+
this.#runtime = settings.runtime;
|
|
56
|
+
}
|
|
57
|
+
get runtime() {
|
|
58
|
+
this.#runtime ??= new Runtime();
|
|
59
|
+
return this.#runtime;
|
|
60
|
+
}
|
|
61
|
+
/** A new sandbox (named after `sessionId` when given), or the wrapped one.
|
|
62
|
+
* `onFirstCreate` runs on a new sandbox before this returns; Runtime keeps
|
|
63
|
+
* no template for `identity`, so it runs for every new sandbox. */
|
|
64
|
+
createSession = async (options = {}) => {
|
|
65
|
+
options.abortSignal?.throwIfAborted();
|
|
66
|
+
if (this.settings.sandbox)
|
|
67
|
+
return this.#session(this.settings.sandbox, false, options.abortSignal);
|
|
68
|
+
const name = options.sessionId ? await sessionSandboxName(options.sessionId) : undefined;
|
|
69
|
+
const sandbox = await authenticated(() => this.runtime.sandboxes.create({ ...this.settings.create, ...(name ? { name } : {}) }, options.abortSignal ? { signal: options.abortSignal } : {}));
|
|
70
|
+
try {
|
|
71
|
+
const session = await this.#session(sandbox, true, options.abortSignal);
|
|
72
|
+
await options.onFirstCreate?.(session.restricted(), {
|
|
73
|
+
...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
|
|
74
|
+
});
|
|
75
|
+
return session;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
// A sandbox nobody holds would run until its lease ends.
|
|
79
|
+
await sandbox.stop({ wait: false }).catch(() => undefined);
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
/** The sandbox `createSession` made for `sessionId`, woken if it is paused.
|
|
84
|
+
* A stopped one cannot be resumed: its memory and disk are gone. */
|
|
85
|
+
resumeSession = async (options) => {
|
|
86
|
+
options.abortSignal?.throwIfAborted();
|
|
87
|
+
if (this.settings.sandbox)
|
|
88
|
+
return this.#session(this.settings.sandbox, false, options.abortSignal);
|
|
89
|
+
const name = await sessionSandboxName(options.sessionId);
|
|
90
|
+
const found = await authenticated(async () => {
|
|
91
|
+
const page = await this.runtime.sandboxes.list({ name, limit: 1 });
|
|
92
|
+
return page.data[0];
|
|
93
|
+
});
|
|
94
|
+
if (!found)
|
|
95
|
+
throw new NotFoundError({
|
|
96
|
+
message: `No live Runtime sandbox is named ${name}, so harness session ${options.sessionId} cannot be resumed.`,
|
|
97
|
+
code: "not_found",
|
|
98
|
+
status: 404,
|
|
99
|
+
hint: "A stopped sandbox cannot be resumed. Set pauseOnStop: true so session.stop() pauses it instead.",
|
|
100
|
+
});
|
|
101
|
+
const request = options.abortSignal ? { signal: options.abortSignal } : {};
|
|
102
|
+
if (found.state === "paused" || found.state === "pausing")
|
|
103
|
+
await found.wake(request);
|
|
104
|
+
else if (found.state !== "running")
|
|
105
|
+
await found.waitFor("running", request);
|
|
106
|
+
return this.#session(found, true, options.abortSignal);
|
|
107
|
+
};
|
|
108
|
+
async #session(sandbox, ownsLifecycle, abortSignal) {
|
|
109
|
+
const session = new RuntimeNetworkSandboxSession(sandbox, this.settings, ownsLifecycle);
|
|
110
|
+
// The harness composes each session's directory under the sandbox's own
|
|
111
|
+
// working directory, read from the sandbox rather than assumed.
|
|
112
|
+
const pwd = await session.run({ command: "pwd", ...(abortSignal ? { abortSignal } : {}) });
|
|
113
|
+
const cwd = pwd.stdout.trim();
|
|
114
|
+
if (pwd.exitCode === 0 && cwd.startsWith("/"))
|
|
115
|
+
session.defaultWorkingDirectory = cwd;
|
|
116
|
+
if (this.settings.ports?.length)
|
|
117
|
+
await session.setPorts(this.settings.ports, abortSignal ? { abortSignal } : {});
|
|
118
|
+
return session;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Files and processes in one Runtime sandbox: what `restricted()` hands to
|
|
122
|
+
* tools, with nothing that stops the sandbox or changes its network. */
|
|
123
|
+
export class RuntimeSandboxSession {
|
|
124
|
+
sandbox;
|
|
125
|
+
settings;
|
|
126
|
+
/** Where relative paths and commands without a `workingDirectory` start. */
|
|
127
|
+
defaultWorkingDirectory = "/workspace";
|
|
128
|
+
constructor(sandbox, settings = {}) {
|
|
129
|
+
this.sandbox = sandbox;
|
|
130
|
+
this.settings = settings;
|
|
131
|
+
}
|
|
132
|
+
get description() {
|
|
133
|
+
return [
|
|
134
|
+
`Runtime Cloud sandbox ${this.sandbox.id}: a Firecracker microVM with its own Linux kernel.`,
|
|
135
|
+
`Commands run under bash as the user "runtime", with passwordless sudo. HOME and the default working directory are ${this.defaultWorkingDirectory}.`,
|
|
136
|
+
"Files persist for the life of the sandbox.",
|
|
137
|
+
].join("\n");
|
|
138
|
+
}
|
|
139
|
+
#path(path) {
|
|
140
|
+
return resolvePosix(this.defaultWorkingDirectory, path);
|
|
141
|
+
}
|
|
142
|
+
#commandOptions(options) {
|
|
143
|
+
const env = { ...this.settings.env, ...options.env };
|
|
144
|
+
return {
|
|
145
|
+
// Without one, the sandbox's own working directory, which is what
|
|
146
|
+
// defaultWorkingDirectory was read from.
|
|
147
|
+
...(options.workingDirectory === undefined
|
|
148
|
+
? {}
|
|
149
|
+
: { cwd: this.#path(options.workingDirectory) }),
|
|
150
|
+
...(Object.keys(env).length ? { env } : {}),
|
|
151
|
+
timeoutMs: Math.min(MAX_COMMAND_MS, options.timeoutMs ?? this.settings.commandTimeoutMs ?? DEFAULT_COMMAND_MS),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** Runs `command` under `bash -c` and returns once it exits, with all of
|
|
155
|
+
* its output. A timeout exits 124, with a note on standard error. */
|
|
156
|
+
run = async (options) => {
|
|
157
|
+
const { abortSignal } = options;
|
|
158
|
+
abortSignal?.throwIfAborted();
|
|
159
|
+
const out = { stdout: "", stderr: "" };
|
|
160
|
+
let exit;
|
|
161
|
+
let processId;
|
|
162
|
+
try {
|
|
163
|
+
// Streamed, so no output is cut at the size a single answer holds.
|
|
164
|
+
for await (const event of this.sandbox.execStream(options.command, {
|
|
165
|
+
...this.#commandOptions(options),
|
|
166
|
+
...(options.stdin === undefined ? {} : { stdin: options.stdin }),
|
|
167
|
+
...(abortSignal ? { signal: abortSignal } : {}),
|
|
168
|
+
})) {
|
|
169
|
+
if (event.type === "start")
|
|
170
|
+
processId = event.processId;
|
|
171
|
+
else if (event.type === "stdout" || event.type === "stderr")
|
|
172
|
+
out[event.type] += event.data;
|
|
173
|
+
else if (event.type === "exit")
|
|
174
|
+
exit = event;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (abortSignal?.aborted) {
|
|
179
|
+
// Aborting the request does not end the command; end it too.
|
|
180
|
+
if (processId)
|
|
181
|
+
await this.#kill(processId);
|
|
182
|
+
throw abortSignal.reason ?? error;
|
|
183
|
+
}
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
if (abortSignal?.aborted)
|
|
187
|
+
throw abortSignal.reason;
|
|
188
|
+
const timedOut = exit?.timedOut ?? false;
|
|
189
|
+
return {
|
|
190
|
+
exitCode: exit?.exitCode ?? (timedOut ? TIMED_OUT_EXIT : KILLED_EXIT),
|
|
191
|
+
stdout: out.stdout,
|
|
192
|
+
stderr: timedOut
|
|
193
|
+
? `${out.stderr}${out.stderr && !out.stderr.endsWith("\n") ? "\n" : ""}Timed out: the command ran past its limit and was ended.\n`
|
|
194
|
+
: out.stderr,
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
/** Starts `command` and returns at once, with its output as streams. */
|
|
198
|
+
spawn = async (options) => {
|
|
199
|
+
const { abortSignal } = options;
|
|
200
|
+
abortSignal?.throwIfAborted();
|
|
201
|
+
if (options.stdin instanceof Uint8Array)
|
|
202
|
+
throw new RuntimeError({
|
|
203
|
+
message: "spawn takes standard input as text; use run for bytes.",
|
|
204
|
+
code: "invalid_request",
|
|
205
|
+
status: 0,
|
|
206
|
+
});
|
|
207
|
+
const { timeoutMs, ...command } = this.#commandOptions(options);
|
|
208
|
+
const process = await this.sandbox.spawn(options.command, {
|
|
209
|
+
...command,
|
|
210
|
+
// A spawned process is often a server: it runs until killed unless a limit is asked for.
|
|
211
|
+
...(options.timeoutMs === undefined ? {} : { timeoutMs }),
|
|
212
|
+
...(options.stdin === undefined ? {} : { stdin: options.stdin }),
|
|
213
|
+
...(abortSignal ? { signal: abortSignal } : {}),
|
|
214
|
+
});
|
|
215
|
+
return this.#process(process, abortSignal);
|
|
216
|
+
};
|
|
217
|
+
#process(process, abortSignal) {
|
|
218
|
+
const encoder = new TextEncoder();
|
|
219
|
+
const streams = {};
|
|
220
|
+
const stdout = new ReadableStream({ start: (c) => void (streams.stdout = c) });
|
|
221
|
+
const stderr = new ReadableStream({ start: (c) => void (streams.stderr = c) });
|
|
222
|
+
const stopReading = new AbortController();
|
|
223
|
+
const onAbort = () => {
|
|
224
|
+
stopReading.abort();
|
|
225
|
+
void this.#kill(process.id);
|
|
226
|
+
};
|
|
227
|
+
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
228
|
+
const drained = (async () => {
|
|
229
|
+
let exit;
|
|
230
|
+
try {
|
|
231
|
+
for await (const event of process.output({ signal: stopReading.signal })) {
|
|
232
|
+
if (event.type === "stdout" || event.type === "stderr")
|
|
233
|
+
streams[event.type].enqueue(encoder.encode(event.data));
|
|
234
|
+
else if (event.type === "exit")
|
|
235
|
+
exit = event;
|
|
236
|
+
}
|
|
237
|
+
streams.stdout.close();
|
|
238
|
+
streams.stderr.close();
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
streams.stdout.error(error);
|
|
242
|
+
streams.stderr.error(error);
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
abortSignal?.removeEventListener("abort", onAbort);
|
|
247
|
+
}
|
|
248
|
+
return exit;
|
|
249
|
+
})();
|
|
250
|
+
// Read or not, a failure is reported by wait(), never as an unhandled rejection.
|
|
251
|
+
drained.catch(() => undefined);
|
|
252
|
+
let killed = false;
|
|
253
|
+
return {
|
|
254
|
+
stdout,
|
|
255
|
+
stderr,
|
|
256
|
+
wait: async () => {
|
|
257
|
+
let exit;
|
|
258
|
+
try {
|
|
259
|
+
exit = await drained;
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
if (abortSignal?.aborted)
|
|
263
|
+
throw abortSignal.reason ?? error;
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
if (abortSignal?.aborted)
|
|
267
|
+
throw abortSignal.reason;
|
|
268
|
+
return {
|
|
269
|
+
exitCode: exit?.exitCode ?? (exit?.timedOut ? TIMED_OUT_EXIT : KILLED_EXIT),
|
|
270
|
+
};
|
|
271
|
+
},
|
|
272
|
+
kill: async () => {
|
|
273
|
+
if (killed)
|
|
274
|
+
return;
|
|
275
|
+
killed = true;
|
|
276
|
+
await this.#kill(process.id);
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/** Ends a process; one that has already exited is not an error. */
|
|
281
|
+
async #kill(processId) {
|
|
282
|
+
try {
|
|
283
|
+
const process = await this.sandbox.processes.get(processId);
|
|
284
|
+
if (process.info.state === "running")
|
|
285
|
+
await process.kill("SIGKILL");
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// Gone already, or the sandbox is: nothing is left to end.
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
readBinaryFile = async (options) => {
|
|
292
|
+
options.abortSignal?.throwIfAborted();
|
|
293
|
+
try {
|
|
294
|
+
return await this.sandbox.files.read(this.#path(options.path), options.abortSignal ? { signal: options.abortSignal } : {});
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
if (error instanceof NotFoundError)
|
|
298
|
+
return null;
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
readFile = async (options) => {
|
|
303
|
+
const bytes = await this.readBinaryFile(options);
|
|
304
|
+
if (!bytes)
|
|
305
|
+
return null;
|
|
306
|
+
return new ReadableStream({
|
|
307
|
+
start(controller) {
|
|
308
|
+
controller.enqueue(bytes);
|
|
309
|
+
controller.close();
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
};
|
|
313
|
+
readTextFile = async (options) => {
|
|
314
|
+
const bytes = await this.readBinaryFile(options);
|
|
315
|
+
if (!bytes)
|
|
316
|
+
return null;
|
|
317
|
+
const text = new TextDecoder(options.encoding ?? "utf-8").decode(bytes);
|
|
318
|
+
return lines(text, options.startLine, options.endLine);
|
|
319
|
+
};
|
|
320
|
+
/** Writes atomically, making parent directories. Past 1 MiB a file must be under /workspace. */
|
|
321
|
+
writeBinaryFile = async (options) => {
|
|
322
|
+
options.abortSignal?.throwIfAborted();
|
|
323
|
+
await this.sandbox.files.write(this.#path(options.path), options.content, options.abortSignal ? { signal: options.abortSignal } : {});
|
|
324
|
+
};
|
|
325
|
+
writeFile = async (options) => {
|
|
326
|
+
const content = new Uint8Array(await new Response(options.content).arrayBuffer());
|
|
327
|
+
await this.writeBinaryFile({ ...options, content });
|
|
328
|
+
};
|
|
329
|
+
writeTextFile = async (options) => {
|
|
330
|
+
const encoding = (options.encoding ?? "utf-8");
|
|
331
|
+
await this.writeBinaryFile({
|
|
332
|
+
...options,
|
|
333
|
+
content: new Uint8Array(Buffer.from(options.content, encoding)),
|
|
334
|
+
});
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
/** The session a harness keeps: files and processes, plus ports as previews,
|
|
338
|
+
* network rules, and stopping. */
|
|
339
|
+
export class RuntimeNetworkSandboxSession extends RuntimeSandboxSession {
|
|
340
|
+
settings;
|
|
341
|
+
ownsLifecycle;
|
|
342
|
+
#ports = [];
|
|
343
|
+
constructor(sandbox, settings, ownsLifecycle) {
|
|
344
|
+
super(sandbox, settings);
|
|
345
|
+
this.settings = settings;
|
|
346
|
+
this.ownsLifecycle = ownsLifecycle;
|
|
347
|
+
}
|
|
348
|
+
/** The sandbox's id: `resumeSession` finds it again by the name it was given. */
|
|
349
|
+
get id() {
|
|
350
|
+
return this.sandbox.id;
|
|
351
|
+
}
|
|
352
|
+
get ports() {
|
|
353
|
+
return this.#ports;
|
|
354
|
+
}
|
|
355
|
+
get description() {
|
|
356
|
+
const ports = this.#ports.length
|
|
357
|
+
? `\nPorts ${this.#ports.join(", ")} are shared at HTTPS preview addresses; listen on 0.0.0.0 or localhost.`
|
|
358
|
+
: "";
|
|
359
|
+
return `${super.description}${ports}`;
|
|
360
|
+
}
|
|
361
|
+
restricted = () => {
|
|
362
|
+
const session = new RuntimeSandboxSession(this.sandbox, this.settings);
|
|
363
|
+
session.defaultWorkingDirectory = this.defaultWorkingDirectory;
|
|
364
|
+
return session;
|
|
365
|
+
};
|
|
366
|
+
/** Where to reach `port`. A private preview's token is in the URL's query and
|
|
367
|
+
* in `headers`. For `ws` the URL has no token: the preview edge answers a GET
|
|
368
|
+
* whose query carries one with a redirect that sets a cookie, which a
|
|
369
|
+
* WebSocket client does not follow, so the header carries it. */
|
|
370
|
+
getPortEndpoint = async (options) => {
|
|
371
|
+
if (!this.#ports.includes(options.port))
|
|
372
|
+
throw new HarnessCapabilityUnsupportedError({
|
|
373
|
+
harnessId: PROVIDER_ID,
|
|
374
|
+
message: `Port ${options.port} is not exposed on this sandbox. Exposed ports: [${this.#ports.join(", ")}]. Pass it in createRuntimeSandbox({ ports }).`,
|
|
375
|
+
});
|
|
376
|
+
const preview = await this.#share(options.port);
|
|
377
|
+
const protocol = options.protocol ?? "https";
|
|
378
|
+
const url = new URL(preview.url);
|
|
379
|
+
url.protocol = protocol === "ws" ? "wss:" : "https:";
|
|
380
|
+
if (!preview.token)
|
|
381
|
+
return { url: url.toString() };
|
|
382
|
+
if (protocol !== "ws")
|
|
383
|
+
url.searchParams.set(TOKEN_QUERY, preview.token);
|
|
384
|
+
return { url: url.toString(), headers: { [PREVIEW_TOKEN_HEADER]: preview.token } };
|
|
385
|
+
};
|
|
386
|
+
/** @deprecated As the AI SDK's: use `getPortEndpoint`. The URL carries the token in its query. */
|
|
387
|
+
getPortUrl = async (options) => {
|
|
388
|
+
const endpoint = await this.getPortEndpoint({ ...options, protocol: "https" });
|
|
389
|
+
const url = new URL(endpoint.url);
|
|
390
|
+
if (options.protocol === "ws")
|
|
391
|
+
url.protocol = "wss:";
|
|
392
|
+
return url.toString();
|
|
393
|
+
};
|
|
394
|
+
/** Shares exactly `ports`: new ones become previews, dropped ones stop being shared. */
|
|
395
|
+
setPorts = async (ports, options = {}) => {
|
|
396
|
+
const wanted = [...new Set(ports)];
|
|
397
|
+
const request = options.abortSignal ? { signal: options.abortSignal } : {};
|
|
398
|
+
for (const port of this.#ports.filter((port) => !wanted.includes(port)))
|
|
399
|
+
await this.sandbox.previews.delete(port, request).catch((error) => {
|
|
400
|
+
if (!(error instanceof NotFoundError))
|
|
401
|
+
throw error;
|
|
402
|
+
});
|
|
403
|
+
for (const port of wanted.filter((port) => !this.#ports.includes(port)))
|
|
404
|
+
await this.#share(port, request);
|
|
405
|
+
this.#ports = wanted;
|
|
406
|
+
};
|
|
407
|
+
/** Creates the preview, or answers the existing one with a fresh token. */
|
|
408
|
+
#share(port, request = {}) {
|
|
409
|
+
return this.sandbox.previews.create(port, {
|
|
410
|
+
visibility: this.settings.previewVisibility ?? "private",
|
|
411
|
+
...(this.settings.previewTtlSeconds ? { ttlSeconds: this.settings.previewTtlSeconds } : {}),
|
|
412
|
+
}, request);
|
|
413
|
+
}
|
|
414
|
+
/** Outbound rules: `allow-all` is the public web, `deny-all` nothing, and
|
|
415
|
+
* `custom` only the listed hosts and ranges, with `deniedCIDRs` refused. */
|
|
416
|
+
setNetworkPolicy = async (policy) => {
|
|
417
|
+
await this.sandbox.network.set(networkRules(policy));
|
|
418
|
+
};
|
|
419
|
+
/** Stops the sandbox (or pauses it, with `pauseOnStop`). A wrapped sandbox is left alone. */
|
|
420
|
+
stop = async () => {
|
|
421
|
+
if (!this.ownsLifecycle)
|
|
422
|
+
return;
|
|
423
|
+
if (this.settings.pauseOnStop) {
|
|
424
|
+
try {
|
|
425
|
+
await this.sandbox.pause();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
// Stop it rather than leave it running.
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
await this.#stop();
|
|
433
|
+
};
|
|
434
|
+
/** Stops the sandbox, whatever `pauseOnStop` says: its files and memory are gone. */
|
|
435
|
+
destroy = async () => {
|
|
436
|
+
if (!this.ownsLifecycle)
|
|
437
|
+
return;
|
|
438
|
+
await this.#stop();
|
|
439
|
+
};
|
|
440
|
+
async #stop() {
|
|
441
|
+
try {
|
|
442
|
+
await this.sandbox.stop();
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
if (!(error instanceof NotFoundError))
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/** The harness's network policy as a Runtime sandbox's network rules. */
|
|
451
|
+
export function networkRules(policy) {
|
|
452
|
+
if (policy.mode === "allow-all")
|
|
453
|
+
return { internet: true };
|
|
454
|
+
if (policy.mode === "deny-all")
|
|
455
|
+
return { internet: false };
|
|
456
|
+
const allow = [...(policy.allowedHosts ?? []), ...(policy.allowedCIDRs ?? [])];
|
|
457
|
+
const deny = [...(policy.deniedCIDRs ?? [])];
|
|
458
|
+
return { internet: true, allow, ...(deny.length ? { deny } : {}) };
|
|
459
|
+
}
|
|
460
|
+
/** A missing or refused key as the harness's own error, so a framework can
|
|
461
|
+
* tell configuration from an outage. */
|
|
462
|
+
async function authenticated(operation) {
|
|
463
|
+
try {
|
|
464
|
+
return await operation();
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
if (error instanceof AuthenticationError ||
|
|
468
|
+
error instanceof PermissionDeniedError ||
|
|
469
|
+
(error instanceof RuntimeError && error.code === "missing_api_key"))
|
|
470
|
+
throw new HarnessSandboxAuthenticationError({
|
|
471
|
+
message: `Runtime authentication failed: ${error.message} Set RUNTIME_API_KEY, run \`npx -y withruntime login\`, or pass createRuntimeSandbox({ runtime: new Runtime({ apiKey }) }).`,
|
|
472
|
+
sandboxProviderId: PROVIDER_ID,
|
|
473
|
+
cause: error,
|
|
474
|
+
});
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/** Lines `start` to `end` (1-based, inclusive) of `text`; past the end is the
|
|
479
|
+
* end. The file's own line ending is kept, as the AI SDK's `extractLines`. */
|
|
480
|
+
function lines(text, start, end) {
|
|
481
|
+
if (start === undefined && end === undefined)
|
|
482
|
+
return text;
|
|
483
|
+
const eol = text.includes("\r\n")
|
|
484
|
+
? "\r\n"
|
|
485
|
+
: !text.includes("\n") && text.includes("\r")
|
|
486
|
+
? "\r"
|
|
487
|
+
: "\n";
|
|
488
|
+
const all = text.split(eol);
|
|
489
|
+
const from = Math.max(1, start ?? 1) - 1;
|
|
490
|
+
const to = Math.min(all.length, end ?? all.length);
|
|
491
|
+
return all.slice(from, to).join(eol);
|
|
492
|
+
}
|
|
493
|
+
function resolvePosix(base, path) {
|
|
494
|
+
const parts = [];
|
|
495
|
+
for (const part of `${path.startsWith("/") ? "" : `${base}/`}${path}`.split("/")) {
|
|
496
|
+
if (part === "" || part === ".")
|
|
497
|
+
continue;
|
|
498
|
+
if (part === "..")
|
|
499
|
+
parts.pop();
|
|
500
|
+
else
|
|
501
|
+
parts.push(part);
|
|
502
|
+
}
|
|
503
|
+
return `/${parts.join("/")}`;
|
|
504
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CliKit } from "./cli.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 function recordCommand(sbx: Sandbox, argv: string[], out: Out, kit: CliKit): Promise<number>;
|
|
9
|
+
export declare function watchCommand(sbx: Sandbox, argv: string[], out: Out, kit: CliKit): Promise<number>;
|
|
10
|
+
export declare function mcpCommand(sbx: Sandbox, argv: string[], out: Out, kit: CliKit): Promise<number>;
|
|
11
|
+
/** Standard input when it is piped or redirected from a file, read whole, up
|
|
12
|
+
* to the API's 1 MiB; undefined for a terminal. A pipe that says nothing for
|
|
13
|
+
* `quietMs` is taken as no input, so a script that leaves standard input open
|
|
14
|
+
* and silent never hangs a command. */
|
|
15
|
+
export declare function pipedStdin(options?: {
|
|
16
|
+
limit?: number;
|
|
17
|
+
quietMs?: number;
|
|
18
|
+
}): Promise<Uint8Array | undefined>;
|
|
19
|
+
/** What to say when a command's streamed output lost bytes, or nothing. */
|
|
20
|
+
export declare function lostOutput(result: {
|
|
21
|
+
stdoutTruncated?: boolean;
|
|
22
|
+
stderrTruncated?: boolean;
|
|
23
|
+
}, id: string): string | undefined;
|
|
24
|
+
export {};
|