@sandboxrouter/sdk 0.1.0 → 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/README.md +75 -42
- package/dist/catalog.d.ts +75 -5
- package/dist/catalog.js +126 -4
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -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/docker.d.ts +17 -0
- package/dist/providers/docker.js +148 -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/northflank.d.ts +21 -0
- package/dist/providers/northflank.js +261 -0
- package/dist/router.js +25 -6
- package/dist/types.d.ts +6 -2
- package/package.json +29 -15
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { ProviderError } from "../types.js";
|
|
2
|
+
import { optionalImport, toBytes } from "../util.js";
|
|
3
|
+
/** Public template used when spec.image.hopx is not set. */
|
|
4
|
+
const DEFAULT_TEMPLATE = "code-interpreter";
|
|
5
|
+
/**
|
|
6
|
+
* HOPX via `@hopx-ai/sdk`. Auth: HOPX_API_KEY. vCPU and memory come from the template, not the
|
|
7
|
+
* create call (HOPX does not take custom resources at create). Set image.hopx to a template name
|
|
8
|
+
* (or a numeric template id) to start from your own; default is the public "code-interpreter".
|
|
9
|
+
*/
|
|
10
|
+
export class HopxProvider {
|
|
11
|
+
apiKey;
|
|
12
|
+
id = "hopx";
|
|
13
|
+
constructor(apiKey = process.env.HOPX_API_KEY) {
|
|
14
|
+
this.apiKey = apiKey;
|
|
15
|
+
}
|
|
16
|
+
configured() {
|
|
17
|
+
return !!this.apiKey;
|
|
18
|
+
}
|
|
19
|
+
async create(spec) {
|
|
20
|
+
const { Sandbox } = await optionalImport("@hopx-ai/sdk", "hopx");
|
|
21
|
+
const image = spec.image?.hopx ?? DEFAULT_TEMPLATE;
|
|
22
|
+
try {
|
|
23
|
+
const sbx = await Sandbox.create({
|
|
24
|
+
...(/^\d+$/.test(image) ? { templateId: image } : { template: image }),
|
|
25
|
+
apiKey: this.apiKey,
|
|
26
|
+
timeoutSeconds: Math.max(60, Math.round((spec.timeoutMinutes ?? 30) * 60)),
|
|
27
|
+
envVars: spec.env,
|
|
28
|
+
});
|
|
29
|
+
return new HopxBox(sbx);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
throw fail(err);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Reattach; the SDK resumes a paused sandbox. */
|
|
36
|
+
async connect(nativeId) {
|
|
37
|
+
const { Sandbox } = await optionalImport("@hopx-ai/sdk", "hopx");
|
|
38
|
+
try {
|
|
39
|
+
return new HopxBox(await Sandbox.connect(nativeId, this.apiKey));
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
throw fail(err);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
class HopxBox {
|
|
47
|
+
sbx;
|
|
48
|
+
nativeId;
|
|
49
|
+
constructor(sbx) {
|
|
50
|
+
this.sbx = sbx;
|
|
51
|
+
this.nativeId = sbx.sandboxId;
|
|
52
|
+
}
|
|
53
|
+
async exec(cmd, opts = {}) {
|
|
54
|
+
if (opts.onStdout || opts.onStderr)
|
|
55
|
+
return this.execStreaming(cmd, opts);
|
|
56
|
+
const t0 = Date.now();
|
|
57
|
+
try {
|
|
58
|
+
const r = await this.sbx.commands.run(cmd, {
|
|
59
|
+
timeout: opts.timeoutSeconds ?? 60,
|
|
60
|
+
workingDir: opts.cwd,
|
|
61
|
+
env: opts.env,
|
|
62
|
+
});
|
|
63
|
+
// A nonzero exit comes back as a normal response with exit_code set.
|
|
64
|
+
return { exitCode: r.exit_code ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "", durationMs: Date.now() - t0 };
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
throw fail(err);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Streams over the agent's WebSocket (/execute/stream) with the code run as bash. */
|
|
71
|
+
async execStreaming(cmd, opts) {
|
|
72
|
+
const t0 = Date.now();
|
|
73
|
+
const seconds = opts.timeoutSeconds ?? 60;
|
|
74
|
+
let stdout = "";
|
|
75
|
+
let stderr = "";
|
|
76
|
+
let exitCode;
|
|
77
|
+
const run = async () => {
|
|
78
|
+
const stream = this.sbx.runCodeStream(cmd, {
|
|
79
|
+
language: "bash",
|
|
80
|
+
timeout: seconds,
|
|
81
|
+
workingDir: opts.cwd,
|
|
82
|
+
env: opts.env,
|
|
83
|
+
});
|
|
84
|
+
for await (const m of stream) {
|
|
85
|
+
if (m.type === "stdout") {
|
|
86
|
+
stdout += m.data;
|
|
87
|
+
opts.onStdout?.(m.data);
|
|
88
|
+
}
|
|
89
|
+
else if (m.type === "stderr") {
|
|
90
|
+
stderr += m.data;
|
|
91
|
+
opts.onStderr?.(m.data);
|
|
92
|
+
}
|
|
93
|
+
else if (m.type === "result") {
|
|
94
|
+
exitCode = m.exit_code;
|
|
95
|
+
}
|
|
96
|
+
else if (m.type === "error") {
|
|
97
|
+
const msg = m.code === "EXECUTION_TIMEOUT" ? `command timed out after ${seconds}s` : m.error;
|
|
98
|
+
throw new ProviderError("hopx", msg);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
// The agent enforces the timeout; the extra grace covers a stream that never closes.
|
|
104
|
+
const graceMs = (seconds + 30) * 1000;
|
|
105
|
+
const timedOut = await Promise.race([run().then(() => false), sleep(graceMs).then(() => true)]);
|
|
106
|
+
if (timedOut)
|
|
107
|
+
throw new ProviderError("hopx", `command timed out after ${seconds}s`);
|
|
108
|
+
if (exitCode === undefined)
|
|
109
|
+
throw new ProviderError("hopx", "stream closed before the command finished");
|
|
110
|
+
return { exitCode, stdout, stderr, durationMs: Date.now() - t0 };
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
throw fail(err);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async writeFile(path, content) {
|
|
117
|
+
try {
|
|
118
|
+
await this.sbx.files.writeBytes(path, Buffer.from(toBytes(content)));
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
throw fail(err);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async readFile(path) {
|
|
125
|
+
try {
|
|
126
|
+
return new Uint8Array(await this.sbx.files.readBytes(path));
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
throw fail(err);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Pause with memory and disk kept. router.get() (Sandbox.connect) resumes it. */
|
|
133
|
+
async pause() {
|
|
134
|
+
try {
|
|
135
|
+
await this.sbx.pause();
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
throw fail(err);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async kill() {
|
|
142
|
+
try {
|
|
143
|
+
await this.sbx.kill();
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
throw fail(err);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** HTTP status for SDK errors that drop it (only APIError keeps statusCode). */
|
|
151
|
+
const STATUS_BY_NAME = {
|
|
152
|
+
AuthenticationError: 401,
|
|
153
|
+
TokenExpiredError: 401,
|
|
154
|
+
RateLimitError: 429,
|
|
155
|
+
NotFoundError: 404,
|
|
156
|
+
TemplateNotFoundError: 404,
|
|
157
|
+
FileNotFoundError: 404,
|
|
158
|
+
SandboxExpiredError: 410,
|
|
159
|
+
};
|
|
160
|
+
function fail(err) {
|
|
161
|
+
if (err instanceof ProviderError)
|
|
162
|
+
return err;
|
|
163
|
+
const status = err?.statusCode ?? err?.response?.status ?? STATUS_BY_NAME[err?.name];
|
|
164
|
+
return new ProviderError("hopx", err?.message ?? String(err), status);
|
|
165
|
+
}
|
|
166
|
+
function sleep(ms) {
|
|
167
|
+
return new Promise((r) => setTimeout(r, ms).unref());
|
|
168
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Northflank sandboxes via `@northflank/js-client`. Auth: NORTHFLANK_API_TOKEN plus
|
|
4
|
+
* NORTHFLANK_PROJECT_ID (the project every sandbox is created in; its region is fixed at
|
|
5
|
+
* project creation). A sandbox is a deployment service (microVM-isolated on Northflank cloud)
|
|
6
|
+
* kept alive by `tail -f /dev/null`; commands run over Northflank's exec API. Default image
|
|
7
|
+
* ubuntu:22.04; set image.northflank to any public image path (e.g. "python:3.13-slim"). The
|
|
8
|
+
* image's entrypoint is replaced, and it needs `sh` and `base64` on PATH for file transfer.
|
|
9
|
+
*/
|
|
10
|
+
export declare class NorthflankProvider implements Provider {
|
|
11
|
+
private token;
|
|
12
|
+
private projectId;
|
|
13
|
+
readonly id: "northflank";
|
|
14
|
+
private client?;
|
|
15
|
+
constructor(token?: string | undefined, projectId?: string | undefined);
|
|
16
|
+
configured(): boolean;
|
|
17
|
+
private northflank;
|
|
18
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
19
|
+
/** Reattach to a running sandbox by service id. */
|
|
20
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
21
|
+
}
|