@sandboxrouter/sdk 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +224 -48
- package/dist/bench.js +4 -4
- package/dist/catalog.d.ts +159 -0
- package/dist/catalog.js +448 -15
- package/dist/check.d.ts +28 -0
- package/dist/check.js +185 -0
- package/dist/cli.js +148 -11
- package/dist/computers.d.ts +55 -13
- package/dist/computers.js +132 -6
- package/dist/conformance.d.ts +46 -0
- package/dist/conformance.js +65 -0
- package/dist/cost.d.ts +67 -0
- package/dist/cost.js +153 -0
- package/dist/gc.d.ts +29 -0
- package/dist/gc.js +54 -0
- package/dist/index.d.ts +28 -2
- package/dist/index.js +28 -2
- package/dist/leaderboard.d.ts +6 -0
- package/dist/leaderboard.js +5 -4
- package/dist/ledger.d.ts +38 -0
- package/dist/ledger.js +89 -0
- package/dist/providers/agent37.d.ts +40 -0
- package/dist/providers/agent37.js +224 -0
- package/dist/providers/agentcore.d.ts +71 -0
- package/dist/providers/agentcore.js +446 -0
- package/dist/providers/alibaba.d.ts +18 -0
- package/dist/providers/alibaba.js +106 -0
- package/dist/providers/azure.d.ts +43 -0
- package/dist/providers/azure.js +330 -0
- package/dist/providers/beam.d.ts +11 -0
- package/dist/providers/beam.js +179 -0
- package/dist/providers/blaxel.d.ts +3 -1
- package/dist/providers/blaxel.js +32 -22
- package/dist/providers/boat.js +8 -3
- package/dist/providers/cloudflare.js +8 -3
- package/dist/providers/codesandbox.d.ts +59 -0
- package/dist/providers/codesandbox.js +287 -0
- package/dist/providers/daytona.d.ts +25 -4
- package/dist/providers/daytona.js +449 -28
- package/dist/providers/dedalus.d.ts +12 -0
- package/dist/providers/dedalus.js +244 -0
- package/dist/providers/deno.d.ts +50 -0
- package/dist/providers/deno.js +170 -0
- package/dist/providers/docker.js +11 -5
- package/dist/providers/e2b.d.ts +70 -3
- package/dist/providers/e2b.js +376 -8
- package/dist/providers/exedev.js +17 -5
- package/dist/providers/freestyle.d.ts +49 -0
- package/dist/providers/freestyle.js +261 -0
- package/dist/providers/hopx.js +27 -18
- package/dist/providers/instabox.d.ts +76 -0
- package/dist/providers/instabox.js +360 -0
- package/dist/providers/koyeb.d.ts +71 -0
- package/dist/providers/koyeb.js +379 -0
- package/dist/providers/lambda.d.ts +51 -0
- package/dist/providers/lambda.js +467 -0
- package/dist/providers/langsmith.d.ts +16 -0
- package/dist/providers/langsmith.js +149 -0
- package/dist/providers/limrun.d.ts +77 -0
- package/dist/providers/limrun.js +626 -0
- package/dist/providers/maritime.d.ts +5 -1
- package/dist/providers/maritime.js +39 -8
- package/dist/providers/mobilerun.d.ts +44 -0
- package/dist/providers/mobilerun.js +417 -0
- package/dist/providers/modal.js +12 -4
- package/dist/providers/morph.js +12 -4
- package/dist/providers/northflank.js +11 -13
- package/dist/providers/novita.d.ts +12 -0
- package/dist/providers/novita.js +81 -0
- package/dist/providers/openscale.d.ts +17 -0
- package/dist/providers/openscale.js +87 -0
- package/dist/providers/orgo.d.ts +45 -0
- package/dist/providers/orgo.js +414 -0
- package/dist/providers/sail.d.ts +32 -0
- package/dist/providers/sail.js +372 -0
- package/dist/providers/superserve.d.ts +25 -0
- package/dist/providers/superserve.js +337 -0
- package/dist/providers/tensorlake.js +17 -14
- package/dist/providers/upstash.d.ts +30 -0
- package/dist/providers/upstash.js +258 -0
- package/dist/providers/vercel.js +14 -3
- package/dist/providers/workspaces.d.ts +60 -0
- package/dist/providers/workspaces.js +662 -0
- package/dist/router.d.ts +36 -5
- package/dist/router.js +149 -19
- package/dist/server.d.ts +1 -1
- package/dist/server.js +22 -3
- package/dist/types.d.ts +13 -5
- package/dist/util.d.ts +28 -1
- package/dist/util.js +56 -7
- package/package.json +80 -5
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { ProviderError } from "../types.js";
|
|
2
|
+
import { dirname, shq, toBytes } from "../util.js";
|
|
3
|
+
/**
|
|
4
|
+
* Dedalus Machines (Dedalus Labs, YC S25) over plain REST. Persistent Linux microVMs that sleep
|
|
5
|
+
* when idle and bill compute only while awake. The Machines API was in private beta when this was
|
|
6
|
+
* written: an org that has not been admitted gets a 403.
|
|
7
|
+
*
|
|
8
|
+
* https://dcs.dedaluslabs.ai, Authorization: Bearer <DEDALUS_API_KEY> (dsk_...):
|
|
9
|
+
* POST /v1/machines {vcpu, memory_mib, storage_gib, autosleep} -> {machine_id, phase, ...}
|
|
10
|
+
* GET /v1/machines/:id -> {machine_id, desired_state, status:{phase, reason, last_error}}
|
|
11
|
+
* POST /v1/machines/:id/sleep, POST /v1/machines/:id/wake, DELETE /v1/machines/:id
|
|
12
|
+
* POST /v1/machines/:id/executions {command:[argv], cwd, env, stdin, timeout_ms}
|
|
13
|
+
* -> {execution_id, status}
|
|
14
|
+
* GET /v1/machines/:id/executions/:eid -> {status, exit_code, signal, error_code, error_message}
|
|
15
|
+
* GET /v1/machines/:id/executions/:eid/events?cursor= -> {items:[{type, chunk, status, exit_code}], next_cursor}
|
|
16
|
+
* GET /v1/machines/:id/executions/:eid/output -> {stdout, stderr, stdout_truncated, stderr_truncated}
|
|
17
|
+
* Execution status: wake_in_progress, queued, running, succeeded, failed, cancelled, expired.
|
|
18
|
+
* Machine phase: accepted, placement_pending, starting, running, stopping, sleeping, destroying,
|
|
19
|
+
* destroyed, failed. Errors are {error_code, message, retryable} or problem+json {title, detail}.
|
|
20
|
+
* There is no file API, so files move through an execution's stdin and stdout as base64.
|
|
21
|
+
* There is no image choice: every machine is the same Linux base.
|
|
22
|
+
* Docs: https://docs.dedaluslabs.ai/dcs/dm/quickstart, https://docs.dedaluslabs.ai/dcs/dm/lifecycle,
|
|
23
|
+
* https://docs.dedaluslabs.ai/dcs/dm/executions, https://docs.dedaluslabs.ai/dcs/dm/pricing,
|
|
24
|
+
* https://docs.dedaluslabs.ai/dcs-openapi.json
|
|
25
|
+
*/
|
|
26
|
+
const ID = "dedalus";
|
|
27
|
+
const READY_TIMEOUT_MS = 5 * 60_000;
|
|
28
|
+
const POLL_MS = 250;
|
|
29
|
+
const TERMINAL = new Set(["succeeded", "failed", "cancelled", "expired"]);
|
|
30
|
+
/** The API returns at most this much of each stream inline from /output; longer output is flagged truncated. */
|
|
31
|
+
const OUTPUT_CAP = 64 * 1024;
|
|
32
|
+
/** Raw bytes per readFile chunk: base64 with line breaks (45 KiB -> about 61 KiB) stays under OUTPUT_CAP. */
|
|
33
|
+
const READ_CHUNK = 45 * 1024;
|
|
34
|
+
export class DedalusProvider {
|
|
35
|
+
apiKey;
|
|
36
|
+
baseUrl;
|
|
37
|
+
id = ID;
|
|
38
|
+
constructor(apiKey = process.env.DEDALUS_API_KEY, baseUrl = process.env.DEDALUS_MACHINES_URL ?? "https://dcs.dedaluslabs.ai") {
|
|
39
|
+
this.apiKey = apiKey;
|
|
40
|
+
this.baseUrl = baseUrl;
|
|
41
|
+
}
|
|
42
|
+
configured() {
|
|
43
|
+
return !!this.apiKey;
|
|
44
|
+
}
|
|
45
|
+
/** @internal */
|
|
46
|
+
async api(method, path, json) {
|
|
47
|
+
if (!this.apiKey)
|
|
48
|
+
throw new ProviderError(ID, "DEDALUS_API_KEY is not set");
|
|
49
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
50
|
+
method,
|
|
51
|
+
headers: { Authorization: `Bearer ${this.apiKey}`, ...(json !== undefined ? { "Content-Type": "application/json" } : {}) },
|
|
52
|
+
body: json !== undefined ? JSON.stringify(json) : undefined,
|
|
53
|
+
});
|
|
54
|
+
const text = await res.text();
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
let detail = text.slice(0, 300);
|
|
57
|
+
try {
|
|
58
|
+
const j = JSON.parse(text);
|
|
59
|
+
detail = j?.message ? `${j.error_code ? `${j.error_code}: ` : ""}${j.message}` : (j?.detail ?? j?.title ?? detail);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* plain-text body */
|
|
63
|
+
}
|
|
64
|
+
throw new ProviderError(ID, `${method} ${path.split("?")[0]} -> ${res.status} ${detail}`, res.status);
|
|
65
|
+
}
|
|
66
|
+
return (text ? JSON.parse(text) : {});
|
|
67
|
+
}
|
|
68
|
+
async create(spec) {
|
|
69
|
+
const minutes = spec.timeoutMinutes ?? 30;
|
|
70
|
+
const m = await this.api("POST", "/v1/machines", {
|
|
71
|
+
vcpu: spec.vcpu ?? 2,
|
|
72
|
+
memory_mib: Math.round((spec.memoryGb ?? 4) * 1024),
|
|
73
|
+
storage_gib: 10,
|
|
74
|
+
// Sleep (compute billing stops, disk kept) once idle for the requested lifetime.
|
|
75
|
+
autosleep: `${Math.max(60, Math.round(minutes * 60))}s`,
|
|
76
|
+
});
|
|
77
|
+
if (!m.machine_id)
|
|
78
|
+
throw new ProviderError(ID, "create returned no machine_id");
|
|
79
|
+
const box = new DedalusBox(this, m.machine_id, spec.env ?? {});
|
|
80
|
+
try {
|
|
81
|
+
await box.waitRunning();
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
await box.kill().catch(() => { });
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
return box;
|
|
88
|
+
}
|
|
89
|
+
async connect(nativeId) {
|
|
90
|
+
const box = new DedalusBox(this, nativeId, {});
|
|
91
|
+
await box.waitRunning();
|
|
92
|
+
return box;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
class DedalusBox {
|
|
96
|
+
p;
|
|
97
|
+
nativeId;
|
|
98
|
+
env;
|
|
99
|
+
constructor(p, nativeId, env) {
|
|
100
|
+
this.p = p;
|
|
101
|
+
this.nativeId = nativeId;
|
|
102
|
+
this.env = env;
|
|
103
|
+
}
|
|
104
|
+
get path() {
|
|
105
|
+
return `/v1/machines/${encodeURIComponent(this.nativeId)}`;
|
|
106
|
+
}
|
|
107
|
+
/** Poll until the machine runs, waking it if it sleeps. */
|
|
108
|
+
async waitRunning() {
|
|
109
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
110
|
+
let woke = false;
|
|
111
|
+
for (;;) {
|
|
112
|
+
const m = await this.p.api("GET", this.path);
|
|
113
|
+
const phase = m.status?.phase ?? m.phase ?? "";
|
|
114
|
+
if (phase === "running")
|
|
115
|
+
return;
|
|
116
|
+
if (phase === "failed" || phase === "destroyed" || phase === "destroying") {
|
|
117
|
+
const why = m.status?.last_error || m.status?.reason;
|
|
118
|
+
throw new ProviderError(ID, `machine ${this.nativeId} is ${phase}${why ? `: ${why}` : ""}`, phase === "failed" ? undefined : 404);
|
|
119
|
+
}
|
|
120
|
+
if (phase === "sleeping" && !woke) {
|
|
121
|
+
await this.p.api("POST", `${this.path}/wake`);
|
|
122
|
+
woke = true;
|
|
123
|
+
}
|
|
124
|
+
if (Date.now() > deadline)
|
|
125
|
+
throw new ProviderError(ID, `machine ${this.nativeId} not running after ${READY_TIMEOUT_MS / 1000}s (${phase || "unknown"})`);
|
|
126
|
+
await sleep(POLL_MS);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Run argv to completion, streaming stdout/stderr from the events feed. */
|
|
130
|
+
async run(command, opts) {
|
|
131
|
+
const t0 = Date.now();
|
|
132
|
+
const secs = opts.timeoutSeconds ?? 60;
|
|
133
|
+
const env = { ...this.env, ...opts.env };
|
|
134
|
+
const created = await this.p.api("POST", `${this.path}/executions`, {
|
|
135
|
+
command,
|
|
136
|
+
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
|
137
|
+
...(Object.keys(env).length ? { env } : {}),
|
|
138
|
+
...(opts.stdin !== undefined ? { stdin: opts.stdin } : {}),
|
|
139
|
+
timeout_ms: secs * 1000,
|
|
140
|
+
});
|
|
141
|
+
const eid = created.execution_id;
|
|
142
|
+
if (!eid)
|
|
143
|
+
throw new ProviderError(ID, "create execution returned no execution_id");
|
|
144
|
+
const ex = `${this.path}/executions/${encodeURIComponent(eid)}`;
|
|
145
|
+
// A wake can sit in front of the command, so allow extra time before giving up locally.
|
|
146
|
+
const deadline = t0 + secs * 1000 + 30_000;
|
|
147
|
+
let status = created.status ?? "";
|
|
148
|
+
const streaming = !!(opts.onStdout || opts.onStderr);
|
|
149
|
+
// Everything the events feed delivered so far, kept so output past the /output cap is still complete.
|
|
150
|
+
let eventsOut = "";
|
|
151
|
+
let eventsErr = "";
|
|
152
|
+
let cursor;
|
|
153
|
+
// A page may come back without next_cursor (it is optional); the next drain then re-reads the
|
|
154
|
+
// same page, so remember how many of its items were already consumed.
|
|
155
|
+
let consumedAtCursor = 0;
|
|
156
|
+
/** Read every events page from the cursor on, passing output chunks to the callbacks. */
|
|
157
|
+
const drain = async () => {
|
|
158
|
+
for (;;) {
|
|
159
|
+
const q = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
|
|
160
|
+
const page = await this.p.api("GET", `${ex}/events${q}`);
|
|
161
|
+
const items = (page.items ?? []).slice(consumedAtCursor);
|
|
162
|
+
for (const e of items) {
|
|
163
|
+
if (e.type === "stdout" && e.chunk)
|
|
164
|
+
(eventsOut += e.chunk), opts.onStdout?.(e.chunk);
|
|
165
|
+
else if (e.type === "stderr" && e.chunk)
|
|
166
|
+
(eventsErr += e.chunk), opts.onStderr?.(e.chunk);
|
|
167
|
+
else if (e.type === "lifecycle" && e.status)
|
|
168
|
+
status = e.status;
|
|
169
|
+
}
|
|
170
|
+
const next = page.next_cursor;
|
|
171
|
+
if (!next || next === cursor) {
|
|
172
|
+
consumedAtCursor += items.length;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
cursor = next;
|
|
176
|
+
consumedAtCursor = 0;
|
|
177
|
+
if (!items.length)
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
while (!TERMINAL.has(status)) {
|
|
182
|
+
if (Date.now() > deadline) {
|
|
183
|
+
await this.p.api("DELETE", ex).catch(() => { });
|
|
184
|
+
throw new ProviderError(ID, `command timed out after ${secs}s`);
|
|
185
|
+
}
|
|
186
|
+
await sleep(POLL_MS);
|
|
187
|
+
if (streaming)
|
|
188
|
+
await drain();
|
|
189
|
+
if (!TERMINAL.has(status))
|
|
190
|
+
status = (await this.p.api("GET", ex)).status ?? status;
|
|
191
|
+
}
|
|
192
|
+
const final = await this.p.api("GET", ex);
|
|
193
|
+
// Deliver anything written between the last events page and the end.
|
|
194
|
+
if (streaming)
|
|
195
|
+
await drain();
|
|
196
|
+
const durationMs = Date.now() - t0;
|
|
197
|
+
const why = `${final.error_code ?? ""} ${final.error_message ?? ""}`;
|
|
198
|
+
if (final.status === "expired" || /timeout|timed out|deadline/i.test(why) || (final.signal && durationMs >= secs * 1000)) {
|
|
199
|
+
throw new ProviderError(ID, `command timed out after ${secs}s`);
|
|
200
|
+
}
|
|
201
|
+
if (final.exit_code === undefined || final.exit_code === null) {
|
|
202
|
+
throw new ProviderError(ID, `command ${final.status ?? "ended"} without an exit code${why.trim() ? `: ${why.trim()}` : ""}`);
|
|
203
|
+
}
|
|
204
|
+
const out = await this.p.api("GET", `${ex}/output`);
|
|
205
|
+
if (!out.stdout_truncated && !out.stderr_truncated)
|
|
206
|
+
return { exitCode: final.exit_code, stdout: out.stdout ?? "", stderr: out.stderr ?? "", durationMs };
|
|
207
|
+
// /output cut a stream at OUTPUT_CAP: the events feed has all of it.
|
|
208
|
+
if (!streaming)
|
|
209
|
+
await drain();
|
|
210
|
+
return { exitCode: final.exit_code, stdout: eventsOut, stderr: eventsErr, durationMs };
|
|
211
|
+
}
|
|
212
|
+
exec(cmd, opts = {}) {
|
|
213
|
+
return this.run(["/bin/sh", "-c", cmd], opts);
|
|
214
|
+
}
|
|
215
|
+
async writeFile(path, content) {
|
|
216
|
+
const b64 = Buffer.from(toBytes(content)).toString("base64");
|
|
217
|
+
const r = await this.run(["/bin/sh", "-c", `mkdir -p ${shq(dirname(path))} && base64 -d > ${shq(path)}`], { stdin: b64, timeoutSeconds: 120 });
|
|
218
|
+
if (r.exitCode !== 0)
|
|
219
|
+
throw new ProviderError(ID, `write ${path}: ${r.stderr.trim() || `exit ${r.exitCode}`}`);
|
|
220
|
+
}
|
|
221
|
+
async readFile(path) {
|
|
222
|
+
const size = await this.run(["/bin/sh", "-c", `wc -c < ${shq(path)}`], {});
|
|
223
|
+
if (size.exitCode !== 0)
|
|
224
|
+
throw new ProviderError(ID, `read ${path}: ${size.stderr.trim() || "no such file"}`, 404);
|
|
225
|
+
const total = Number(size.stdout.trim());
|
|
226
|
+
const parts = [];
|
|
227
|
+
for (let off = 0; off < total; off += READ_CHUNK) {
|
|
228
|
+
const r = await this.run(["/bin/sh", "-c", `tail -c +${off + 1} ${shq(path)} | head -c ${READ_CHUNK} | base64`], { timeoutSeconds: 120 });
|
|
229
|
+
if (r.exitCode !== 0)
|
|
230
|
+
throw new ProviderError(ID, `read ${path}: ${r.stderr.trim() || `exit ${r.exitCode}`}`);
|
|
231
|
+
parts.push(Buffer.from(r.stdout.replace(/\s+/g, ""), "base64"));
|
|
232
|
+
}
|
|
233
|
+
return new Uint8Array(Buffer.concat(parts));
|
|
234
|
+
}
|
|
235
|
+
async pause() {
|
|
236
|
+
await this.p.api("POST", `${this.path}/sleep`);
|
|
237
|
+
}
|
|
238
|
+
async kill() {
|
|
239
|
+
await this.p.api("DELETE", `${this.path}?force=true`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function sleep(ms) {
|
|
243
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
244
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
export declare const DENO_VCPU = 2;
|
|
3
|
+
/** The parts of `@deno/sandbox` this adapter uses (local types: the package is optional). */
|
|
4
|
+
interface DenoChild {
|
|
5
|
+
stdout: ReadableStream<Uint8Array> | null;
|
|
6
|
+
stderr: ReadableStream<Uint8Array> | null;
|
|
7
|
+
status: Promise<{
|
|
8
|
+
code: number;
|
|
9
|
+
signal: string | null;
|
|
10
|
+
success: boolean;
|
|
11
|
+
}>;
|
|
12
|
+
kill(signal?: string): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
interface DenoSdkSandbox {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
spawn(command: string, options?: Record<string, unknown>): Promise<DenoChild>;
|
|
17
|
+
fs: {
|
|
18
|
+
writeFile(path: string, data: Uint8Array): Promise<void>;
|
|
19
|
+
readFile(path: string): Promise<Uint8Array>;
|
|
20
|
+
mkdir(path: string, options?: {
|
|
21
|
+
recursive?: boolean;
|
|
22
|
+
}): Promise<void>;
|
|
23
|
+
};
|
|
24
|
+
kill(): Promise<void>;
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export interface DenoSdk {
|
|
28
|
+
Sandbox: {
|
|
29
|
+
create(options?: Record<string, unknown>): Promise<DenoSdkSandbox>;
|
|
30
|
+
connect(options: Record<string, unknown> & {
|
|
31
|
+
id: string;
|
|
32
|
+
}): Promise<DenoSdkSandbox>;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export declare class DenoProvider implements Provider {
|
|
36
|
+
private token;
|
|
37
|
+
private org;
|
|
38
|
+
private region;
|
|
39
|
+
/** @internal Loads the SDK; tests pass a fake. */
|
|
40
|
+
private load;
|
|
41
|
+
readonly id: "deno";
|
|
42
|
+
constructor(token?: string | undefined, org?: string | undefined, region?: string | undefined,
|
|
43
|
+
/** @internal Loads the SDK; tests pass a fake. */
|
|
44
|
+
load?: () => Promise<DenoSdk>);
|
|
45
|
+
configured(): boolean;
|
|
46
|
+
private auth;
|
|
47
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
48
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
49
|
+
}
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { ProviderError } from "../types.js";
|
|
2
|
+
import { dirname, optionalImport, toBytes } from "../util.js";
|
|
3
|
+
/**
|
|
4
|
+
* Deno Sandbox (Linux microVMs on Deno Deploy) via the official `@deno/sandbox` SDK. The SDK is
|
|
5
|
+
* required: sandboxes are driven over an SDK-private WebSocket RPC protocol that Deno does not
|
|
6
|
+
* document, so there is no plain REST path. Optional peer dependency: `@deno/sandbox` (>=0.13 <0.14,
|
|
7
|
+
* a 0.x API; verified against 0.13.2).
|
|
8
|
+
* Auth: DENO_DEPLOY_TOKEN, an organization token (ddo_...), or a personal token (ddp_...) plus
|
|
9
|
+
* DENO_DEPLOY_ORG. Sandboxes need a paid Deploy plan (Pro or Builder).
|
|
10
|
+
* Fixed limits (https://docs.deno.com/sandbox/#limits): 2 vCPU, 768-4096 MB memory (default
|
|
11
|
+
* 1280), 10 GB disk, lifetime up to 30 minutes, regions `ord` and `ams`. Outbound network is open
|
|
12
|
+
* unless allowNet is set. image.deno is a snapshot or volume id/slug used as the root filesystem.
|
|
13
|
+
* The default timeout ("session") would end the VM when this process disconnects, so the adapter
|
|
14
|
+
* always passes a duration, which is what lets connect() reattach. exec streams stdout/stderr from
|
|
15
|
+
* a spawned `/bin/sh -c`. No pause.
|
|
16
|
+
* Docs: https://docs.deno.com/sandbox/, https://docs.deno.com/sandbox/create/,
|
|
17
|
+
* https://docs.deno.com/sandbox/timeouts/, https://docs.deno.com/sandbox/security/,
|
|
18
|
+
* https://deno.com/sandbox (pricing), https://jsr.io/@deno/sandbox
|
|
19
|
+
*/
|
|
20
|
+
const ID = "deno";
|
|
21
|
+
const MIN_MEMORY_MB = 768;
|
|
22
|
+
const MAX_MEMORY_MB = 4096;
|
|
23
|
+
const MAX_LIFETIME_MIN = 30;
|
|
24
|
+
export const DENO_VCPU = 2;
|
|
25
|
+
export class DenoProvider {
|
|
26
|
+
token;
|
|
27
|
+
org;
|
|
28
|
+
region;
|
|
29
|
+
load;
|
|
30
|
+
id = ID;
|
|
31
|
+
constructor(token = process.env.DENO_DEPLOY_TOKEN, org = process.env.DENO_DEPLOY_ORG, region = process.env.DENO_SANDBOX_REGION,
|
|
32
|
+
/** @internal Loads the SDK; tests pass a fake. */
|
|
33
|
+
load = () => optionalImport("@deno/sandbox", "deno")) {
|
|
34
|
+
this.token = token;
|
|
35
|
+
this.org = org;
|
|
36
|
+
this.region = region;
|
|
37
|
+
this.load = load;
|
|
38
|
+
}
|
|
39
|
+
configured() {
|
|
40
|
+
return !!this.token && (this.token.startsWith("ddo_") || !!this.org);
|
|
41
|
+
}
|
|
42
|
+
auth() {
|
|
43
|
+
if (!this.token)
|
|
44
|
+
throw new ProviderError(ID, "DENO_DEPLOY_TOKEN is not set");
|
|
45
|
+
if (!this.token.startsWith("ddo_") && !this.org)
|
|
46
|
+
throw new ProviderError(ID, "a personal DENO_DEPLOY_TOKEN needs DENO_DEPLOY_ORG");
|
|
47
|
+
return { token: this.token, ...(this.org ? { org: this.org } : {}) };
|
|
48
|
+
}
|
|
49
|
+
async create(spec) {
|
|
50
|
+
const vcpu = spec.vcpu ?? 2;
|
|
51
|
+
if (vcpu > DENO_VCPU)
|
|
52
|
+
throw new ProviderError(ID, `Deno Sandbox has ${DENO_VCPU} vCPU, not ${vcpu}`);
|
|
53
|
+
const memoryMb = Math.ceil((spec.memoryGb ?? 4) * 1024);
|
|
54
|
+
if (memoryMb > MAX_MEMORY_MB)
|
|
55
|
+
throw new ProviderError(ID, `Deno Sandbox memory is at most ${MAX_MEMORY_MB} MB, not ${memoryMb}`);
|
|
56
|
+
const minutes = Math.min(MAX_LIFETIME_MIN, Math.max(1, Math.ceil(spec.timeoutMinutes ?? 30)));
|
|
57
|
+
const options = {
|
|
58
|
+
...this.auth(),
|
|
59
|
+
memory: `${Math.max(MIN_MEMORY_MB, memoryMb)}MiB`,
|
|
60
|
+
timeout: `${minutes}m`,
|
|
61
|
+
};
|
|
62
|
+
if (this.region)
|
|
63
|
+
options.region = this.region;
|
|
64
|
+
if (spec.env)
|
|
65
|
+
options.env = spec.env;
|
|
66
|
+
if (spec.metadata)
|
|
67
|
+
options.labels = spec.metadata;
|
|
68
|
+
if (spec.image?.deno)
|
|
69
|
+
options.root = spec.image.deno;
|
|
70
|
+
const { Sandbox } = await this.load();
|
|
71
|
+
return new DenoBox(await sdk(() => Sandbox.create(options)));
|
|
72
|
+
}
|
|
73
|
+
async connect(nativeId) {
|
|
74
|
+
const { Sandbox } = await this.load();
|
|
75
|
+
return new DenoBox(await sdk(() => Sandbox.connect({ ...this.auth(), id: nativeId })));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
class DenoBox {
|
|
79
|
+
sbx;
|
|
80
|
+
nativeId;
|
|
81
|
+
constructor(sbx) {
|
|
82
|
+
this.sbx = sbx;
|
|
83
|
+
this.nativeId = sbx.id;
|
|
84
|
+
}
|
|
85
|
+
async exec(cmd, opts = {}) {
|
|
86
|
+
const t0 = Date.now();
|
|
87
|
+
const secs = opts.timeoutSeconds ?? 60;
|
|
88
|
+
const child = await sdk(() => this.sbx.spawn("/bin/sh", {
|
|
89
|
+
args: ["-c", cmd],
|
|
90
|
+
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
|
91
|
+
...(opts.env ? { env: opts.env } : {}),
|
|
92
|
+
stdin: "null",
|
|
93
|
+
stdout: "piped",
|
|
94
|
+
stderr: "piped",
|
|
95
|
+
}));
|
|
96
|
+
let timedOut = false;
|
|
97
|
+
let giveUp;
|
|
98
|
+
// Killing the shell can leave its children holding stdout open, so stop waiting for the
|
|
99
|
+
// streams shortly after the kill instead of hanging until those children exit.
|
|
100
|
+
const abandoned = new Promise((_, reject) => {
|
|
101
|
+
giveUp = setTimeout(() => reject(new ProviderError(ID, `command timed out after ${secs}s`)), secs * 1000 + 1000);
|
|
102
|
+
});
|
|
103
|
+
const timer = setTimeout(() => {
|
|
104
|
+
timedOut = true;
|
|
105
|
+
child.kill("SIGKILL").catch(() => { });
|
|
106
|
+
}, secs * 1000);
|
|
107
|
+
try {
|
|
108
|
+
const [stdout, stderr, status] = await Promise.race([
|
|
109
|
+
Promise.all([drain(child.stdout, opts.onStdout), drain(child.stderr, opts.onStderr), sdk(() => child.status)]),
|
|
110
|
+
abandoned,
|
|
111
|
+
]);
|
|
112
|
+
if (timedOut)
|
|
113
|
+
throw new ProviderError(ID, `command timed out after ${secs}s`);
|
|
114
|
+
// Deno reports a signal death as the signal's name plus a negative code (minus its number);
|
|
115
|
+
// shells report 128 + n, so callers see the same code they would from any other provider.
|
|
116
|
+
const exitCode = status.signal ? 128 + (SIGNALS[status.signal] ?? Math.max(0, -status.code)) : status.code;
|
|
117
|
+
return { exitCode, stdout, stderr, durationMs: Date.now() - t0 };
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
clearTimeout(giveUp);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async writeFile(path, content) {
|
|
125
|
+
const dir = dirname(path);
|
|
126
|
+
if (dir !== "/" && dir !== path)
|
|
127
|
+
await sdk(() => this.sbx.fs.mkdir(dir, { recursive: true }));
|
|
128
|
+
await sdk(() => this.sbx.fs.writeFile(path, toBytes(content)));
|
|
129
|
+
}
|
|
130
|
+
async readFile(path) {
|
|
131
|
+
return new Uint8Array(await sdk(() => this.sbx.fs.readFile(path)));
|
|
132
|
+
}
|
|
133
|
+
async kill() {
|
|
134
|
+
await sdk(() => this.sbx.kill());
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Read a byte stream to a string, passing each decoded chunk on as it arrives. */
|
|
138
|
+
async function drain(stream, on) {
|
|
139
|
+
if (!stream)
|
|
140
|
+
return "";
|
|
141
|
+
const reader = stream.getReader();
|
|
142
|
+
const decoder = new TextDecoder();
|
|
143
|
+
let out = "";
|
|
144
|
+
for (;;) {
|
|
145
|
+
const { done, value } = await reader.read();
|
|
146
|
+
const text = done ? decoder.decode() : decoder.decode(value, { stream: true });
|
|
147
|
+
if (text) {
|
|
148
|
+
out += text;
|
|
149
|
+
on?.(text);
|
|
150
|
+
}
|
|
151
|
+
if (done)
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Run an SDK call, turning its errors into ProviderErrors with an HTTP-like status where known. */
|
|
156
|
+
async function sdk(fn) {
|
|
157
|
+
try {
|
|
158
|
+
return await fn();
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
if (err instanceof ProviderError)
|
|
162
|
+
throw err;
|
|
163
|
+
const msg = err?.message ?? String(err);
|
|
164
|
+
let status = typeof err?.status === "number" ? err.status : undefined;
|
|
165
|
+
if (status === undefined && (err?.name === "NotFound" || err?.code === "ENOENT" || /not found|no such file/i.test(msg)))
|
|
166
|
+
status = 404;
|
|
167
|
+
throw new ProviderError(ID, `${err?.name && err.name !== "Error" ? `${err.name}: ` : ""}${msg}`, status);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const SIGNALS = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGABRT: 6, SIGKILL: 9, SIGSEGV: 11, SIGPIPE: 13, SIGALRM: 14, SIGTERM: 15 };
|
package/dist/providers/docker.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { DEFAULT_MEMORY_GB, DEFAULT_VCPU } from "../catalog.js";
|
|
4
4
|
import { ProviderError } from "../types.js";
|
|
5
|
-
import { dirname, shq, toBytes } from "../util.js";
|
|
5
|
+
import { DEFAULT_EXEC_TIMEOUT_S, dirname, guardTimeout, killedByTimeout, shq, timeoutError, toBytes } from "../util.js";
|
|
6
6
|
/**
|
|
7
7
|
* Local sandboxes in Docker containers, through the `docker` CLI. Free and needs no account, so it
|
|
8
8
|
* is good for development and tests. A container shares your machine's kernel: it is weaker
|
|
@@ -68,15 +68,19 @@ class DockerBox {
|
|
|
68
68
|
this.nativeId = nativeId;
|
|
69
69
|
}
|
|
70
70
|
exec(cmd, opts = {}) {
|
|
71
|
+
const seconds = opts.timeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_S;
|
|
71
72
|
const args = ["exec", "-i"];
|
|
72
73
|
if (opts.cwd)
|
|
73
74
|
args.push("-w", opts.cwd);
|
|
74
75
|
for (const [k, v] of Object.entries(opts.env ?? {}))
|
|
75
76
|
args.push("-e", `${k}=${v}`);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
// The command is killed inside the container: Docker does not stop an exec'd process when its
|
|
78
|
+
// client dies, so killing `docker exec` alone would leave it running. The client-side limit
|
|
79
|
+
// only covers a container that stops answering.
|
|
80
|
+
args.push(this.nativeId, "sh", "-c", guardTimeout(cmd, seconds));
|
|
81
|
+
return run(this.bin, args, { timeoutMs: (seconds + 10) * 1000, onStdout: opts.onStdout, onStderr: opts.onStderr }).then((r) => {
|
|
82
|
+
if (r.timedOut || killedByTimeout(r.code ?? -1, r.ms, seconds))
|
|
83
|
+
throw timeoutError("docker", seconds);
|
|
80
84
|
return { exitCode: r.code ?? -1, stdout: r.stdout, stderr: r.stderr, durationMs: r.ms };
|
|
81
85
|
});
|
|
82
86
|
}
|
|
@@ -140,6 +144,8 @@ function run(bin, args, opts = {}) {
|
|
|
140
144
|
clearTimeout(timer);
|
|
141
145
|
resolve({ code, stdout, stderr, bytes: opts.binary ? new Uint8Array(Buffer.concat(out)) : undefined, ms: Date.now() - t0, timedOut });
|
|
142
146
|
});
|
|
147
|
+
// A command that exits before reading its stdin makes the pipe raise EPIPE; the exit code already tells the story.
|
|
148
|
+
child.stdin.on("error", () => { });
|
|
143
149
|
if (opts.stdin)
|
|
144
150
|
child.stdin.end(Buffer.from(opts.stdin));
|
|
145
151
|
else
|
package/dist/providers/e2b.d.ts
CHANGED
|
@@ -1,10 +1,77 @@
|
|
|
1
|
-
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { type ExecOptions, type ExecResult, type Provider, type ProviderId, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
import type { ComputerOs, ComputerProvider, ComputerSpec, ProviderComputer } from "../computers.js";
|
|
3
|
+
/**
|
|
4
|
+
* E2B via the official `e2b` SDK. vCPU and memory come from the template, not the create call.
|
|
5
|
+
* Computers (Linux desktops) via `@e2b/desktop`: the public `desktop` template (Xvfb + XFCE),
|
|
6
|
+
* driven with xdotool and scrot inside the sandbox.
|
|
7
|
+
*/
|
|
8
|
+
export declare class E2BProvider implements Provider, ComputerProvider {
|
|
4
9
|
private apiKey;
|
|
5
10
|
readonly id: "e2b";
|
|
11
|
+
readonly computerOs: ComputerOs[];
|
|
6
12
|
constructor(apiKey?: string | undefined);
|
|
7
13
|
configured(): boolean;
|
|
8
14
|
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
9
15
|
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
16
|
+
/**
|
|
17
|
+
* A fresh desktop every call. E2B has no get-or-create by user, so `externalUserId` (and `name`)
|
|
18
|
+
* are only stored as sandbox metadata; keep the returned id to come back to the same desktop.
|
|
19
|
+
*/
|
|
20
|
+
createComputer(spec: ComputerSpec): Promise<ProviderComputer>;
|
|
21
|
+
/** Reattach by sandbox id. A paused (slept) desktop is resumed. */
|
|
22
|
+
connectComputer(nativeId: string): Promise<ProviderComputer>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The members of an `e2b` Sandbox that the shared box below uses. SDKs forked from E2B's
|
|
26
|
+
* (e.g. `novita-sandbox`) and E2B-API-compatible services reached through the `e2b` SDK have the
|
|
27
|
+
* same shape, so their adapters reuse these classes.
|
|
28
|
+
*/
|
|
29
|
+
export interface E2BLikeSandbox {
|
|
30
|
+
readonly sandboxId: string;
|
|
31
|
+
commands: {
|
|
32
|
+
/** Started in the background: returns a handle to wait on or kill. */
|
|
33
|
+
run(cmd: string, opts: {
|
|
34
|
+
cwd?: string;
|
|
35
|
+
envs?: Record<string, string>;
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
background: true;
|
|
38
|
+
onStdout?: (chunk: string) => void;
|
|
39
|
+
onStderr?: (chunk: string) => void;
|
|
40
|
+
}): Promise<E2BCommandHandle>;
|
|
41
|
+
};
|
|
42
|
+
files: {
|
|
43
|
+
write(path: string, data: Uint8Array): Promise<unknown>;
|
|
44
|
+
read(path: string, opts: {
|
|
45
|
+
format: "bytes";
|
|
46
|
+
}): Promise<Uint8Array>;
|
|
47
|
+
};
|
|
48
|
+
kill(): Promise<unknown>;
|
|
49
|
+
pause(): Promise<unknown>;
|
|
50
|
+
}
|
|
51
|
+
/** A background command (the SDK's CommandHandle). */
|
|
52
|
+
export interface E2BCommandHandle {
|
|
53
|
+
/** Resolves when the command exits; like the SDK, rejects with CommandExitError on a nonzero exit. */
|
|
54
|
+
wait(): Promise<{
|
|
55
|
+
exitCode: number;
|
|
56
|
+
stdout: string;
|
|
57
|
+
stderr: string;
|
|
58
|
+
}>;
|
|
59
|
+
/** SIGKILL the command inside the sandbox. */
|
|
60
|
+
kill(): Promise<boolean>;
|
|
61
|
+
}
|
|
62
|
+
/** exec/files/kill over an E2B-style SDK sandbox. `provider` labels errors. No pause. */
|
|
63
|
+
export declare class E2BCompatBox implements ProviderSandbox {
|
|
64
|
+
protected sbx: E2BLikeSandbox;
|
|
65
|
+
protected provider: ProviderId;
|
|
66
|
+
readonly nativeId: string;
|
|
67
|
+
constructor(sbx: E2BLikeSandbox, provider?: ProviderId);
|
|
68
|
+
exec(cmd: string, opts?: ExecOptions): Promise<ExecResult>;
|
|
69
|
+
writeFile(path: string, content: string | Uint8Array): Promise<void>;
|
|
70
|
+
readFile(path: string): Promise<Uint8Array>;
|
|
71
|
+
kill(): Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
/** E2BCompatBox plus pause, for services where pause/resume is generally available. */
|
|
74
|
+
export declare class E2BBox extends E2BCompatBox {
|
|
75
|
+
/** Snapshot memory and disk. Sandbox.connect (router.get) resumes it. */
|
|
76
|
+
pause(): Promise<void>;
|
|
10
77
|
}
|