@sandboxrouter/sdk 0.3.3 → 0.3.5
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 +55 -435
- package/dist/cli.js +3 -2
- package/dist/client.d.ts +58 -0
- package/dist/client.js +140 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/providers/agentcore.d.ts +9 -0
- package/dist/providers/agentcore.js +44 -3
- package/dist/providers/freestyle.js +11 -4
- package/dist/providers/modal.js +5 -1
- package/dist/providers/sprites.js +4 -3
- package/dist/providers/vercel.js +5 -1
- package/dist/results.js +1 -0
- package/dist/router.d.ts +8 -0
- package/dist/router.js +11 -3
- package/dist/server.d.ts +29 -2
- package/dist/server.js +32 -6
- package/dist/types.d.ts +6 -3
- package/dist/verified.d.ts +2 -0
- package/dist/verified.js +18 -0
- package/package.json +1 -1
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Attempt, ExecOptions, ExecResult, RouteOptions, SandboxSpec } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Client for the sandboxrouter HTTP API: the hosted service (one key, prepaid credits) or your own
|
|
4
|
+
* `sandboxrouter serve`. Provider keys live on the server; this side only needs the one key.
|
|
5
|
+
*
|
|
6
|
+
* const sr = new Client({ apiKey: process.env.SANDBOXROUTER_API_KEY });
|
|
7
|
+
* const sbx = await sr.create({ vcpu: 2, memoryGb: 4 });
|
|
8
|
+
* console.log(sbx.provider, (await sbx.exec("python3 -c 'print(6*7)'")).stdout);
|
|
9
|
+
* await sbx.kill();
|
|
10
|
+
*/
|
|
11
|
+
export declare class Client {
|
|
12
|
+
readonly baseUrl: string;
|
|
13
|
+
private readonly apiKey?;
|
|
14
|
+
private readonly fetch;
|
|
15
|
+
constructor(opts?: {
|
|
16
|
+
apiKey?: string;
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
fetch?: typeof fetch;
|
|
19
|
+
});
|
|
20
|
+
/** @internal */
|
|
21
|
+
request(method: string, path: string, body?: unknown, accept?: string): Promise<Response>;
|
|
22
|
+
private json;
|
|
23
|
+
/** Providers this server can route to, with list prices. */
|
|
24
|
+
providers(): Promise<{
|
|
25
|
+
id: string;
|
|
26
|
+
configured: boolean;
|
|
27
|
+
estimatedHourlyUsd: number;
|
|
28
|
+
}[]>;
|
|
29
|
+
/** Dry run: which providers would be tried, in order. */
|
|
30
|
+
route(spec?: SandboxSpec, route?: RouteOptions): Promise<{
|
|
31
|
+
provider: string;
|
|
32
|
+
estimatedHourlyUsd: number;
|
|
33
|
+
skip?: string;
|
|
34
|
+
}[]>;
|
|
35
|
+
/** Start a sandbox on the first provider that works, as the server routes it. */
|
|
36
|
+
create(spec?: SandboxSpec, route?: RouteOptions): Promise<RemoteSandbox>;
|
|
37
|
+
/** A handle on a sandbox you created earlier. */
|
|
38
|
+
sandbox(id: string): RemoteSandbox;
|
|
39
|
+
}
|
|
40
|
+
export declare class ClientError extends Error {
|
|
41
|
+
readonly status: number;
|
|
42
|
+
readonly attempts: Attempt[];
|
|
43
|
+
constructor(status: number, message: string, attempts?: Attempt[]);
|
|
44
|
+
}
|
|
45
|
+
export declare class RemoteSandbox {
|
|
46
|
+
private readonly client;
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly provider: string;
|
|
49
|
+
readonly estimatedHourlyUsd?: number | undefined;
|
|
50
|
+
readonly attempts: Attempt[];
|
|
51
|
+
constructor(client: Client, id: string, provider: string, estimatedHourlyUsd?: number | undefined, attempts?: Attempt[]);
|
|
52
|
+
private path;
|
|
53
|
+
exec(cmd: string, opts?: ExecOptions): Promise<ExecResult>;
|
|
54
|
+
writeFile(path: string, content: string | Uint8Array): Promise<void>;
|
|
55
|
+
readFile(path: string): Promise<Uint8Array>;
|
|
56
|
+
pause(): Promise<void>;
|
|
57
|
+
kill(): Promise<void>;
|
|
58
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the sandboxrouter HTTP API: the hosted service (one key, prepaid credits) or your own
|
|
3
|
+
* `sandboxrouter serve`. Provider keys live on the server; this side only needs the one key.
|
|
4
|
+
*
|
|
5
|
+
* const sr = new Client({ apiKey: process.env.SANDBOXROUTER_API_KEY });
|
|
6
|
+
* const sbx = await sr.create({ vcpu: 2, memoryGb: 4 });
|
|
7
|
+
* console.log(sbx.provider, (await sbx.exec("python3 -c 'print(6*7)'")).stdout);
|
|
8
|
+
* await sbx.kill();
|
|
9
|
+
*/
|
|
10
|
+
export class Client {
|
|
11
|
+
baseUrl;
|
|
12
|
+
apiKey;
|
|
13
|
+
fetch;
|
|
14
|
+
constructor(opts = {}) {
|
|
15
|
+
this.baseUrl = (opts.baseUrl ?? process.env.SANDBOXROUTER_URL ?? DEFAULT_URL).replace(/\/+$/, "");
|
|
16
|
+
this.apiKey = opts.apiKey ?? process.env.SANDBOXROUTER_API_KEY ?? process.env.SANDBOXROUTER_TOKEN;
|
|
17
|
+
this.fetch = opts.fetch ?? globalThis.fetch;
|
|
18
|
+
}
|
|
19
|
+
/** @internal */
|
|
20
|
+
async request(method, path, body, accept = "application/json") {
|
|
21
|
+
const raw = body instanceof Uint8Array;
|
|
22
|
+
const res = await this.fetch(this.baseUrl + path, {
|
|
23
|
+
method,
|
|
24
|
+
headers: {
|
|
25
|
+
Accept: accept,
|
|
26
|
+
...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
|
|
27
|
+
...(body === undefined ? {} : { "Content-Type": raw ? "application/octet-stream" : "application/json" }),
|
|
28
|
+
},
|
|
29
|
+
body: body === undefined ? undefined : raw ? body : JSON.stringify(body),
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
const text = await res.text();
|
|
33
|
+
let parsed = {};
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(text);
|
|
36
|
+
}
|
|
37
|
+
catch { }
|
|
38
|
+
throw new ClientError(res.status, parsed.error ?? (text || res.statusText), parsed.attempts ?? []);
|
|
39
|
+
}
|
|
40
|
+
return res;
|
|
41
|
+
}
|
|
42
|
+
async json(method, path, body) {
|
|
43
|
+
return (await this.request(method, path, body)).json();
|
|
44
|
+
}
|
|
45
|
+
/** Providers this server can route to, with list prices. */
|
|
46
|
+
async providers() {
|
|
47
|
+
return (await this.json("GET", "/v1/providers")).providers;
|
|
48
|
+
}
|
|
49
|
+
/** Dry run: which providers would be tried, in order. */
|
|
50
|
+
async route(spec = {}, route = {}) {
|
|
51
|
+
return (await this.json("POST", "/v1/route", { ...spec, ...route })).candidates;
|
|
52
|
+
}
|
|
53
|
+
/** Start a sandbox on the first provider that works, as the server routes it. */
|
|
54
|
+
async create(spec = {}, route = {}) {
|
|
55
|
+
const r = await this.json("POST", "/v1/sandboxes", {
|
|
56
|
+
...spec,
|
|
57
|
+
...route,
|
|
58
|
+
});
|
|
59
|
+
return new RemoteSandbox(this, r.id, r.provider, r.estimatedHourlyUsd, r.attempts);
|
|
60
|
+
}
|
|
61
|
+
/** A handle on a sandbox you created earlier. */
|
|
62
|
+
sandbox(id) {
|
|
63
|
+
return new RemoteSandbox(this, id, id.split(":")[0]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const DEFAULT_URL = "http://127.0.0.1:8787";
|
|
67
|
+
export class ClientError extends Error {
|
|
68
|
+
status;
|
|
69
|
+
attempts;
|
|
70
|
+
constructor(status, message, attempts = []) {
|
|
71
|
+
super(`${status}: ${message}`);
|
|
72
|
+
this.status = status;
|
|
73
|
+
this.attempts = attempts;
|
|
74
|
+
this.name = "ClientError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export class RemoteSandbox {
|
|
78
|
+
client;
|
|
79
|
+
id;
|
|
80
|
+
provider;
|
|
81
|
+
estimatedHourlyUsd;
|
|
82
|
+
attempts;
|
|
83
|
+
constructor(client, id, provider, estimatedHourlyUsd, attempts = []) {
|
|
84
|
+
this.client = client;
|
|
85
|
+
this.id = id;
|
|
86
|
+
this.provider = provider;
|
|
87
|
+
this.estimatedHourlyUsd = estimatedHourlyUsd;
|
|
88
|
+
this.attempts = attempts;
|
|
89
|
+
}
|
|
90
|
+
path(suffix = "") {
|
|
91
|
+
return `/v1/sandboxes/${encodeURIComponent(this.id)}${suffix}`;
|
|
92
|
+
}
|
|
93
|
+
async exec(cmd, opts = {}) {
|
|
94
|
+
const { onStdout, onStderr, ...rest } = opts;
|
|
95
|
+
const body = { cmd, ...rest };
|
|
96
|
+
if (!onStdout && !onStderr)
|
|
97
|
+
return (await this.client.request("POST", this.path("/exec"), body)).json();
|
|
98
|
+
const res = await this.client.request("POST", this.path("/exec"), { ...body, stream: true }, "text/event-stream");
|
|
99
|
+
const decoder = new TextDecoder();
|
|
100
|
+
let buf = "";
|
|
101
|
+
for await (const chunk of res.body) {
|
|
102
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
103
|
+
let end;
|
|
104
|
+
while ((end = buf.indexOf("\n\n")) >= 0) {
|
|
105
|
+
const block = buf.slice(0, end);
|
|
106
|
+
buf = buf.slice(end + 2);
|
|
107
|
+
const event = /^event: ?(.*)$/m.exec(block)?.[1] ?? "message";
|
|
108
|
+
const data = block
|
|
109
|
+
.split("\n")
|
|
110
|
+
.filter((l) => l.startsWith("data:"))
|
|
111
|
+
.map((l) => l.slice(l[5] === " " ? 6 : 5))
|
|
112
|
+
.join("\n");
|
|
113
|
+
const payload = JSON.parse(data);
|
|
114
|
+
if (event === "stdout")
|
|
115
|
+
onStdout?.(payload);
|
|
116
|
+
else if (event === "stderr")
|
|
117
|
+
onStderr?.(payload);
|
|
118
|
+
else if (event === "result")
|
|
119
|
+
return payload;
|
|
120
|
+
else if (event === "error")
|
|
121
|
+
throw new ClientError(502, payload.error ?? data);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
throw new ClientError(502, "stream ended before the command finished");
|
|
125
|
+
}
|
|
126
|
+
async writeFile(path, content) {
|
|
127
|
+
const data = typeof content === "string" ? new TextEncoder().encode(content) : content;
|
|
128
|
+
await this.client.request("PUT", this.path(`/files?path=${encodeURIComponent(path)}`), data);
|
|
129
|
+
}
|
|
130
|
+
async readFile(path) {
|
|
131
|
+
const res = await this.client.request("GET", this.path(`/files?path=${encodeURIComponent(path)}`), undefined, "application/octet-stream");
|
|
132
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
133
|
+
}
|
|
134
|
+
async pause() {
|
|
135
|
+
await this.client.request("POST", this.path("/pause"));
|
|
136
|
+
}
|
|
137
|
+
async kill() {
|
|
138
|
+
await this.client.request("DELETE", this.path());
|
|
139
|
+
}
|
|
140
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,8 @@ export { conformanceTable, loadConformance, type ConformanceRow, type SavedCheck
|
|
|
4
4
|
export { gc, listOpen, isGone, type GcResult, type OpenSandbox } from "./gc.js";
|
|
5
5
|
export { Router, RoutedSandbox, defaultProviders, defaultComputerProviders, parseId, type Candidate, type RouterOptions } from "./router.js";
|
|
6
6
|
export { CATALOG, hourlyCost, unfit, info, type ProviderInfo } from "./catalog.js";
|
|
7
|
-
export { createApp } from "./server.js";
|
|
7
|
+
export { createApp, HttpError, type ServerHooks } from "./server.js";
|
|
8
|
+
export { Client, ClientError, RemoteSandbox } from "./client.js";
|
|
8
9
|
export { RoutedComputer, createComputer, planComputer, computerCatalog, computerHourly, COMPUTER_ENTRIES, type ComputerCatalogEntry, type ComputerCandidate, type ComputerProviderId, type ActionResult, type ComputerAction, type ComputerActionName, type ComputerInfo, type ComputerOs, type ComputerProvider, type ComputerSpec, type ProviderComputer, } from "./computers.js";
|
|
9
10
|
export { bench, benchMarkdown, type BenchRow } from "./bench.js";
|
|
10
11
|
export { aggregate, loadReports, makeReport, renderLeaderboard, type BenchReport, type LeaderRow } from "./leaderboard.js";
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,8 @@ export { conformanceTable, loadConformance } from "./conformance.js";
|
|
|
4
4
|
export { gc, listOpen, isGone } from "./gc.js";
|
|
5
5
|
export { Router, RoutedSandbox, defaultProviders, defaultComputerProviders, parseId } from "./router.js";
|
|
6
6
|
export { CATALOG, hourlyCost, unfit, info } from "./catalog.js";
|
|
7
|
-
export { createApp } from "./server.js";
|
|
7
|
+
export { createApp, HttpError } from "./server.js";
|
|
8
|
+
export { Client, ClientError, RemoteSandbox } from "./client.js";
|
|
8
9
|
export { RoutedComputer, createComputer, planComputer, computerCatalog, computerHourly, COMPUTER_ENTRIES, } from "./computers.js";
|
|
9
10
|
export { bench, benchMarkdown } from "./bench.js";
|
|
10
11
|
export { aggregate, loadReports, makeReport, renderLeaderboard } from "./leaderboard.js";
|
|
@@ -69,3 +69,12 @@ export declare class AgentCoreProvider implements Provider {
|
|
|
69
69
|
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
70
70
|
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Splits exec's combined terminal output at the stderr marker: the command's stdout comes before
|
|
74
|
+
* it, its stderr (base64) after. If the terminal still translated newlines (stty unavailable), the
|
|
75
|
+
* marker is preceded by \r\n and stdout's \r\n are turned back into \n.
|
|
76
|
+
*/
|
|
77
|
+
export declare function splitOutput(combined: string, marker: string): {
|
|
78
|
+
stdout: string;
|
|
79
|
+
stderr: string;
|
|
80
|
+
};
|
|
@@ -386,14 +386,39 @@ class AgentCoreSandbox {
|
|
|
386
386
|
const timeout = Math.max(1, Math.round(opts.timeoutSeconds ?? 60));
|
|
387
387
|
const full = wrapCommand(cmd, { cwd: opts.cwd, env: { ...this.env, ...opts.env } });
|
|
388
388
|
const b64 = Buffer.from(full).toString("base64");
|
|
389
|
+
// The service runs commands in a terminal: stdout and stderr come back as one stream (labelled
|
|
390
|
+
// "stdout" on exit 0 and "stderr" otherwise), with \n turned into \r\n. So stderr goes to a file
|
|
391
|
+
// and is printed base64-encoded after a marker, and `stty -onlcr` keeps newlines as they are.
|
|
392
|
+
const nonce = randomBytes(6).toString("hex");
|
|
393
|
+
const errFile = `/tmp/.sbr-stderr-${nonce}`;
|
|
394
|
+
const marker = `__SBR_STDERR_${nonce}__`;
|
|
389
395
|
// base64 keeps the user's command intact through whatever quoting the service applies.
|
|
390
|
-
const command = `timeout -k 5 ${timeout} bash -c "$(echo ${b64} | base64 -d)"
|
|
391
|
-
|
|
396
|
+
const command = `stty -onlcr 2>/dev/null; timeout -k 5 ${timeout} bash -c "$(echo ${b64} | base64 -d)" 2>${errFile}; c=$?; ` +
|
|
397
|
+
`printf '\\n${marker}\\n'; base64 < ${errFile} | tr -d '\\r\\n'; rm -f ${errFile}; exit $c`;
|
|
398
|
+
// Stream stdout as it arrives, holding back only what could be the start of the marker.
|
|
399
|
+
let combined = "";
|
|
400
|
+
let sent = 0;
|
|
401
|
+
const feed = (chunk) => {
|
|
402
|
+
combined += chunk;
|
|
403
|
+
if (!opts.onStdout)
|
|
404
|
+
return;
|
|
405
|
+
const at = combined.indexOf(`\n${marker}`);
|
|
406
|
+
const safe = at >= 0 ? at : Math.max(sent, combined.length - marker.length - 2);
|
|
407
|
+
if (safe > sent)
|
|
408
|
+
opts.onStdout(combined.slice(sent, safe));
|
|
409
|
+
sent = Math.max(sent, safe);
|
|
410
|
+
};
|
|
411
|
+
const r = await this.command(command, { onStdout: feed, onStderr: feed, timeoutMs: (timeout + 60) * 1000 });
|
|
392
412
|
const durationMs = Date.now() - t0;
|
|
413
|
+
const { stdout, stderr } = splitOutput(combined || r.stdout + r.stderr, marker);
|
|
414
|
+
if (opts.onStdout && stdout.length > sent)
|
|
415
|
+
opts.onStdout(stdout.slice(sent));
|
|
416
|
+
if (stderr)
|
|
417
|
+
opts.onStderr?.(stderr);
|
|
393
418
|
// `timeout` leaves exit 124 (or 137 after SIGKILL) when it had to stop the command.
|
|
394
419
|
if (killedByTimeout(r.exitCode, durationMs, timeout))
|
|
395
420
|
throw timeoutError(ID, timeout);
|
|
396
|
-
return {
|
|
421
|
+
return { stdout, stderr, exitCode: r.exitCode, durationMs };
|
|
397
422
|
}
|
|
398
423
|
async writeFile(path, content) {
|
|
399
424
|
const bytes = toBytes(content);
|
|
@@ -426,6 +451,22 @@ class AgentCoreSandbox {
|
|
|
426
451
|
}
|
|
427
452
|
}
|
|
428
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* Splits exec's combined terminal output at the stderr marker: the command's stdout comes before
|
|
456
|
+
* it, its stderr (base64) after. If the terminal still translated newlines (stty unavailable), the
|
|
457
|
+
* marker is preceded by \r\n and stdout's \r\n are turned back into \n.
|
|
458
|
+
*/
|
|
459
|
+
export function splitOutput(combined, marker) {
|
|
460
|
+
const at = combined.lastIndexOf(`\n${marker}`);
|
|
461
|
+
if (at < 0)
|
|
462
|
+
return { stdout: combined, stderr: "" };
|
|
463
|
+
const translated = combined[at - 1] === "\r";
|
|
464
|
+
let stdout = combined.slice(0, translated ? at - 1 : at);
|
|
465
|
+
if (translated)
|
|
466
|
+
stdout = stdout.replace(/\r\n/g, "\n");
|
|
467
|
+
const b64 = combined.slice(at + marker.length + 1).replace(/\s/g, "");
|
|
468
|
+
return { stdout, stderr: Buffer.from(b64, "base64").toString("utf8") };
|
|
469
|
+
}
|
|
429
470
|
function exceptionStatus(name) {
|
|
430
471
|
const n = name.toLowerCase();
|
|
431
472
|
if (n.startsWith("accessdenied"))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { ProviderError } from "../types.js";
|
|
3
|
-
import { dirname, toBytes, wrapCommand } from "../util.js";
|
|
3
|
+
import { dirname, shq, toBytes, wrapCommand } from "../util.js";
|
|
4
4
|
/**
|
|
5
5
|
* Freestyle VMs over the public REST API (no SDK needed). Auth: Authorization: Bearer
|
|
6
6
|
* <FREESTYLE_API_KEY>, base https://api.freestyle.sh. Errors are {code, message}.
|
|
@@ -230,11 +230,18 @@ class FreestyleBox {
|
|
|
230
230
|
}
|
|
231
231
|
async writeFile(path, content) {
|
|
232
232
|
const bytes = toBytes(content);
|
|
233
|
-
//
|
|
233
|
+
// The file API runs as root, so a folder it creates belongs to root and commands (which run as
|
|
234
|
+
// the image's user) can't write in it. Make the folder with a command instead, as that user;
|
|
235
|
+
// fall back to the file API where the user can't (e.g. under /root). Best effort: the write
|
|
234
236
|
// itself reports any real problem.
|
|
235
237
|
const dir = dirname(path);
|
|
236
|
-
if (dir !== "/")
|
|
237
|
-
await this.p
|
|
238
|
+
if (dir !== "/") {
|
|
239
|
+
const r = await this.p
|
|
240
|
+
.json("POST", `${this.path}/exec-await`, { json: { command: `mkdir -p ${shq(dir)}`, timeoutMs: 30_000 }, background: true })
|
|
241
|
+
.catch(() => undefined);
|
|
242
|
+
if (r?.statusCode !== 0)
|
|
243
|
+
await this.p.request("POST", `${this.path}/fs/mkdir`, { json: { path: dir }, background: true }).catch(() => { });
|
|
244
|
+
}
|
|
238
245
|
const q = new URLSearchParams({ path, sha256: createHash("sha256").update(bytes).digest("hex") });
|
|
239
246
|
await this.p.request("PUT", `${this.path}/fs/write?${q}`, { bytes, background: true });
|
|
240
247
|
}
|
package/dist/providers/modal.js
CHANGED
|
@@ -75,7 +75,11 @@ class ModalBox {
|
|
|
75
75
|
catch (err) {
|
|
76
76
|
if (err instanceof ProviderError)
|
|
77
77
|
throw err;
|
|
78
|
-
|
|
78
|
+
const message = err?.message ?? String(err);
|
|
79
|
+
// Newer SDKs end the output streams with "Deadline exceeded" at timeoutMs instead of a signal exit.
|
|
80
|
+
if (/deadline exceeded/i.test(message) && killedByTimeout(-1, Date.now() - t0, seconds))
|
|
81
|
+
throw timeoutError("modal", seconds);
|
|
82
|
+
throw new ProviderError("modal", message);
|
|
79
83
|
}
|
|
80
84
|
}
|
|
81
85
|
async writeFile(path, content) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { StringDecoder } from "node:string_decoder";
|
|
3
3
|
import { ProviderError } from "../types.js";
|
|
4
|
-
import { optionalImport, toBytes } from "../util.js";
|
|
4
|
+
import { optionalImport, timeoutError, toBytes } from "../util.js";
|
|
5
5
|
/**
|
|
6
6
|
* Fly.io Sprites via `@fly/sprites`. A sprite is a persistent machine named by the caller, so the
|
|
7
7
|
* name is the native id. Sprites idle on their own and have no lifetime knob: kill() deletes it.
|
|
@@ -78,9 +78,10 @@ class SpritesBox {
|
|
|
78
78
|
try {
|
|
79
79
|
const exitCode = await new Promise((resolve, reject) => {
|
|
80
80
|
const timer = setTimeout(() => {
|
|
81
|
+
// Reject first: killing can report an exit (-1) right away, and that must not count as the result.
|
|
82
|
+
reject(timeoutError("sprites", limit / 1000));
|
|
81
83
|
proc.kill("SIGKILL");
|
|
82
84
|
proc.close();
|
|
83
|
-
reject(new ProviderError("sprites", `command timed out after ${limit / 1000}s`));
|
|
84
85
|
}, limit);
|
|
85
86
|
timer.unref();
|
|
86
87
|
proc.on("exit", (code) => {
|
|
@@ -89,8 +90,8 @@ class SpritesBox {
|
|
|
89
90
|
});
|
|
90
91
|
proc.on("error", (e) => {
|
|
91
92
|
clearTimeout(timer);
|
|
92
|
-
proc.close();
|
|
93
93
|
reject(e);
|
|
94
|
+
proc.close();
|
|
94
95
|
});
|
|
95
96
|
});
|
|
96
97
|
const so = out.end();
|
package/dist/providers/vercel.js
CHANGED
|
@@ -27,6 +27,9 @@ export class VercelProvider {
|
|
|
27
27
|
resources: { vcpus },
|
|
28
28
|
env: spec.env,
|
|
29
29
|
tags: spec.metadata,
|
|
30
|
+
// v3 keeps sandboxes by default and restarts a stopped one on the next command; a router
|
|
31
|
+
// sandbox is single-use, so nothing should outlive kill().
|
|
32
|
+
persistent: false,
|
|
30
33
|
});
|
|
31
34
|
return new VercelBox(sbx);
|
|
32
35
|
}
|
|
@@ -93,6 +96,7 @@ class VercelBox {
|
|
|
93
96
|
return new Uint8Array(buf);
|
|
94
97
|
}
|
|
95
98
|
async kill() {
|
|
96
|
-
|
|
99
|
+
// stop() only ends the session: the next SDK call would start it again. delete() makes it inert.
|
|
100
|
+
await this.sbx.delete({ deleteOrphanSnapshots: true });
|
|
97
101
|
}
|
|
98
102
|
}
|
package/dist/results.js
CHANGED
|
@@ -35,6 +35,7 @@ const MAX_MS = 15 * 60_000;
|
|
|
35
35
|
const SECRET_SHAPES = [
|
|
36
36
|
["Stripe-style or Sail key", /\bsk_(?:live_|test_)?[A-Za-z0-9]{16,}/],
|
|
37
37
|
["OpenAI-style key", /\bsk-[A-Za-z0-9_-]{20,}/],
|
|
38
|
+
["sandboxrouter key", /\bsr_live_[A-Za-z0-9_-]{20,}/],
|
|
38
39
|
["E2B key", /\be2b_[A-Za-z0-9]{20,}/],
|
|
39
40
|
["Daytona key", /\bdtn_[A-Za-z0-9]{20,}/],
|
|
40
41
|
["LangSmith key", /\blsv2_(?:sk|pt)_[A-Za-z0-9_]{20,}/],
|
package/dist/router.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export interface Candidate {
|
|
|
5
5
|
provider: ProviderId;
|
|
6
6
|
estimatedHourlyUsd: number;
|
|
7
7
|
medianCreateMs?: number;
|
|
8
|
+
/** Passed the real-account check (as of this release, or `RouterOptions.verified`). */
|
|
9
|
+
verified: boolean;
|
|
8
10
|
/** Why this provider won't be tried, if it won't. */
|
|
9
11
|
skip?: string;
|
|
10
12
|
}
|
|
@@ -21,6 +23,11 @@ export interface RouterOptions {
|
|
|
21
23
|
* false to keep no record. SANDBOXROUTER_LEDGER=off also turns it off.
|
|
22
24
|
*/
|
|
23
25
|
ledger?: string | false;
|
|
26
|
+
/**
|
|
27
|
+
* Providers the default "verified" strategy tries first. Default: those whose last real-account check
|
|
28
|
+
* passed as of this release (`VERIFIED`). Pass your own list, e.g. after running `check` yourself.
|
|
29
|
+
*/
|
|
30
|
+
verified?: ProviderId[];
|
|
24
31
|
}
|
|
25
32
|
/** Desktop-only providers. Filled in as adapters are added. */
|
|
26
33
|
export declare function defaultComputerProviders(): ComputerProvider[];
|
|
@@ -28,6 +35,7 @@ export declare function defaultProviders(): Provider[];
|
|
|
28
35
|
export declare class Router {
|
|
29
36
|
private providers;
|
|
30
37
|
private benchFile?;
|
|
38
|
+
private readonly verified;
|
|
31
39
|
private computerOnly;
|
|
32
40
|
/** The local record of sandboxes this router created, or undefined when turned off. */
|
|
33
41
|
readonly ledger?: Ledger;
|
package/dist/router.js
CHANGED
|
@@ -3,6 +3,7 @@ import { RoutedComputer, createComputer, planComputer, } from "./computers.js";
|
|
|
3
3
|
import { loadBench, median } from "./bench-store.js";
|
|
4
4
|
import { Ledger, defaultLedgerFile } from "./ledger.js";
|
|
5
5
|
import { DEFAULT_EXEC_TIMEOUT_S, errorStatus, timeoutError } from "./util.js";
|
|
6
|
+
import { VERIFIED } from "./verified.js";
|
|
6
7
|
import { DaytonaProvider } from "./providers/daytona.js";
|
|
7
8
|
import { E2BProvider } from "./providers/e2b.js";
|
|
8
9
|
import { MaritimeProvider } from "./providers/maritime.js";
|
|
@@ -87,6 +88,7 @@ export function defaultProviders() {
|
|
|
87
88
|
export class Router {
|
|
88
89
|
providers;
|
|
89
90
|
benchFile;
|
|
91
|
+
verified;
|
|
90
92
|
computerOnly;
|
|
91
93
|
/** The local record of sandboxes this router created, or undefined when turned off. */
|
|
92
94
|
ledger;
|
|
@@ -96,6 +98,7 @@ export class Router {
|
|
|
96
98
|
this.ledger = new Ledger(file);
|
|
97
99
|
this.providers = new Map((opts.providers ?? defaultProviders()).map((p) => [p.id, p]));
|
|
98
100
|
this.benchFile = opts.benchFile;
|
|
101
|
+
this.verified = new Set(opts.verified ?? Object.keys(VERIFIED));
|
|
99
102
|
this.computerOnly = opts.computers ?? (opts.providers ? [] : defaultComputerProviders());
|
|
100
103
|
}
|
|
101
104
|
/** Registered and has credentials. False for providers this router doesn't know. */
|
|
@@ -122,12 +125,13 @@ export class Router {
|
|
|
122
125
|
const list = ids.map((id) => {
|
|
123
126
|
// An id the catalog has never heard of (a typo in an explicit list) has no price to look up.
|
|
124
127
|
if (!CATALOG.some((c) => c.id === id))
|
|
125
|
-
return { provider: id, estimatedHourlyUsd: 0, skip: "unknown provider" };
|
|
128
|
+
return { provider: id, estimatedHourlyUsd: 0, verified: false, skip: "unknown provider" };
|
|
126
129
|
const p = this.providers.get(id);
|
|
127
130
|
const c = {
|
|
128
131
|
provider: id,
|
|
129
132
|
estimatedHourlyUsd: round(hourlyCost(id, spec)),
|
|
130
133
|
medianCreateMs: median(bench[id]?.createMs),
|
|
134
|
+
verified: this.verified.has(id),
|
|
131
135
|
};
|
|
132
136
|
if (!p)
|
|
133
137
|
c.skip = "no adapter registered";
|
|
@@ -137,16 +141,20 @@ export class Router {
|
|
|
137
141
|
}
|
|
138
142
|
return c;
|
|
139
143
|
});
|
|
140
|
-
// Neutral by construction:
|
|
144
|
+
// Neutral by construction: the same rule for every provider, ties alphabetical by id. The default
|
|
145
|
+
// puts providers that passed the real-account check first; any provider gets there by passing it.
|
|
141
146
|
// An explicit provider list keeps the caller's order.
|
|
142
147
|
if (!explicit) {
|
|
143
148
|
const byPrice = (a, b) => a.estimatedHourlyUsd - b.estimatedHourlyUsd || a.provider.localeCompare(b.provider);
|
|
144
149
|
if (route.strategy === "fastest") {
|
|
145
150
|
list.sort((a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity) || byPrice(a, b));
|
|
146
151
|
}
|
|
147
|
-
else {
|
|
152
|
+
else if (route.strategy === "cheapest") {
|
|
148
153
|
list.sort(byPrice);
|
|
149
154
|
}
|
|
155
|
+
else {
|
|
156
|
+
list.sort((a, b) => Number(b.verified) - Number(a.verified) || byPrice(a, b));
|
|
157
|
+
}
|
|
150
158
|
}
|
|
151
159
|
return list;
|
|
152
160
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
-
import { Router } from "./router.js";
|
|
2
|
+
import { Router, type RoutedSandbox } from "./router.js";
|
|
3
|
+
import { type RouteOptions, type SandboxSpec } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Hooks for running this API for many users (accounts, billing, per-user sandboxes). With none set,
|
|
6
|
+
* the server is single-user: one SANDBOXROUTER_TOKEN, every sandbox reachable by anyone holding it.
|
|
7
|
+
*/
|
|
8
|
+
export interface ServerHooks {
|
|
9
|
+
/** Who is calling, or null for 401. Replaces the single-token check. */
|
|
10
|
+
authorize?(req: IncomingMessage): Promise<unknown | null>;
|
|
11
|
+
/** Routes outside /v1 (sign-in, billing webhooks, pages), checked before auth. Return true if handled. */
|
|
12
|
+
extra?(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean>;
|
|
13
|
+
/** Before a sandbox starts. Throw HttpError to refuse (402 out of credit, 429 too many); may edit spec. */
|
|
14
|
+
beforeCreate?(caller: unknown, spec: SandboxSpec, route: RouteOptions): Promise<void>;
|
|
15
|
+
/** After it started. If this throws, the sandbox is killed and the error returned. */
|
|
16
|
+
afterCreate?(caller: unknown, sbx: RoutedSandbox, spec: SandboxSpec): Promise<void>;
|
|
17
|
+
/** May this caller use this sandbox? No means 404. */
|
|
18
|
+
owns?(caller: unknown, id: string): Promise<boolean>;
|
|
19
|
+
/** After DELETE killed it. */
|
|
20
|
+
afterKill?(caller: unknown, id: string): Promise<void>;
|
|
21
|
+
/** false turns off /v1/computers. */
|
|
22
|
+
computers?: boolean;
|
|
23
|
+
/** false turns off pause. */
|
|
24
|
+
pause?: boolean;
|
|
25
|
+
}
|
|
3
26
|
/**
|
|
4
27
|
* OpenRouter-style HTTP API in front of every provider. One token for your clients; provider keys
|
|
5
28
|
* stay on this host.
|
|
@@ -28,4 +51,8 @@ import { Router } from "./router.js";
|
|
|
28
51
|
*/
|
|
29
52
|
export declare function createApp(router?: Router, token?: string | undefined,
|
|
30
53
|
/** Browser origins allowed to call this server (e.g. the website's Playground). Off by default. */
|
|
31
|
-
corsOrigins?: string[]): import("node:http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
54
|
+
corsOrigins?: string[], hooks?: ServerHooks): import("node:http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
55
|
+
export declare class HttpError extends Error {
|
|
56
|
+
readonly status: number;
|
|
57
|
+
constructor(status: number, message: string);
|
|
58
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -31,11 +31,11 @@ import { ProviderError, RouterError } from "./types.js";
|
|
|
31
31
|
*/
|
|
32
32
|
export function createApp(router = new Router(), token = process.env.SANDBOXROUTER_TOKEN,
|
|
33
33
|
/** Browser origins allowed to call this server (e.g. the website's Playground). Off by default. */
|
|
34
|
-
corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
34
|
+
corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.trim()).filter(Boolean), hooks = {}) {
|
|
35
35
|
// With CORS on, any page open in a browser on this machine can call the server (Chrome even
|
|
36
36
|
// lets a public page reach localhost, which the Private-Network header below allows), so it
|
|
37
37
|
// would start sandboxes on these provider keys. A token is required for that.
|
|
38
|
-
if (corsOrigins.length && !token) {
|
|
38
|
+
if (corsOrigins.length && !token && !hooks.authorize) {
|
|
39
39
|
throw new Error("CORS is on but SANDBOXROUTER_TOKEN is not set: browser pages could start sandboxes on your keys. Set a token, or turn CORS off.");
|
|
40
40
|
}
|
|
41
41
|
const expected = token ? Buffer.from(`Bearer ${token}`) : undefined;
|
|
@@ -71,11 +71,23 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
71
71
|
}
|
|
72
72
|
if (url.pathname === "/health")
|
|
73
73
|
return json(res, 200, { ok: true });
|
|
74
|
-
if (
|
|
74
|
+
if (hooks.extra && (await hooks.extra(req, res, url)))
|
|
75
|
+
return;
|
|
76
|
+
let caller = null;
|
|
77
|
+
if (hooks.authorize) {
|
|
78
|
+
caller = await hooks.authorize(req);
|
|
79
|
+
if (caller === null)
|
|
80
|
+
return json(res, 401, { error: "unauthorized" });
|
|
81
|
+
}
|
|
82
|
+
else if (!authorized(req))
|
|
75
83
|
return json(res, 401, { error: "unauthorized" });
|
|
76
84
|
if (parts[0] !== "v1")
|
|
77
85
|
return json(res, 404, { error: "not found" });
|
|
78
|
-
const box = async (id) =>
|
|
86
|
+
const box = async (id) => {
|
|
87
|
+
if (hooks.owns && !(await hooks.owns(caller, id)))
|
|
88
|
+
throw new HttpError(404, "no such sandbox");
|
|
89
|
+
return live.get(id) ?? router.get(id);
|
|
90
|
+
};
|
|
79
91
|
// GET /v1/providers
|
|
80
92
|
if (req.method === "GET" && parts[1] === "providers" && parts.length === 2) {
|
|
81
93
|
return json(res, 200, {
|
|
@@ -91,14 +103,25 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
91
103
|
const { spec, route } = splitBody(await readJson(req));
|
|
92
104
|
return json(res, 200, { candidates: router.plan(spec, route) });
|
|
93
105
|
}
|
|
94
|
-
if (parts[1] === "computers")
|
|
106
|
+
if (parts[1] === "computers") {
|
|
107
|
+
if (hooks.computers === false)
|
|
108
|
+
return json(res, 404, { error: "computers aren't available on this server" });
|
|
95
109
|
return computers(req, res, url, parts);
|
|
110
|
+
}
|
|
96
111
|
if (parts[1] !== "sandboxes")
|
|
97
112
|
return json(res, 404, { error: "not found" });
|
|
98
113
|
// POST /v1/sandboxes
|
|
99
114
|
if (req.method === "POST" && parts.length === 2) {
|
|
100
115
|
const { spec, route } = splitBody(await readJson(req));
|
|
116
|
+
await hooks.beforeCreate?.(caller, spec, route);
|
|
101
117
|
const sbx = await router.create(spec, route);
|
|
118
|
+
try {
|
|
119
|
+
await hooks.afterCreate?.(caller, sbx, spec);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
await sbx.kill().catch(() => { });
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
102
125
|
live.set(sbx.id, sbx);
|
|
103
126
|
return json(res, 201, {
|
|
104
127
|
id: sbx.id,
|
|
@@ -132,6 +155,8 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
132
155
|
}
|
|
133
156
|
// POST /v1/sandboxes/:id/pause
|
|
134
157
|
if (req.method === "POST" && parts[3] === "pause" && parts.length === 4) {
|
|
158
|
+
if (hooks.pause === false)
|
|
159
|
+
return json(res, 400, { error: "pause isn't available on this server" });
|
|
135
160
|
await (await box(id)).pause();
|
|
136
161
|
// Drop the cached handle so the next call reattaches through router.get(), which resumes.
|
|
137
162
|
live.delete(id);
|
|
@@ -156,6 +181,7 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
156
181
|
if (req.method === "DELETE" && parts.length === 3) {
|
|
157
182
|
await (await box(id)).kill();
|
|
158
183
|
live.delete(id);
|
|
184
|
+
await hooks.afterKill?.(caller, id);
|
|
159
185
|
return json(res, 200, { id, killed: true });
|
|
160
186
|
}
|
|
161
187
|
return json(res, 404, { error: "not found" });
|
|
@@ -227,7 +253,7 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
227
253
|
});
|
|
228
254
|
});
|
|
229
255
|
}
|
|
230
|
-
class HttpError extends Error {
|
|
256
|
+
export class HttpError extends Error {
|
|
231
257
|
status;
|
|
232
258
|
constructor(status, message) {
|
|
233
259
|
super(message);
|
package/dist/types.d.ts
CHANGED
|
@@ -22,14 +22,17 @@ export interface SandboxSpec {
|
|
|
22
22
|
metadata?: Record<string, string>;
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
|
-
* "
|
|
25
|
+
* "verified" (and "default", the same thing): providers that passed the real-account check first
|
|
26
|
+
* (as of this release; `VERIFIED` in src/verified.ts), then the rest; cheapest first within each,
|
|
27
|
+
* ties alphabetical.
|
|
28
|
+
* "cheapest": lowest list price for the spec, tested or not, ties alphabetical.
|
|
26
29
|
* "fastest": lowest median cold start you measured with `bench`, then cheapest.
|
|
27
30
|
*/
|
|
28
|
-
export type Strategy = "default" | "cheapest" | "fastest";
|
|
31
|
+
export type Strategy = "default" | "verified" | "cheapest" | "fastest";
|
|
29
32
|
export interface RouteOptions {
|
|
30
33
|
/** Pin to one provider, or give an ordered list to try. Omit to let the router choose. */
|
|
31
34
|
provider?: ProviderId | ProviderId[];
|
|
32
|
-
/** How to order candidates when no explicit list is given. Default: cheapest. */
|
|
35
|
+
/** How to order candidates when no explicit list is given. Default: "verified" (tested providers first, then cheapest). */
|
|
33
36
|
strategy?: Strategy;
|
|
34
37
|
/**
|
|
35
38
|
* What to do when a create fails. `true` (default): try the next candidate when the provider
|