@sandboxrouter/sdk 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +293 -2
- package/dist/bench-store.d.ts +12 -0
- package/dist/bench-store.js +23 -0
- package/dist/bench.d.ts +24 -0
- package/dist/bench.js +65 -0
- package/dist/catalog.d.ts +105 -0
- package/dist/catalog.js +320 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +195 -0
- package/dist/computers.d.ts +124 -0
- package/dist/computers.js +66 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -0
- package/dist/leaderboard.d.ts +39 -0
- package/dist/leaderboard.js +184 -0
- package/dist/providers/blaxel.d.ts +21 -0
- package/dist/providers/blaxel.js +152 -0
- package/dist/providers/boat.d.ts +25 -0
- package/dist/providers/boat.js +258 -0
- package/dist/providers/cloudflare.d.ts +30 -0
- package/dist/providers/cloudflare.js +147 -0
- package/dist/providers/daytona.d.ts +13 -0
- package/dist/providers/daytona.js +121 -0
- package/dist/providers/docker.d.ts +17 -0
- package/dist/providers/docker.js +148 -0
- package/dist/providers/e2b.d.ts +10 -0
- package/dist/providers/e2b.js +69 -0
- package/dist/providers/exedev.d.ts +23 -0
- package/dist/providers/exedev.js +278 -0
- package/dist/providers/hopx.d.ts +15 -0
- package/dist/providers/hopx.js +168 -0
- package/dist/providers/maritime.d.ts +38 -0
- package/dist/providers/maritime.js +240 -0
- package/dist/providers/modal.d.ts +10 -0
- package/dist/providers/modal.js +82 -0
- package/dist/providers/morph.d.ts +19 -0
- package/dist/providers/morph.js +181 -0
- package/dist/providers/northflank.d.ts +21 -0
- package/dist/providers/northflank.js +261 -0
- package/dist/providers/runloop.d.ts +16 -0
- package/dist/providers/runloop.js +145 -0
- package/dist/providers/sprites.d.ts +15 -0
- package/dist/providers/sprites.js +135 -0
- package/dist/providers/tensorlake.d.ts +14 -0
- package/dist/providers/tensorlake.js +192 -0
- package/dist/providers/vercel.d.ts +12 -0
- package/dist/providers/vercel.js +87 -0
- package/dist/router.d.ts +65 -0
- package/dist/router.js +199 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +257 -0
- package/dist/sse.d.ts +6 -0
- package/dist/sse.js +33 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.js +19 -0
- package/dist/util.d.ts +11 -0
- package/dist/util.js +37 -0
- package/package.json +127 -4
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { DEFAULT_MEMORY_GB, DEFAULT_VCPU } from "../catalog.js";
|
|
4
|
+
import { ProviderError } from "../types.js";
|
|
5
|
+
import { dirname, shq, toBytes } from "../util.js";
|
|
6
|
+
/**
|
|
7
|
+
* Local sandboxes in Docker containers, through the `docker` CLI. Free and needs no account, so it
|
|
8
|
+
* is good for development and tests. A container shares your machine's kernel: it is weaker
|
|
9
|
+
* isolation than a cloud micro-VM, so don't point untrusted code at it with secrets mounted.
|
|
10
|
+
* Never chosen automatically; route to it with { provider: "docker" }.
|
|
11
|
+
*/
|
|
12
|
+
export class DockerProvider {
|
|
13
|
+
bin;
|
|
14
|
+
id = "docker";
|
|
15
|
+
available;
|
|
16
|
+
constructor(bin = process.env.SANDBOXROUTER_DOCKER_BIN ?? "docker") {
|
|
17
|
+
this.bin = bin;
|
|
18
|
+
}
|
|
19
|
+
/** The docker CLI exists and its daemon answers. Cached for the life of the process. */
|
|
20
|
+
configured() {
|
|
21
|
+
if (this.available === undefined) {
|
|
22
|
+
const r = spawnSync(this.bin, ["info", "--format", "{{.ServerVersion}}"], { timeout: 4000, encoding: "utf8" });
|
|
23
|
+
this.available = r.status === 0 && !!r.stdout.trim();
|
|
24
|
+
}
|
|
25
|
+
return this.available;
|
|
26
|
+
}
|
|
27
|
+
async create(spec) {
|
|
28
|
+
const name = `sbr-${randomUUID().slice(0, 12)}`;
|
|
29
|
+
const lifetime = Math.max(60, Math.round((spec.timeoutMinutes ?? 30) * 60));
|
|
30
|
+
const args = [
|
|
31
|
+
"run",
|
|
32
|
+
"-d",
|
|
33
|
+
"--rm",
|
|
34
|
+
"--name",
|
|
35
|
+
name,
|
|
36
|
+
"--label",
|
|
37
|
+
"sandboxrouter=1",
|
|
38
|
+
"--cpus",
|
|
39
|
+
String(spec.vcpu ?? DEFAULT_VCPU),
|
|
40
|
+
"--memory",
|
|
41
|
+
`${Math.round((spec.memoryGb ?? DEFAULT_MEMORY_GB) * 1024)}m`,
|
|
42
|
+
];
|
|
43
|
+
for (const [k, v] of Object.entries(spec.env ?? {}))
|
|
44
|
+
args.push("-e", `${k}=${v}`);
|
|
45
|
+
for (const [k, v] of Object.entries(spec.metadata ?? {}))
|
|
46
|
+
args.push("--label", `${k}=${v}`);
|
|
47
|
+
// The container's main process is a timer: it exits at the lifetime and --rm removes it.
|
|
48
|
+
args.push(spec.image?.docker ?? "python:3.12-slim", "sleep", String(lifetime));
|
|
49
|
+
const r = await run(this.bin, args);
|
|
50
|
+
if (r.code !== 0)
|
|
51
|
+
throw new ProviderError("docker", `docker run failed: ${r.stderr.trim().slice(0, 300)}`);
|
|
52
|
+
return new DockerBox(this.bin, name);
|
|
53
|
+
}
|
|
54
|
+
async connect(nativeId) {
|
|
55
|
+
const r = await run(this.bin, ["inspect", "-f", "{{.State.Status}}", nativeId]);
|
|
56
|
+
if (r.code !== 0)
|
|
57
|
+
throw new ProviderError("docker", `no container ${nativeId}`, 404);
|
|
58
|
+
if (r.stdout.trim() === "paused")
|
|
59
|
+
await run(this.bin, ["unpause", nativeId]);
|
|
60
|
+
return new DockerBox(this.bin, nativeId);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
class DockerBox {
|
|
64
|
+
bin;
|
|
65
|
+
nativeId;
|
|
66
|
+
constructor(bin, nativeId) {
|
|
67
|
+
this.bin = bin;
|
|
68
|
+
this.nativeId = nativeId;
|
|
69
|
+
}
|
|
70
|
+
exec(cmd, opts = {}) {
|
|
71
|
+
const args = ["exec", "-i"];
|
|
72
|
+
if (opts.cwd)
|
|
73
|
+
args.push("-w", opts.cwd);
|
|
74
|
+
for (const [k, v] of Object.entries(opts.env ?? {}))
|
|
75
|
+
args.push("-e", `${k}=${v}`);
|
|
76
|
+
args.push(this.nativeId, "sh", "-c", cmd);
|
|
77
|
+
return run(this.bin, args, { timeoutMs: (opts.timeoutSeconds ?? 60) * 1000, onStdout: opts.onStdout, onStderr: opts.onStderr }).then((r) => {
|
|
78
|
+
if (r.timedOut)
|
|
79
|
+
throw new ProviderError("docker", `command timed out after ${opts.timeoutSeconds ?? 60}s`);
|
|
80
|
+
return { exitCode: r.code ?? -1, stdout: r.stdout, stderr: r.stderr, durationMs: r.ms };
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
async writeFile(path, content) {
|
|
84
|
+
const r = await run(this.bin, ["exec", "-i", this.nativeId, "sh", "-c", `mkdir -p ${shq(dirname(path))} && cat > ${shq(path)}`], {
|
|
85
|
+
stdin: toBytes(content),
|
|
86
|
+
});
|
|
87
|
+
if (r.code !== 0)
|
|
88
|
+
throw new ProviderError("docker", `write ${path} failed: ${r.stderr.trim().slice(0, 300)}`);
|
|
89
|
+
}
|
|
90
|
+
async readFile(path) {
|
|
91
|
+
const r = await run(this.bin, ["exec", this.nativeId, "cat", path], { binary: true });
|
|
92
|
+
if (r.code !== 0)
|
|
93
|
+
throw new ProviderError("docker", `read ${path} failed: ${r.stderr.trim().slice(0, 300)}`, 404);
|
|
94
|
+
return r.bytes;
|
|
95
|
+
}
|
|
96
|
+
async pause() {
|
|
97
|
+
const r = await run(this.bin, ["pause", this.nativeId]);
|
|
98
|
+
if (r.code !== 0)
|
|
99
|
+
throw new ProviderError("docker", `pause failed: ${r.stderr.trim()}`);
|
|
100
|
+
}
|
|
101
|
+
async kill() {
|
|
102
|
+
await run(this.bin, ["rm", "-f", this.nativeId]);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function run(bin, args, opts = {}) {
|
|
106
|
+
const t0 = Date.now();
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
109
|
+
const out = [];
|
|
110
|
+
let stdout = "";
|
|
111
|
+
let stderr = "";
|
|
112
|
+
let timedOut = false;
|
|
113
|
+
const dec = new TextDecoder();
|
|
114
|
+
const decErr = new TextDecoder();
|
|
115
|
+
child.stdout.on("data", (b) => {
|
|
116
|
+
if (opts.binary)
|
|
117
|
+
return void out.push(b);
|
|
118
|
+
const c = dec.decode(b, { stream: true });
|
|
119
|
+
stdout += c;
|
|
120
|
+
if (c)
|
|
121
|
+
opts.onStdout?.(c);
|
|
122
|
+
});
|
|
123
|
+
child.stderr.on("data", (b) => {
|
|
124
|
+
const c = decErr.decode(b, { stream: true });
|
|
125
|
+
stderr += c;
|
|
126
|
+
if (c)
|
|
127
|
+
opts.onStderr?.(c);
|
|
128
|
+
});
|
|
129
|
+
const timer = opts.timeoutMs
|
|
130
|
+
? setTimeout(() => {
|
|
131
|
+
timedOut = true;
|
|
132
|
+
child.kill("SIGKILL");
|
|
133
|
+
}, opts.timeoutMs)
|
|
134
|
+
: undefined;
|
|
135
|
+
child.on("error", (e) => {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
reject(new ProviderError("docker", `could not run ${bin}: ${e.message}`));
|
|
138
|
+
});
|
|
139
|
+
child.on("close", (code) => {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
resolve({ code, stdout, stderr, bytes: opts.binary ? new Uint8Array(Buffer.concat(out)) : undefined, ms: Date.now() - t0, timedOut });
|
|
142
|
+
});
|
|
143
|
+
if (opts.stdin)
|
|
144
|
+
child.stdin.end(Buffer.from(opts.stdin));
|
|
145
|
+
else
|
|
146
|
+
child.stdin.end();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
/** E2B via the official `e2b` SDK. vCPU and memory come from the template, not the create call. */
|
|
3
|
+
export declare class E2BProvider implements Provider {
|
|
4
|
+
private apiKey;
|
|
5
|
+
readonly id: "e2b";
|
|
6
|
+
constructor(apiKey?: string | undefined);
|
|
7
|
+
configured(): boolean;
|
|
8
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
9
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ProviderError } from "../types.js";
|
|
2
|
+
import { optionalImport, toBytes } from "../util.js";
|
|
3
|
+
/** E2B via the official `e2b` SDK. vCPU and memory come from the template, not the create call. */
|
|
4
|
+
export class E2BProvider {
|
|
5
|
+
apiKey;
|
|
6
|
+
id = "e2b";
|
|
7
|
+
constructor(apiKey = process.env.E2B_API_KEY) {
|
|
8
|
+
this.apiKey = apiKey;
|
|
9
|
+
}
|
|
10
|
+
configured() {
|
|
11
|
+
return !!this.apiKey;
|
|
12
|
+
}
|
|
13
|
+
async create(spec) {
|
|
14
|
+
const { Sandbox } = await optionalImport("e2b", "e2b");
|
|
15
|
+
const opts = {
|
|
16
|
+
apiKey: this.apiKey,
|
|
17
|
+
timeoutMs: (spec.timeoutMinutes ?? 30) * 60_000,
|
|
18
|
+
envs: spec.env,
|
|
19
|
+
metadata: spec.metadata,
|
|
20
|
+
};
|
|
21
|
+
const sbx = spec.image?.e2b ? await Sandbox.create(spec.image.e2b, opts) : await Sandbox.create(opts);
|
|
22
|
+
return new E2BBox(sbx);
|
|
23
|
+
}
|
|
24
|
+
async connect(nativeId) {
|
|
25
|
+
const { Sandbox } = await optionalImport("e2b", "e2b");
|
|
26
|
+
return new E2BBox(await Sandbox.connect(nativeId, { apiKey: this.apiKey }));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
class E2BBox {
|
|
30
|
+
sbx;
|
|
31
|
+
nativeId;
|
|
32
|
+
constructor(sbx) {
|
|
33
|
+
this.sbx = sbx;
|
|
34
|
+
this.nativeId = sbx.sandboxId;
|
|
35
|
+
}
|
|
36
|
+
async exec(cmd, opts = {}) {
|
|
37
|
+
const t0 = Date.now();
|
|
38
|
+
try {
|
|
39
|
+
const r = await this.sbx.commands.run(cmd, {
|
|
40
|
+
cwd: opts.cwd,
|
|
41
|
+
envs: opts.env,
|
|
42
|
+
timeoutMs: (opts.timeoutSeconds ?? 60) * 1000,
|
|
43
|
+
onStdout: opts.onStdout,
|
|
44
|
+
onStderr: opts.onStderr,
|
|
45
|
+
});
|
|
46
|
+
return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, durationMs: Date.now() - t0 };
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
// The SDK throws CommandExitError for a nonzero exit; that's a result, not a failure.
|
|
50
|
+
if (err?.name === "CommandExitError" || typeof err?.exitCode === "number") {
|
|
51
|
+
return { exitCode: err.exitCode, stdout: err.stdout ?? "", stderr: err.stderr ?? "", durationMs: Date.now() - t0 };
|
|
52
|
+
}
|
|
53
|
+
throw new ProviderError("e2b", err?.message ?? String(err));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async writeFile(path, content) {
|
|
57
|
+
await this.sbx.files.write(path, toBytes(content));
|
|
58
|
+
}
|
|
59
|
+
async readFile(path) {
|
|
60
|
+
return await this.sbx.files.read(path, { format: "bytes" });
|
|
61
|
+
}
|
|
62
|
+
/** Snapshot memory and disk. Sandbox.connect (router.get) resumes it. */
|
|
63
|
+
async pause() {
|
|
64
|
+
await this.sbx.pause();
|
|
65
|
+
}
|
|
66
|
+
async kill() {
|
|
67
|
+
await this.sbx.kill();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
export declare class ExeDevProvider implements Provider {
|
|
3
|
+
private token;
|
|
4
|
+
private baseUrl;
|
|
5
|
+
readonly id: "exedev";
|
|
6
|
+
constructor(token?: string | undefined, baseUrl?: string);
|
|
7
|
+
configured(): boolean;
|
|
8
|
+
/** @internal POST /exec with a command line. */
|
|
9
|
+
request(command: string, accept?: string): Promise<Response>;
|
|
10
|
+
/** @internal Lobby command with JSON output. */
|
|
11
|
+
lobby<T>(command: string): Promise<T>;
|
|
12
|
+
/**
|
|
13
|
+
* @internal Run a shell script on the VM. The script is base64-encoded so it survives both quoting
|
|
14
|
+
* layers (the lobby's shell lexer, then the VM's shell) untouched.
|
|
15
|
+
*/
|
|
16
|
+
ssh(vm: string, script: string): Promise<{
|
|
17
|
+
exitCode: number;
|
|
18
|
+
stdout: Uint8Array;
|
|
19
|
+
stderr: Uint8Array;
|
|
20
|
+
}>;
|
|
21
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
22
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { ProviderError } from "../types.js";
|
|
3
|
+
import { dirname, shq, toBytes, wrapCommand } from "../util.js";
|
|
4
|
+
/**
|
|
5
|
+
* exe.dev standalone VMs over the HTTPS API (no SDK needed).
|
|
6
|
+
* Everything is one endpoint: POST https://exe.dev/exec, Authorization: Bearer exe1..., body = the
|
|
7
|
+
* command you would type at `ssh exe.dev`. Lifecycle commands answer JSON; `ssh <vm> <cmd>` runs a
|
|
8
|
+
* command in the VM and, with Accept: application/x-ndjson, answers {"stdout":b64} / {"stderr":b64}
|
|
9
|
+
* lines then {"exit_code":N} (or {"error":...}).
|
|
10
|
+
* new --standalone --name=N --cpu=C --memory=MGB [--image=I] --json create (metered per second)
|
|
11
|
+
* ls --json list ({vms:[{vm_name,status,...}]})
|
|
12
|
+
* ssh <vm> '<cmd>' run a command
|
|
13
|
+
* rm <vm> delete; billing stops
|
|
14
|
+
* Limits of /exec: 30 s per request (504), 64 KB request body, no stdin, no pty. So exec runs the
|
|
15
|
+
* command detached on the VM (setsid nohup, as the docs suggest) and polls its output files, and
|
|
16
|
+
* files move as base64 inside commands, in chunks.
|
|
17
|
+
* The token must allow `new`, `ls`, `ssh` and `rm` (`ssh` and `rm` are not in the default cmds):
|
|
18
|
+
* ssh exe.dev ssh-key generate-api-key --cmds=new,ls,ssh,rm --exp=30d
|
|
19
|
+
* Docs: https://exe.dev/docs/https-api.md, https://exe.dev/docs/https-api-run-on-vm.md,
|
|
20
|
+
* https://exe.dev/docs/standalone-vms.md, https://exe.dev/docs/api.md
|
|
21
|
+
*/
|
|
22
|
+
const ID = "exedev";
|
|
23
|
+
/** Leave headroom under the 64 KB body cap. */
|
|
24
|
+
const MAX_BODY = 60_000;
|
|
25
|
+
/** Raw bytes per writeFile chunk: base64 twice (4/3 * 4/3) plus the script stays under MAX_BODY. */
|
|
26
|
+
const WRITE_CHUNK = 30_000;
|
|
27
|
+
const VM_NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
28
|
+
export class ExeDevProvider {
|
|
29
|
+
token;
|
|
30
|
+
baseUrl;
|
|
31
|
+
id = ID;
|
|
32
|
+
constructor(token = process.env.EXE_TOKEN, baseUrl = process.env.EXE_API_URL ?? "https://exe.dev") {
|
|
33
|
+
this.token = token;
|
|
34
|
+
this.baseUrl = baseUrl;
|
|
35
|
+
}
|
|
36
|
+
configured() {
|
|
37
|
+
return !!this.token;
|
|
38
|
+
}
|
|
39
|
+
/** @internal POST /exec with a command line. */
|
|
40
|
+
async request(command, accept = "application/json") {
|
|
41
|
+
if (!this.token)
|
|
42
|
+
throw new ProviderError(ID, "EXE_TOKEN is not set");
|
|
43
|
+
if (Buffer.byteLength(command) > MAX_BODY)
|
|
44
|
+
throw new ProviderError(ID, `request body over the 64 KB /exec limit (${Buffer.byteLength(command)} bytes)`);
|
|
45
|
+
const res = await fetch(`${this.baseUrl}/exec`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "text/plain", Accept: accept },
|
|
48
|
+
body: command,
|
|
49
|
+
});
|
|
50
|
+
// A VM command with a nonzero exit still streams its NDJSON result.
|
|
51
|
+
if (!res.ok && !(accept.includes("ndjson") && res.headers.get("content-type")?.includes("ndjson"))) {
|
|
52
|
+
const text = await res.text();
|
|
53
|
+
let detail = text.slice(0, 300);
|
|
54
|
+
try {
|
|
55
|
+
const j = JSON.parse(text);
|
|
56
|
+
if (typeof j?.error === "string")
|
|
57
|
+
detail = j.error;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* plain-text body */
|
|
61
|
+
}
|
|
62
|
+
const verb = command.split(" ", 1)[0];
|
|
63
|
+
throw new ProviderError(ID, `/exec ${verb} -> ${res.status} ${detail}`, res.status);
|
|
64
|
+
}
|
|
65
|
+
return res;
|
|
66
|
+
}
|
|
67
|
+
/** @internal Lobby command with JSON output. */
|
|
68
|
+
async lobby(command) {
|
|
69
|
+
const text = await (await this.request(`${command} --json`)).text();
|
|
70
|
+
const j = (text ? JSON.parse(text) : {});
|
|
71
|
+
if (typeof j.error === "string")
|
|
72
|
+
throw new ProviderError(ID, `${command.split(" ", 1)[0]}: ${j.error}`);
|
|
73
|
+
return j;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* @internal Run a shell script on the VM. The script is base64-encoded so it survives both quoting
|
|
77
|
+
* layers (the lobby's shell lexer, then the VM's shell) untouched.
|
|
78
|
+
*/
|
|
79
|
+
async ssh(vm, script) {
|
|
80
|
+
const b64 = Buffer.from(script).toString("base64");
|
|
81
|
+
const res = await this.request(`ssh ${vm} 'bash -c "$(echo ${b64} | base64 -d)"'`, "application/x-ndjson");
|
|
82
|
+
const out = [];
|
|
83
|
+
const err = [];
|
|
84
|
+
let exitCode;
|
|
85
|
+
for await (const line of readLines(res)) {
|
|
86
|
+
const j = JSON.parse(line);
|
|
87
|
+
if (j.stdout !== undefined)
|
|
88
|
+
out.push(Buffer.from(j.stdout, "base64"));
|
|
89
|
+
if (j.stderr !== undefined)
|
|
90
|
+
err.push(Buffer.from(j.stderr, "base64"));
|
|
91
|
+
if (j.exit_code !== undefined)
|
|
92
|
+
exitCode = j.exit_code;
|
|
93
|
+
if (j.error !== undefined)
|
|
94
|
+
throw new ProviderError(ID, `ssh ${vm}: ${j.error}`);
|
|
95
|
+
}
|
|
96
|
+
if (exitCode === undefined)
|
|
97
|
+
throw new ProviderError(ID, `ssh ${vm}: response ended without an exit code`);
|
|
98
|
+
return { exitCode, stdout: Buffer.concat(out), stderr: Buffer.concat(err) };
|
|
99
|
+
}
|
|
100
|
+
async create(spec) {
|
|
101
|
+
// Standalone VMs take up to 2 GiB per vCPU, so raise the vCPUs when the memory asks for more.
|
|
102
|
+
const memoryGb = Math.max(1, Math.ceil(spec.memoryGb ?? 4));
|
|
103
|
+
const cpu = Math.max(1, Math.ceil(spec.vcpu ?? 2), Math.ceil(memoryGb / 2));
|
|
104
|
+
const name = `sbr-${randomBytes(6).toString("hex")}`;
|
|
105
|
+
const flags = [`--standalone`, `--name=${name}`, `--cpu=${cpu}`, `--memory=${memoryGb}GB`];
|
|
106
|
+
const image = spec.image?.exedev;
|
|
107
|
+
if (image) {
|
|
108
|
+
if (!/^[A-Za-z0-9._/:@-]+$/.test(image))
|
|
109
|
+
throw new ProviderError(ID, `invalid image reference: ${image}`);
|
|
110
|
+
flags.push(`--image=${image}`);
|
|
111
|
+
}
|
|
112
|
+
const vm = await this.lobby(`new ${flags.join(" ")}`);
|
|
113
|
+
const box = new ExeDevSandbox(this, vm.vm_name ?? name, spec.env);
|
|
114
|
+
try {
|
|
115
|
+
await box.waitForSsh();
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
await box.kill().catch(() => { });
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
return box;
|
|
122
|
+
}
|
|
123
|
+
async connect(nativeId) {
|
|
124
|
+
if (!VM_NAME.test(nativeId))
|
|
125
|
+
throw new ProviderError(ID, `invalid VM name: ${nativeId}`);
|
|
126
|
+
const { vms = [] } = await this.lobby("ls");
|
|
127
|
+
if (!vms.some((v) => v.vm_name === nativeId))
|
|
128
|
+
throw new ProviderError(ID, `VM ${nativeId} not found`, 404);
|
|
129
|
+
return new ExeDevSandbox(this, nativeId);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
class ExeDevSandbox {
|
|
133
|
+
p;
|
|
134
|
+
nativeId;
|
|
135
|
+
env;
|
|
136
|
+
constructor(p, nativeId, env) {
|
|
137
|
+
this.p = p;
|
|
138
|
+
this.nativeId = nativeId;
|
|
139
|
+
this.env = env;
|
|
140
|
+
}
|
|
141
|
+
/** `new` returns once the VM exists; retry a no-op until sshd answers. */
|
|
142
|
+
async waitForSsh(timeoutMs = 120_000) {
|
|
143
|
+
const deadline = Date.now() + timeoutMs;
|
|
144
|
+
for (;;) {
|
|
145
|
+
try {
|
|
146
|
+
if ((await this.p.ssh(this.nativeId, "true")).exitCode === 0)
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
if (err instanceof ProviderError && (err.status === 401 || err.status === 403))
|
|
151
|
+
throw err;
|
|
152
|
+
if (Date.now() > deadline)
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
if (Date.now() > deadline)
|
|
156
|
+
throw new ProviderError(ID, `VM ${this.nativeId} did not accept commands within ${timeoutMs / 1000}s`);
|
|
157
|
+
await sleep(1000);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* One /exec request lasts at most 30 s, so the command runs detached on the VM, writing to
|
|
162
|
+
* /tmp/.sbr-<id>/{out,err,exit}. Each request waits up to `wait` for it to finish, sends the output
|
|
163
|
+
* written since the last request, and either exits with the command's code (done) or ends stderr
|
|
164
|
+
* with a random marker (still running).
|
|
165
|
+
*/
|
|
166
|
+
async exec(cmd, opts = {}) {
|
|
167
|
+
const t0 = Date.now();
|
|
168
|
+
const timeout = Math.max(1, Math.round(opts.timeoutSeconds ?? 60));
|
|
169
|
+
const job = randomBytes(8).toString("hex");
|
|
170
|
+
const dir = `/tmp/.sbr-${job}`;
|
|
171
|
+
const marker = `__sandboxrouter_running_${job}__`;
|
|
172
|
+
const streaming = !!(opts.onStdout || opts.onStderr);
|
|
173
|
+
const waitTenths = streaming ? 10 : 200; // 1 s or 20 s per request
|
|
174
|
+
const full = wrapCommand(cmd, { cwd: opts.cwd, env: { ...this.env, ...opts.env } });
|
|
175
|
+
const cmdB64 = Buffer.from(full).toString("base64");
|
|
176
|
+
const report = (outOff, errOff) => [
|
|
177
|
+
`d=${dir}; i=0`,
|
|
178
|
+
`while [ ! -f "$d/exit" ] && [ $i -lt ${waitTenths} ]; do sleep 0.1; i=$((i+1)); done`,
|
|
179
|
+
`f=0; [ -f "$d/exit" ] && f=1`,
|
|
180
|
+
`tail -c +${outOff + 1} "$d/out" 2>/dev/null`,
|
|
181
|
+
`tail -c +${errOff + 1} "$d/err" >&2 2>/dev/null`,
|
|
182
|
+
`if [ $f = 1 ]; then c=$(cat "$d/exit"); rm -rf "$d"; exit "$c"; fi`,
|
|
183
|
+
`printf %s ${marker} >&2`,
|
|
184
|
+
].join("\n");
|
|
185
|
+
const start = [
|
|
186
|
+
`d=${dir}; mkdir -p "$d" && echo ${cmdB64} | base64 -d > "$d/cmd" || exit 1`,
|
|
187
|
+
`setsid nohup bash -c 'timeout -k 5 ${timeout} bash "$0/cmd" > "$0/out" 2> "$0/err" < /dev/null; echo $? > "$0/exit.tmp"; mv "$0/exit.tmp" "$0/exit"' "$d" > /dev/null 2>&1 < /dev/null &`,
|
|
188
|
+
report(0, 0),
|
|
189
|
+
].join("\n");
|
|
190
|
+
const outDec = new TextDecoder();
|
|
191
|
+
const errDec = new TextDecoder();
|
|
192
|
+
let stdout = "";
|
|
193
|
+
let stderr = "";
|
|
194
|
+
let outOff = 0;
|
|
195
|
+
let errOff = 0;
|
|
196
|
+
const deadline = Date.now() + (timeout + 60) * 1000;
|
|
197
|
+
let script = start;
|
|
198
|
+
for (;;) {
|
|
199
|
+
const r = await this.p.ssh(this.nativeId, script);
|
|
200
|
+
let errBytes = r.stderr;
|
|
201
|
+
const m = Buffer.from(marker);
|
|
202
|
+
const running = errBytes.length >= m.length && Buffer.from(errBytes.subarray(errBytes.length - m.length)).equals(m);
|
|
203
|
+
if (running)
|
|
204
|
+
errBytes = errBytes.subarray(0, errBytes.length - m.length);
|
|
205
|
+
outOff += r.stdout.length;
|
|
206
|
+
errOff += errBytes.length;
|
|
207
|
+
const o = outDec.decode(r.stdout, { stream: running });
|
|
208
|
+
const e = errDec.decode(errBytes, { stream: running });
|
|
209
|
+
if (o) {
|
|
210
|
+
stdout += o;
|
|
211
|
+
opts.onStdout?.(o);
|
|
212
|
+
}
|
|
213
|
+
if (e) {
|
|
214
|
+
stderr += e;
|
|
215
|
+
opts.onStderr?.(e);
|
|
216
|
+
}
|
|
217
|
+
if (!running)
|
|
218
|
+
return { exitCode: r.exitCode, stdout, stderr, durationMs: Date.now() - t0 };
|
|
219
|
+
if (Date.now() > deadline) {
|
|
220
|
+
await this.p.ssh(this.nativeId, `pkill -f ${shq(`${dir}/cmd`)}; rm -rf ${dir}`).catch(() => { });
|
|
221
|
+
throw new ProviderError(ID, `command still running ${timeout + 60}s after it started`);
|
|
222
|
+
}
|
|
223
|
+
script = report(outOff, errOff);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async writeFile(path, content) {
|
|
227
|
+
const bytes = toBytes(content);
|
|
228
|
+
const tmp = `${path}.sbr-${randomBytes(4).toString("hex")}`;
|
|
229
|
+
let first = true;
|
|
230
|
+
for (let i = 0; first || i < bytes.length; i += WRITE_CHUNK) {
|
|
231
|
+
const b64 = Buffer.from(bytes.subarray(i, i + WRITE_CHUNK)).toString("base64");
|
|
232
|
+
const prefix = first ? `mkdir -p ${shq(dirname(path))} && ` : "";
|
|
233
|
+
const r = await this.p.ssh(this.nativeId, `${prefix}echo ${b64 || "''"} | base64 -d ${first ? ">" : ">>"} ${shq(tmp)}`);
|
|
234
|
+
if (r.exitCode !== 0)
|
|
235
|
+
throw new ProviderError(ID, `writeFile ${path}: ${r.stderr.toString().trim() || `exit ${r.exitCode}`}`);
|
|
236
|
+
first = false;
|
|
237
|
+
}
|
|
238
|
+
const r = await this.p.ssh(this.nativeId, `mv -f ${shq(tmp)} ${shq(path)}`);
|
|
239
|
+
if (r.exitCode !== 0)
|
|
240
|
+
throw new ProviderError(ID, `writeFile ${path}: ${r.stderr.toString().trim() || `exit ${r.exitCode}`}`);
|
|
241
|
+
}
|
|
242
|
+
async readFile(path) {
|
|
243
|
+
const r = await this.p.ssh(this.nativeId, `base64 -w0 ${shq(path)}`);
|
|
244
|
+
if (r.exitCode !== 0)
|
|
245
|
+
throw new ProviderError(ID, `readFile ${path}: ${r.stderr.toString().trim() || `exit ${r.exitCode}`}`);
|
|
246
|
+
return new Uint8Array(Buffer.from(r.stdout.toString(), "base64"));
|
|
247
|
+
}
|
|
248
|
+
async kill() {
|
|
249
|
+
await this.p.lobby(`rm ${this.nativeId}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** Newline-delimited JSON reader for a fetch Response. */
|
|
253
|
+
async function* readLines(res) {
|
|
254
|
+
if (!res.body)
|
|
255
|
+
return;
|
|
256
|
+
const reader = res.body.getReader();
|
|
257
|
+
const decoder = new TextDecoder();
|
|
258
|
+
let buffer = "";
|
|
259
|
+
for (;;) {
|
|
260
|
+
const { done, value } = await reader.read();
|
|
261
|
+
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
|
|
262
|
+
let nl;
|
|
263
|
+
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
264
|
+
const line = buffer.slice(0, nl).trim();
|
|
265
|
+
buffer = buffer.slice(nl + 1);
|
|
266
|
+
if (line)
|
|
267
|
+
yield line;
|
|
268
|
+
}
|
|
269
|
+
if (done) {
|
|
270
|
+
if (buffer.trim())
|
|
271
|
+
yield buffer.trim();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function sleep(ms) {
|
|
277
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
278
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* HOPX via `@hopx-ai/sdk`. Auth: HOPX_API_KEY. vCPU and memory come from the template, not the
|
|
4
|
+
* create call (HOPX does not take custom resources at create). Set image.hopx to a template name
|
|
5
|
+
* (or a numeric template id) to start from your own; default is the public "code-interpreter".
|
|
6
|
+
*/
|
|
7
|
+
export declare class HopxProvider implements Provider {
|
|
8
|
+
private apiKey;
|
|
9
|
+
readonly id: "hopx";
|
|
10
|
+
constructor(apiKey?: string | undefined);
|
|
11
|
+
configured(): boolean;
|
|
12
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
13
|
+
/** Reattach; the SDK resumes a paused sandbox. */
|
|
14
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
15
|
+
}
|