@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.
@@ -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
- const r = await this.command(command, { onStdout: opts.onStdout, onStderr: opts.onStderr, timeoutMs: (timeout + 60) * 1000 });
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 { ...r, durationMs };
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
- // Best effort (whether mkdir of an existing directory errors is not documented); the write
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.request("POST", `${this.path}/fs/mkdir`, { json: { path: dir }, background: true }).catch(() => { });
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
  }
@@ -75,7 +75,11 @@ class ModalBox {
75
75
  catch (err) {
76
76
  if (err instanceof ProviderError)
77
77
  throw err;
78
- throw new ProviderError("modal", err?.message ?? String(err));
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();
@@ -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
- await this.sbx.stop();
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: price (or measured speed) decides, ties break alphabetically by id.
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 (!authorized(req))
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) => live.get(id) ?? router.get(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
- * "cheapest" (and "default", the same thing): lowest list price for the spec, ties alphabetical.
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
@@ -0,0 +1,2 @@
1
+ import type { ProviderId } from "./types.js";
2
+ export declare const VERIFIED: Partial<Record<ProviderId, string>>;