@sandboxrouter/sdk 0.1.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.
Files changed (97) hide show
  1. package/README.md +271 -62
  2. package/dist/bench.js +4 -4
  3. package/dist/catalog.d.ts +232 -3
  4. package/dist/catalog.js +569 -14
  5. package/dist/check.d.ts +28 -0
  6. package/dist/check.js +185 -0
  7. package/dist/cli.js +149 -12
  8. package/dist/computers.d.ts +55 -13
  9. package/dist/computers.js +132 -6
  10. package/dist/conformance.d.ts +46 -0
  11. package/dist/conformance.js +65 -0
  12. package/dist/cost.d.ts +67 -0
  13. package/dist/cost.js +153 -0
  14. package/dist/gc.d.ts +29 -0
  15. package/dist/gc.js +54 -0
  16. package/dist/index.d.ts +34 -2
  17. package/dist/index.js +34 -2
  18. package/dist/leaderboard.d.ts +6 -0
  19. package/dist/leaderboard.js +5 -4
  20. package/dist/ledger.d.ts +38 -0
  21. package/dist/ledger.js +89 -0
  22. package/dist/providers/agent37.d.ts +40 -0
  23. package/dist/providers/agent37.js +224 -0
  24. package/dist/providers/agentcore.d.ts +71 -0
  25. package/dist/providers/agentcore.js +446 -0
  26. package/dist/providers/alibaba.d.ts +18 -0
  27. package/dist/providers/alibaba.js +106 -0
  28. package/dist/providers/azure.d.ts +43 -0
  29. package/dist/providers/azure.js +330 -0
  30. package/dist/providers/beam.d.ts +11 -0
  31. package/dist/providers/beam.js +179 -0
  32. package/dist/providers/blaxel.d.ts +3 -1
  33. package/dist/providers/blaxel.js +32 -22
  34. package/dist/providers/boat.d.ts +25 -0
  35. package/dist/providers/boat.js +263 -0
  36. package/dist/providers/cloudflare.d.ts +30 -0
  37. package/dist/providers/cloudflare.js +152 -0
  38. package/dist/providers/codesandbox.d.ts +59 -0
  39. package/dist/providers/codesandbox.js +287 -0
  40. package/dist/providers/daytona.d.ts +25 -4
  41. package/dist/providers/daytona.js +449 -28
  42. package/dist/providers/dedalus.d.ts +12 -0
  43. package/dist/providers/dedalus.js +244 -0
  44. package/dist/providers/deno.d.ts +50 -0
  45. package/dist/providers/deno.js +170 -0
  46. package/dist/providers/docker.d.ts +17 -0
  47. package/dist/providers/docker.js +154 -0
  48. package/dist/providers/e2b.d.ts +70 -3
  49. package/dist/providers/e2b.js +376 -8
  50. package/dist/providers/exedev.d.ts +23 -0
  51. package/dist/providers/exedev.js +290 -0
  52. package/dist/providers/freestyle.d.ts +49 -0
  53. package/dist/providers/freestyle.js +261 -0
  54. package/dist/providers/hopx.d.ts +15 -0
  55. package/dist/providers/hopx.js +177 -0
  56. package/dist/providers/instabox.d.ts +76 -0
  57. package/dist/providers/instabox.js +360 -0
  58. package/dist/providers/koyeb.d.ts +71 -0
  59. package/dist/providers/koyeb.js +379 -0
  60. package/dist/providers/lambda.d.ts +51 -0
  61. package/dist/providers/lambda.js +467 -0
  62. package/dist/providers/langsmith.d.ts +16 -0
  63. package/dist/providers/langsmith.js +149 -0
  64. package/dist/providers/limrun.d.ts +77 -0
  65. package/dist/providers/limrun.js +626 -0
  66. package/dist/providers/maritime.d.ts +5 -1
  67. package/dist/providers/maritime.js +39 -8
  68. package/dist/providers/mobilerun.d.ts +44 -0
  69. package/dist/providers/mobilerun.js +417 -0
  70. package/dist/providers/modal.js +12 -4
  71. package/dist/providers/morph.js +12 -4
  72. package/dist/providers/northflank.d.ts +21 -0
  73. package/dist/providers/northflank.js +259 -0
  74. package/dist/providers/novita.d.ts +12 -0
  75. package/dist/providers/novita.js +81 -0
  76. package/dist/providers/openscale.d.ts +17 -0
  77. package/dist/providers/openscale.js +87 -0
  78. package/dist/providers/orgo.d.ts +45 -0
  79. package/dist/providers/orgo.js +414 -0
  80. package/dist/providers/sail.d.ts +32 -0
  81. package/dist/providers/sail.js +372 -0
  82. package/dist/providers/superserve.d.ts +25 -0
  83. package/dist/providers/superserve.js +337 -0
  84. package/dist/providers/tensorlake.js +17 -14
  85. package/dist/providers/upstash.d.ts +30 -0
  86. package/dist/providers/upstash.js +258 -0
  87. package/dist/providers/vercel.js +14 -3
  88. package/dist/providers/workspaces.d.ts +60 -0
  89. package/dist/providers/workspaces.js +662 -0
  90. package/dist/router.d.ts +36 -5
  91. package/dist/router.js +174 -25
  92. package/dist/server.d.ts +1 -1
  93. package/dist/server.js +22 -3
  94. package/dist/types.d.ts +18 -6
  95. package/dist/util.d.ts +28 -1
  96. package/dist/util.js +56 -7
  97. package/package.json +106 -17
@@ -0,0 +1,290 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { ProviderError } from "../types.js";
3
+ import { dirname, killedByTimeout, shq, timeoutError, 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 vmName = vm.vm_name ?? name;
114
+ const box = new ExeDevSandbox(this, vmName, spec.env);
115
+ try {
116
+ await box.waitForSsh();
117
+ }
118
+ catch (err) {
119
+ await box.kill().catch(() => { });
120
+ throw err;
121
+ }
122
+ // exe.dev has no lifetime setting, so the VM powers itself off when spec.timeoutMinutes runs out
123
+ // (a stopped VM bills disk only; kill() or `sandboxrouter gc` deletes it). Best effort: it needs
124
+ // root or passwordless sudo in the image (not yet confirmed on a real exe.dev VM).
125
+ const secs = Math.max(60, Math.round((spec.timeoutMinutes ?? 30) * 60));
126
+ await this.ssh(vmName, `setsid nohup sh -c 'sleep ${secs}; sudo -n poweroff || poweroff' > /dev/null 2>&1 < /dev/null &`).catch(() => { });
127
+ return box;
128
+ }
129
+ async connect(nativeId) {
130
+ if (!VM_NAME.test(nativeId))
131
+ throw new ProviderError(ID, `invalid VM name: ${nativeId}`);
132
+ const { vms = [] } = await this.lobby("ls");
133
+ if (!vms.some((v) => v.vm_name === nativeId))
134
+ throw new ProviderError(ID, `VM ${nativeId} not found`, 404);
135
+ return new ExeDevSandbox(this, nativeId);
136
+ }
137
+ }
138
+ class ExeDevSandbox {
139
+ p;
140
+ nativeId;
141
+ env;
142
+ constructor(p, nativeId, env) {
143
+ this.p = p;
144
+ this.nativeId = nativeId;
145
+ this.env = env;
146
+ }
147
+ /** `new` returns once the VM exists; retry a no-op until sshd answers. */
148
+ async waitForSsh(timeoutMs = 120_000) {
149
+ const deadline = Date.now() + timeoutMs;
150
+ for (;;) {
151
+ try {
152
+ if ((await this.p.ssh(this.nativeId, "true")).exitCode === 0)
153
+ return;
154
+ }
155
+ catch (err) {
156
+ if (err instanceof ProviderError && (err.status === 401 || err.status === 403))
157
+ throw err;
158
+ if (Date.now() > deadline)
159
+ throw err;
160
+ }
161
+ if (Date.now() > deadline)
162
+ throw new ProviderError(ID, `VM ${this.nativeId} did not accept commands within ${timeoutMs / 1000}s`);
163
+ await sleep(1000);
164
+ }
165
+ }
166
+ /**
167
+ * One /exec request lasts at most 30 s, so the command runs detached on the VM, writing to
168
+ * /tmp/.sbr-<id>/{out,err,exit}. Each request waits up to `wait` for it to finish, sends the output
169
+ * written since the last request, and either exits with the command's code (done) or ends stderr
170
+ * with a random marker (still running).
171
+ */
172
+ async exec(cmd, opts = {}) {
173
+ const t0 = Date.now();
174
+ const timeout = Math.max(1, Math.round(opts.timeoutSeconds ?? 60));
175
+ const job = randomBytes(8).toString("hex");
176
+ const dir = `/tmp/.sbr-${job}`;
177
+ const marker = `__sandboxrouter_running_${job}__`;
178
+ const streaming = !!(opts.onStdout || opts.onStderr);
179
+ const waitTenths = streaming ? 10 : 200; // 1 s or 20 s per request
180
+ const full = wrapCommand(cmd, { cwd: opts.cwd, env: { ...this.env, ...opts.env } });
181
+ const cmdB64 = Buffer.from(full).toString("base64");
182
+ const report = (outOff, errOff) => [
183
+ `d=${dir}; i=0`,
184
+ `while [ ! -f "$d/exit" ] && [ $i -lt ${waitTenths} ]; do sleep 0.1; i=$((i+1)); done`,
185
+ `f=0; [ -f "$d/exit" ] && f=1`,
186
+ `tail -c +${outOff + 1} "$d/out" 2>/dev/null`,
187
+ `tail -c +${errOff + 1} "$d/err" >&2 2>/dev/null`,
188
+ `if [ $f = 1 ]; then c=$(cat "$d/exit"); rm -rf "$d"; exit "$c"; fi`,
189
+ `printf %s ${marker} >&2`,
190
+ ].join("\n");
191
+ const start = [
192
+ `d=${dir}; mkdir -p "$d" && echo ${cmdB64} | base64 -d > "$d/cmd" || exit 1`,
193
+ `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 &`,
194
+ report(0, 0),
195
+ ].join("\n");
196
+ const outDec = new TextDecoder();
197
+ const errDec = new TextDecoder();
198
+ let stdout = "";
199
+ let stderr = "";
200
+ let outOff = 0;
201
+ let errOff = 0;
202
+ const deadline = Date.now() + (timeout + 60) * 1000;
203
+ let script = start;
204
+ for (;;) {
205
+ const r = await this.p.ssh(this.nativeId, script);
206
+ let errBytes = r.stderr;
207
+ const m = Buffer.from(marker);
208
+ const running = errBytes.length >= m.length && Buffer.from(errBytes.subarray(errBytes.length - m.length)).equals(m);
209
+ if (running)
210
+ errBytes = errBytes.subarray(0, errBytes.length - m.length);
211
+ outOff += r.stdout.length;
212
+ errOff += errBytes.length;
213
+ const o = outDec.decode(r.stdout, { stream: running });
214
+ const e = errDec.decode(errBytes, { stream: running });
215
+ if (o) {
216
+ stdout += o;
217
+ opts.onStdout?.(o);
218
+ }
219
+ if (e) {
220
+ stderr += e;
221
+ opts.onStderr?.(e);
222
+ }
223
+ if (!running) {
224
+ const durationMs = Date.now() - t0;
225
+ // `timeout -k 5` on the VM leaves exit 124 (or 137 after SIGKILL) when it had to stop the command.
226
+ if (killedByTimeout(r.exitCode, durationMs, timeout))
227
+ throw timeoutError(ID, timeout);
228
+ return { exitCode: r.exitCode, stdout, stderr, durationMs };
229
+ }
230
+ if (Date.now() > deadline) {
231
+ // The VM's own `timeout` should have ended it a minute ago: stop it from here as a last resort.
232
+ await this.p.ssh(this.nativeId, `pkill -f ${shq(`${dir}/cmd`)}; rm -rf ${dir}`).catch(() => { });
233
+ throw timeoutError(ID, timeout);
234
+ }
235
+ script = report(outOff, errOff);
236
+ }
237
+ }
238
+ async writeFile(path, content) {
239
+ const bytes = toBytes(content);
240
+ const tmp = `${path}.sbr-${randomBytes(4).toString("hex")}`;
241
+ let first = true;
242
+ for (let i = 0; first || i < bytes.length; i += WRITE_CHUNK) {
243
+ const b64 = Buffer.from(bytes.subarray(i, i + WRITE_CHUNK)).toString("base64");
244
+ const prefix = first ? `mkdir -p ${shq(dirname(path))} && ` : "";
245
+ const r = await this.p.ssh(this.nativeId, `${prefix}echo ${b64 || "''"} | base64 -d ${first ? ">" : ">>"} ${shq(tmp)}`);
246
+ if (r.exitCode !== 0)
247
+ throw new ProviderError(ID, `writeFile ${path}: ${r.stderr.toString().trim() || `exit ${r.exitCode}`}`);
248
+ first = false;
249
+ }
250
+ const r = await this.p.ssh(this.nativeId, `mv -f ${shq(tmp)} ${shq(path)}`);
251
+ if (r.exitCode !== 0)
252
+ throw new ProviderError(ID, `writeFile ${path}: ${r.stderr.toString().trim() || `exit ${r.exitCode}`}`);
253
+ }
254
+ async readFile(path) {
255
+ const r = await this.p.ssh(this.nativeId, `base64 -w0 ${shq(path)}`);
256
+ if (r.exitCode !== 0)
257
+ throw new ProviderError(ID, `readFile ${path}: ${r.stderr.toString().trim() || `exit ${r.exitCode}`}`);
258
+ return new Uint8Array(Buffer.from(r.stdout.toString(), "base64"));
259
+ }
260
+ async kill() {
261
+ await this.p.lobby(`rm ${this.nativeId}`);
262
+ }
263
+ }
264
+ /** Newline-delimited JSON reader for a fetch Response. */
265
+ async function* readLines(res) {
266
+ if (!res.body)
267
+ return;
268
+ const reader = res.body.getReader();
269
+ const decoder = new TextDecoder();
270
+ let buffer = "";
271
+ for (;;) {
272
+ const { done, value } = await reader.read();
273
+ buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
274
+ let nl;
275
+ while ((nl = buffer.indexOf("\n")) !== -1) {
276
+ const line = buffer.slice(0, nl).trim();
277
+ buffer = buffer.slice(nl + 1);
278
+ if (line)
279
+ yield line;
280
+ }
281
+ if (done) {
282
+ if (buffer.trim())
283
+ yield buffer.trim();
284
+ return;
285
+ }
286
+ }
287
+ }
288
+ function sleep(ms) {
289
+ return new Promise((r) => setTimeout(r, ms));
290
+ }
@@ -0,0 +1,49 @@
1
+ import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
2
+ /**
3
+ * Freestyle's Ubuntu base snapshots (hardware is baked into each), from
4
+ * https://www.freestyle.sh/docs/vms/base-snapshots, priced at the list rates on
5
+ * https://www.freestyle.sh/docs/vms/pricing-and-limits ($0.04032/vCPU-h, $0.0129/GiB-h memory,
6
+ * $0.000086/GiB-h disk), checked 2026-11-04. Free tops out at `ubuntu`, Hobby at `ubuntu-lg`,
7
+ * Pro at `ubuntu-2xl`.
8
+ */
9
+ export declare const FREESTYLE_SIZES: {
10
+ snapshot: string;
11
+ vcpu: number;
12
+ memoryGb: number;
13
+ diskGb: number;
14
+ usdHour: number;
15
+ }[];
16
+ /** Smallest Ubuntu base snapshot with at least the requested vCPU and memory, or undefined. */
17
+ export declare function freestyleSize(vcpu: number, memoryGb: number): (typeof FREESTYLE_SIZES)[number] | undefined;
18
+ export declare class FreestyleProvider implements Provider {
19
+ private apiKey;
20
+ private baseUrl;
21
+ /** How often to poll a backgrounded request or a booting VM. */
22
+ private pollMs;
23
+ readonly id: "freestyle";
24
+ constructor(apiKey?: string | undefined, baseUrl?: string,
25
+ /** How often to poll a backgrounded request or a booting VM. */
26
+ pollMs?: number);
27
+ configured(): boolean;
28
+ /**
29
+ * @internal One API call. With `background`, a call still running after 5 s comes back as 202
30
+ * and is polled at /v5/background-requests/:id until its own response is ready.
31
+ */
32
+ request(method: string, path: string, init?: {
33
+ json?: unknown;
34
+ bytes?: Uint8Array;
35
+ background?: boolean;
36
+ signal?: AbortSignal;
37
+ }): Promise<Response>;
38
+ /** @internal JSON call. */
39
+ json<T>(method: string, path: string, init?: {
40
+ json?: unknown;
41
+ background?: boolean;
42
+ signal?: AbortSignal;
43
+ }): Promise<T>;
44
+ create(spec: SandboxSpec): Promise<ProviderSandbox>;
45
+ /** Reattach by VM id (or slug); a paused or stopped VM is started again. */
46
+ connect(nativeId: string): Promise<ProviderSandbox>;
47
+ /** @internal */
48
+ get poll(): number;
49
+ }
@@ -0,0 +1,261 @@
1
+ import { createHash } from "node:crypto";
2
+ import { ProviderError } from "../types.js";
3
+ import { dirname, toBytes, wrapCommand } from "../util.js";
4
+ /**
5
+ * Freestyle VMs over the public REST API (no SDK needed). Auth: Authorization: Bearer
6
+ * <FREESTYLE_API_KEY>, base https://api.freestyle.sh. Errors are {code, message}.
7
+ * POST /v5/vms {snapshotId, firewall:{rules}, ttlSeconds, metadata} -> Vm (201, booting)
8
+ * GET /v5/vms/:id -> Vm {id, state: starting|running|pausing|paused|stopped, resources}
9
+ * POST /v5/vms/:id/exec-await {command, env, timeoutMs} -> {statusCode|null, stdout, stderr}
10
+ * PUT /v5/vms/:id/fs/write?path=P&sha256=H raw bytes, atomic (204)
11
+ * GET /v5/vms/:id/fs/read?path=P raw bytes (404 when missing; works while paused)
12
+ * POST /v5/vms/:id/fs/mkdir {path} (creates parents)
13
+ * POST /v5/vms/:id/pause, POST /v5/vms/:id/start, POST /v5/vms/:id/resize {cpu, memory(MiB)}
14
+ * DELETE /v5/vms/:id
15
+ * Long calls: with `x-freestyle-background-after-secs: N` a request still running after N s
16
+ * answers 202 {requestId} and GET /v5/background-requests/:id replays its response once done (202
17
+ * while pending). That is what lets exec run past the 5-minute inline cap (up to 24 h).
18
+ * Sizes come from the base snapshot (FREESTYLE_SIZES): the adapter boots the smallest
19
+ * freestyle/ubuntu-* that fits. A custom image.freestyle (snapshot id/slug) boots at its own size
20
+ * and is grown with resize when the spec asks for more. A VM gets no network unless allowed, so the
21
+ * adapter opens outbound Internet, like other providers' defaults. exec has no streaming (output
22
+ * streams only over PTY WebSockets), so onStdout/onStderr get the whole output once it finishes.
23
+ * pause() keeps memory and disk; connect() and a paused exec start the VM again.
24
+ * Docs: https://www.freestyle.sh/docs/vms, https://www.freestyle.sh/docs/vms/lifecycle,
25
+ * https://www.freestyle.sh/docs/vms/files, https://www.freestyle.sh/docs/vms/base-snapshots,
26
+ * https://www.freestyle.sh/docs/vms/pricing-and-limits, https://www.freestyle.sh/openapi.json
27
+ */
28
+ const ID = "freestyle";
29
+ const BACKGROUND_AFTER_SECS = "5";
30
+ const READY_TIMEOUT_MS = 3 * 60_000;
31
+ const MAX_EXEC_MS = 86_400_000;
32
+ const OUTBOUND_INTERNET = { rules: [{ action: "allow", source: {}, destination: { public: true } }] };
33
+ /**
34
+ * Freestyle's Ubuntu base snapshots (hardware is baked into each), from
35
+ * https://www.freestyle.sh/docs/vms/base-snapshots, priced at the list rates on
36
+ * https://www.freestyle.sh/docs/vms/pricing-and-limits ($0.04032/vCPU-h, $0.0129/GiB-h memory,
37
+ * $0.000086/GiB-h disk), checked 2026-11-04. Free tops out at `ubuntu`, Hobby at `ubuntu-lg`,
38
+ * Pro at `ubuntu-2xl`.
39
+ */
40
+ export const FREESTYLE_SIZES = [
41
+ { snapshot: "freestyle/ubuntu-sm", vcpu: 2, memoryGb: 4, diskGb: 16 },
42
+ { snapshot: "freestyle/ubuntu", vcpu: 4, memoryGb: 8, diskGb: 32 },
43
+ { snapshot: "freestyle/ubuntu-lg", vcpu: 8, memoryGb: 16, diskGb: 64 },
44
+ { snapshot: "freestyle/ubuntu-xl", vcpu: 16, memoryGb: 32, diskGb: 128 },
45
+ { snapshot: "freestyle/ubuntu-2xl", vcpu: 32, memoryGb: 64, diskGb: 128 },
46
+ ].map((s) => ({ ...s, usdHour: round(s.vcpu * 0.04032 + s.memoryGb * 0.0129 + s.diskGb * 0.000086) }));
47
+ /** Smallest Ubuntu base snapshot with at least the requested vCPU and memory, or undefined. */
48
+ export function freestyleSize(vcpu, memoryGb) {
49
+ return FREESTYLE_SIZES.find((s) => s.vcpu >= vcpu && s.memoryGb >= memoryGb);
50
+ }
51
+ export class FreestyleProvider {
52
+ apiKey;
53
+ baseUrl;
54
+ pollMs;
55
+ id = ID;
56
+ constructor(apiKey = process.env.FREESTYLE_API_KEY, baseUrl = process.env.FREESTYLE_API_URL ?? "https://api.freestyle.sh",
57
+ /** How often to poll a backgrounded request or a booting VM. */
58
+ pollMs = 2000) {
59
+ this.apiKey = apiKey;
60
+ this.baseUrl = baseUrl;
61
+ this.pollMs = pollMs;
62
+ }
63
+ configured() {
64
+ return !!this.apiKey;
65
+ }
66
+ /**
67
+ * @internal One API call. With `background`, a call still running after 5 s comes back as 202
68
+ * and is polled at /v5/background-requests/:id until its own response is ready.
69
+ */
70
+ async request(method, path, init = {}) {
71
+ if (!this.apiKey)
72
+ throw new ProviderError(ID, "FREESTYLE_API_KEY is not set");
73
+ const headers = { Authorization: `Bearer ${this.apiKey}` };
74
+ if (init.background)
75
+ headers["x-freestyle-background-after-secs"] = BACKGROUND_AFTER_SECS;
76
+ let body;
77
+ if (init.json !== undefined) {
78
+ headers["Content-Type"] = "application/json";
79
+ body = JSON.stringify(init.json);
80
+ }
81
+ else if (init.bytes) {
82
+ headers["Content-Type"] = "application/octet-stream";
83
+ body = init.bytes;
84
+ }
85
+ let res = await fetch(`${this.baseUrl}${path}`, { method, headers, body, signal: init.signal });
86
+ if (init.background && res.status === 202) {
87
+ const accepted = (await res.json().catch(() => ({})));
88
+ const requestId = res.headers.get("x-freestyle-background-request-id") ?? accepted.requestId;
89
+ const resultPath = accepted.resultUrl ?? (requestId ? `/v5/background-requests/${encodeURIComponent(requestId)}` : undefined);
90
+ if (!resultPath)
91
+ throw new ProviderError(ID, `${method} ${path.split("?")[0]} -> 202 without a background request id`, 202);
92
+ for (;;) {
93
+ await sleep(this.pollMs, init.signal);
94
+ res = await fetch(resultPath.startsWith("http") ? resultPath : `${this.baseUrl}${resultPath}`, {
95
+ headers: { Authorization: `Bearer ${this.apiKey}` },
96
+ signal: init.signal,
97
+ });
98
+ if (res.status !== 202)
99
+ break;
100
+ await res.body?.cancel().catch(() => { });
101
+ }
102
+ }
103
+ if (!res.ok) {
104
+ const text = await res.text();
105
+ let detail = text.slice(0, 300);
106
+ try {
107
+ const j = JSON.parse(text);
108
+ if (j?.message)
109
+ detail = `${j.code ? `${j.code}: ` : ""}${j.message}`;
110
+ }
111
+ catch {
112
+ /* plain-text body */
113
+ }
114
+ throw new ProviderError(ID, `${method} ${path.split("?")[0]} -> ${res.status} ${detail}`, res.status);
115
+ }
116
+ return res;
117
+ }
118
+ /** @internal JSON call. */
119
+ async json(method, path, init = {}) {
120
+ const text = await (await this.request(method, path, init)).text();
121
+ return (text ? JSON.parse(text) : {});
122
+ }
123
+ async create(spec) {
124
+ const vcpu = spec.vcpu ?? 2;
125
+ const memoryGb = spec.memoryGb ?? 4;
126
+ const custom = spec.image?.freestyle;
127
+ const size = freestyleSize(vcpu, memoryGb);
128
+ if (!custom && !size)
129
+ throw new ProviderError(ID, `no Freestyle size has ${vcpu} vCPU / ${memoryGb} GB (max ubuntu-2xl: 32 vCPU / 64 GiB)`);
130
+ const body = {
131
+ snapshotId: custom ?? size.snapshot,
132
+ firewall: OUTBOUND_INTERNET,
133
+ ttlSeconds: Math.max(60, Math.round((spec.timeoutMinutes ?? 30) * 60)),
134
+ };
135
+ if (spec.metadata)
136
+ body.metadata = spec.metadata;
137
+ const vm = await this.json("POST", "/v5/vms", { json: body, background: true });
138
+ const box = new FreestyleBox(this, vm.id, spec.env);
139
+ try {
140
+ const ready = await box.waitRunning();
141
+ // A custom snapshot boots at its own size; grow it (resize is grow-only) when the spec wants more.
142
+ const res = ready.resources;
143
+ if (custom && res && (res.cpu < Math.ceil(vcpu) || res.memory < Math.ceil(memoryGb * 1024))) {
144
+ await this.json("POST", `/v5/vms/${encodeURIComponent(vm.id)}/resize`, {
145
+ json: { cpu: Math.max(res.cpu, Math.ceil(vcpu)), memory: Math.max(res.memory, Math.ceil(memoryGb * 1024)) },
146
+ background: true,
147
+ });
148
+ }
149
+ }
150
+ catch (err) {
151
+ await box.kill().catch(() => { });
152
+ throw err;
153
+ }
154
+ return box;
155
+ }
156
+ /** Reattach by VM id (or slug); a paused or stopped VM is started again. */
157
+ async connect(nativeId) {
158
+ const box = new FreestyleBox(this, nativeId);
159
+ await box.waitRunning();
160
+ return box;
161
+ }
162
+ /** @internal */
163
+ get poll() {
164
+ return this.pollMs;
165
+ }
166
+ }
167
+ class FreestyleBox {
168
+ p;
169
+ nativeId;
170
+ env;
171
+ constructor(p, nativeId, env) {
172
+ this.p = p;
173
+ this.nativeId = nativeId;
174
+ this.env = env;
175
+ }
176
+ get path() {
177
+ return `/v5/vms/${encodeURIComponent(this.nativeId)}`;
178
+ }
179
+ /** Wait until the VM runs, starting it when it is paused or stopped. */
180
+ async waitRunning() {
181
+ const deadline = Date.now() + READY_TIMEOUT_MS;
182
+ let started = false;
183
+ for (;;) {
184
+ const vm = await this.p.json("GET", this.path);
185
+ if (vm.state === "running")
186
+ return vm;
187
+ if ((vm.state === "paused" || vm.state === "stopped") && !started) {
188
+ await this.p.json("POST", `${this.path}/start`, { background: true });
189
+ started = true;
190
+ continue;
191
+ }
192
+ if (Date.now() > deadline)
193
+ throw new ProviderError(ID, `VM ${this.nativeId} still ${vm.state} after ${READY_TIMEOUT_MS / 1000}s`);
194
+ await sleep(Math.min(this.p.poll, 500));
195
+ }
196
+ }
197
+ async exec(cmd, opts = {}) {
198
+ const t0 = Date.now();
199
+ const secs = opts.timeoutSeconds ?? 60;
200
+ const body = {
201
+ command: wrapCommand(cmd, { cwd: opts.cwd }),
202
+ timeoutMs: Math.min(MAX_EXEC_MS, Math.max(1, Math.round(secs * 1000))),
203
+ };
204
+ const env = { ...this.env, ...opts.env };
205
+ if (Object.keys(env).length)
206
+ body.env = env;
207
+ let r;
208
+ try {
209
+ r = await this.p.json("POST", `${this.path}/exec-await`, { json: body, background: true });
210
+ }
211
+ catch (err) {
212
+ // 409 also means "stopped responding mid-command" (VM_NON_RESPONSIVE): never re-run that one.
213
+ if (!(err instanceof ProviderError) || err.status !== 409 || /VM_NON_RESPONSIVE/.test(err.message))
214
+ throw err;
215
+ const vm = await this.p.json("GET", this.path);
216
+ if (vm.state === "running")
217
+ throw err;
218
+ await this.waitRunning();
219
+ r = await this.p.json("POST", `${this.path}/exec-await`, { json: body, background: true });
220
+ }
221
+ if (r.statusCode === null || r.statusCode === undefined)
222
+ throw new ProviderError(ID, `command timed out after ${secs}s`);
223
+ const stdout = r.stdout ?? "";
224
+ const stderr = r.stderr ?? "";
225
+ if (stdout)
226
+ opts.onStdout?.(stdout);
227
+ if (stderr)
228
+ opts.onStderr?.(stderr);
229
+ return { exitCode: r.statusCode, stdout, stderr, durationMs: Date.now() - t0 };
230
+ }
231
+ async writeFile(path, content) {
232
+ const bytes = toBytes(content);
233
+ // Best effort (whether mkdir of an existing directory errors is not documented); the write
234
+ // itself reports any real problem.
235
+ const dir = dirname(path);
236
+ if (dir !== "/")
237
+ await this.p.request("POST", `${this.path}/fs/mkdir`, { json: { path: dir }, background: true }).catch(() => { });
238
+ const q = new URLSearchParams({ path, sha256: createHash("sha256").update(bytes).digest("hex") });
239
+ await this.p.request("PUT", `${this.path}/fs/write?${q}`, { bytes, background: true });
240
+ }
241
+ async readFile(path) {
242
+ const res = await this.p.request("GET", `${this.path}/fs/read?${new URLSearchParams({ path })}`);
243
+ return new Uint8Array(await res.arrayBuffer());
244
+ }
245
+ async pause() {
246
+ await this.p.request("POST", `${this.path}/pause`, { background: true });
247
+ }
248
+ async kill() {
249
+ await this.p.request("DELETE", this.path);
250
+ }
251
+ }
252
+ function round(n) {
253
+ return Math.round(n * 1e5) / 1e5;
254
+ }
255
+ function sleep(ms, signal) {
256
+ return new Promise((resolve, reject) => {
257
+ // Not unref'd: between polls nothing else may keep the process alive.
258
+ const t = setTimeout(resolve, ms);
259
+ signal?.addEventListener("abort", () => (clearTimeout(t), reject(signal.reason)), { once: true });
260
+ });
261
+ }
@@ -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
+ }