@sandboxrouter/sdk 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -2
  3. package/dist/bench-store.d.ts +12 -0
  4. package/dist/bench-store.js +23 -0
  5. package/dist/bench.d.ts +24 -0
  6. package/dist/bench.js +65 -0
  7. package/dist/catalog.d.ts +105 -0
  8. package/dist/catalog.js +320 -0
  9. package/dist/cli.d.ts +2 -0
  10. package/dist/cli.js +195 -0
  11. package/dist/computers.d.ts +124 -0
  12. package/dist/computers.js +66 -0
  13. package/dist/index.d.ts +23 -0
  14. package/dist/index.js +23 -0
  15. package/dist/leaderboard.d.ts +39 -0
  16. package/dist/leaderboard.js +184 -0
  17. package/dist/providers/blaxel.d.ts +21 -0
  18. package/dist/providers/blaxel.js +152 -0
  19. package/dist/providers/boat.d.ts +25 -0
  20. package/dist/providers/boat.js +258 -0
  21. package/dist/providers/cloudflare.d.ts +30 -0
  22. package/dist/providers/cloudflare.js +147 -0
  23. package/dist/providers/daytona.d.ts +13 -0
  24. package/dist/providers/daytona.js +121 -0
  25. package/dist/providers/docker.d.ts +17 -0
  26. package/dist/providers/docker.js +148 -0
  27. package/dist/providers/e2b.d.ts +10 -0
  28. package/dist/providers/e2b.js +69 -0
  29. package/dist/providers/exedev.d.ts +23 -0
  30. package/dist/providers/exedev.js +278 -0
  31. package/dist/providers/hopx.d.ts +15 -0
  32. package/dist/providers/hopx.js +168 -0
  33. package/dist/providers/maritime.d.ts +38 -0
  34. package/dist/providers/maritime.js +240 -0
  35. package/dist/providers/modal.d.ts +10 -0
  36. package/dist/providers/modal.js +82 -0
  37. package/dist/providers/morph.d.ts +19 -0
  38. package/dist/providers/morph.js +181 -0
  39. package/dist/providers/northflank.d.ts +21 -0
  40. package/dist/providers/northflank.js +261 -0
  41. package/dist/providers/runloop.d.ts +16 -0
  42. package/dist/providers/runloop.js +145 -0
  43. package/dist/providers/sprites.d.ts +15 -0
  44. package/dist/providers/sprites.js +135 -0
  45. package/dist/providers/tensorlake.d.ts +14 -0
  46. package/dist/providers/tensorlake.js +192 -0
  47. package/dist/providers/vercel.d.ts +12 -0
  48. package/dist/providers/vercel.js +87 -0
  49. package/dist/router.d.ts +65 -0
  50. package/dist/router.js +199 -0
  51. package/dist/server.d.ts +31 -0
  52. package/dist/server.js +257 -0
  53. package/dist/sse.d.ts +6 -0
  54. package/dist/sse.js +33 -0
  55. package/dist/types.d.ts +87 -0
  56. package/dist/types.js +19 -0
  57. package/dist/util.d.ts +11 -0
  58. package/dist/util.js +37 -0
  59. package/package.json +127 -4
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Every provider is treated the same: the router orders candidates by list price for the requested
3
+ * size (or by your measured cold starts), ties broken alphabetically. Nobody gets a better slot.
4
+ * Prices come from each provider's public pricing page; PRs that correct one are welcome.
5
+ */
6
+ const ENTRIES = [
7
+ {
8
+ id: "docker",
9
+ name: "Docker (local)",
10
+ pricing: {
11
+ vcpuHour: 0,
12
+ gbHour: 0,
13
+ note: "runs on your own machine; free, but shares your kernel, so weaker isolation than a cloud micro-VM",
14
+ source: "https://docs.docker.com/engine/",
15
+ checked: "2026-11-04",
16
+ },
17
+ capabilities: ["pause", "custom-image"],
18
+ limits: { maxVcpu: 64, maxMemoryGb: 256 },
19
+ local: true,
20
+ credentials: [],
21
+ docs: "https://docs.docker.com/engine/",
22
+ },
23
+ {
24
+ id: "maritime",
25
+ name: "Maritime",
26
+ pricing: {
27
+ vcpuHour: 0.0055,
28
+ gbHour: 0.00275,
29
+ note: "per second, disk included",
30
+ source: "https://maritime.sh/pricing",
31
+ checked: "2026-11-04",
32
+ },
33
+ capabilities: ["pause", "custom-image"],
34
+ limits: { maxVcpu: 16, maxMemoryGb: 64 },
35
+ credentials: [["MARITIME_API_KEY"]],
36
+ docs: "https://maritime.sh/docs/ephemeral-sandboxes",
37
+ },
38
+ {
39
+ id: "e2b",
40
+ name: "E2B",
41
+ pricing: {
42
+ vcpuHour: 0.0504,
43
+ gbHour: 0.0162,
44
+ note: "per second; Pro plan $150/mo",
45
+ source: "https://e2b.dev/pricing",
46
+ checked: "2026-11-04",
47
+ },
48
+ capabilities: ["pause", "custom-image"],
49
+ limits: { maxVcpu: 8, maxMemoryGb: 8 },
50
+ credentials: [["E2B_API_KEY"]],
51
+ docs: "https://e2b.dev/docs",
52
+ },
53
+ {
54
+ id: "daytona",
55
+ name: "Daytona",
56
+ pricing: {
57
+ vcpuHour: 0.0504,
58
+ gbHour: 0.0162,
59
+ note: "per second, plus disk above 5 GiB",
60
+ source: "https://www.daytona.io/pricing",
61
+ checked: "2026-11-04",
62
+ },
63
+ capabilities: ["pause", "custom-image"],
64
+ limits: { maxVcpu: 4, maxMemoryGb: 8 },
65
+ credentials: [["DAYTONA_API_KEY"]],
66
+ docs: "https://www.daytona.io/docs",
67
+ },
68
+ {
69
+ id: "modal",
70
+ name: "Modal",
71
+ pricing: {
72
+ vcpuHour: 0.07096,
73
+ gbHour: 0.0242,
74
+ note: "sandbox rates, GPUs extra; region pinning x1.15-1.75",
75
+ source: "https://modal.com/pricing",
76
+ checked: "2026-11-04",
77
+ },
78
+ capabilities: ["gpu", "custom-image"],
79
+ limits: { maxVcpu: 64, maxMemoryGb: 256 },
80
+ credentials: [["MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET"]],
81
+ docs: "https://modal.com/docs/guide/sandbox",
82
+ },
83
+ {
84
+ id: "vercel",
85
+ name: "Vercel Sandbox",
86
+ pricing: {
87
+ vcpuHour: 0.128,
88
+ gbHour: 0.0212,
89
+ note: "bills active CPU only; this is the 100%-CPU price, idle work costs less",
90
+ source: "https://vercel.com/docs/vercel-sandbox/pricing",
91
+ checked: "2026-11-04",
92
+ },
93
+ capabilities: [],
94
+ limits: { maxVcpu: 8, maxMemoryGb: 16 },
95
+ credentials: [["VERCEL_OIDC_TOKEN"], ["VERCEL_TOKEN", "VERCEL_TEAM_ID", "VERCEL_PROJECT_ID"]],
96
+ docs: "https://vercel.com/docs/vercel-sandbox",
97
+ },
98
+ {
99
+ id: "morph",
100
+ name: "Morph Cloud",
101
+ pricing: {
102
+ vcpuHour: 0.05,
103
+ gbHour: 0,
104
+ note: "$0.05/MCU; MCU-hours = max(vCPU, RAM/4 GB, disk/16 GB); snapshots extra",
105
+ source: "https://cloud.morph.so/web/pricing",
106
+ checked: "2026-11-04",
107
+ },
108
+ capabilities: ["pause", "custom-image"],
109
+ limits: { maxVcpu: 64, maxMemoryGb: 256 },
110
+ credentials: [["MORPH_API_KEY"]],
111
+ docs: "https://cloud.morph.so/docs/developers",
112
+ },
113
+ {
114
+ id: "sprites",
115
+ name: "Fly.io Sprites",
116
+ pricing: {
117
+ vcpuHour: 0.0385,
118
+ gbHour: 0.021875,
119
+ note: "bills actual CPU and memory use; this is the full-use price; storage extra",
120
+ source: "https://fly.io/sprites",
121
+ checked: "2026-11-04",
122
+ },
123
+ capabilities: [],
124
+ // Not published per sprite; 8 GB is what accounts report getting by default.
125
+ limits: { maxVcpu: 8, maxMemoryGb: 8 },
126
+ credentials: [["SPRITES_TOKEN"]],
127
+ docs: "https://fly.io/sprites",
128
+ },
129
+ {
130
+ id: "blaxel",
131
+ name: "Blaxel",
132
+ pricing: {
133
+ vcpuHour: 0,
134
+ gbHour: 0.0414,
135
+ note: "billed on memory only ($0.0000115/GB-s), CPU scales with memory (~2 GB per core)",
136
+ source: "https://blaxel.ai/pricing",
137
+ checked: "2026-11-04",
138
+ },
139
+ capabilities: ["pause", "custom-image"],
140
+ // Self-serve caps are per-tier and not published; these are conservative.
141
+ limits: { maxVcpu: 8, maxMemoryGb: 16 },
142
+ credentials: [["BL_API_KEY", "BL_WORKSPACE"]],
143
+ docs: "https://docs.blaxel.ai/Sandboxes/Overview",
144
+ },
145
+ {
146
+ id: "tensorlake",
147
+ name: "Tensorlake",
148
+ pricing: {
149
+ vcpuHour: 0.07,
150
+ gbHour: 0.015,
151
+ note: "bills active CPU (floor 20% of allocation); this is the full-use price; Pro plan is lower",
152
+ source: "https://www.tensorlake.ai/pricing",
153
+ checked: "2026-11-04",
154
+ },
155
+ capabilities: ["pause", "custom-image"],
156
+ limits: { maxVcpu: 4, maxMemoryGb: 16 },
157
+ credentials: [["TENSORLAKE_API_KEY"]],
158
+ docs: "https://docs.tensorlake.ai/sandboxes",
159
+ },
160
+ {
161
+ id: "runloop",
162
+ name: "Runloop",
163
+ pricing: {
164
+ vcpuHour: 0.108,
165
+ gbHour: 0.0252,
166
+ note: "per second, plus disk; pause (suspend) is a Pro-plan feature",
167
+ source: "https://www.runloop.ai/pricing",
168
+ checked: "2026-11-04",
169
+ },
170
+ capabilities: ["pause", "custom-image"],
171
+ limits: { maxVcpu: 16, maxMemoryGb: 64 },
172
+ credentials: [["RUNLOOP_API_KEY"]],
173
+ docs: "https://docs.runloop.ai",
174
+ },
175
+ ];
176
+ ENTRIES.push({
177
+ id: "boat",
178
+ name: "Boat",
179
+ pricing: {
180
+ vcpuHour: 0.009,
181
+ gbHour: 0,
182
+ note: "fixed sizes billed per second ($0.018/h for 2 vCPU / 4 GB up to $0.20/h for 16 / 32); needs a plan from $20/mo, paid back as VM time",
183
+ source: "https://docs.boat.dev/pricing.md",
184
+ checked: "2026-11-04",
185
+ },
186
+ // "pause" here is Boat's stop: the disk is kept, running processes end.
187
+ capabilities: ["pause", "custom-image"],
188
+ limits: { maxVcpu: 16, maxMemoryGb: 32 },
189
+ credentials: [["BOAT_API_KEY"]],
190
+ docs: "https://docs.boat.dev",
191
+ }, {
192
+ id: "exedev",
193
+ name: "exe.dev",
194
+ pricing: {
195
+ vcpuHour: 0.0525,
196
+ gbHour: 0,
197
+ note: "$0.105/h per 2 vCPU + 4 GiB, per second, up to 2 GiB per vCPU; needs an exe.dev subscription",
198
+ source: "https://exe.dev/docs/standalone-vms.md",
199
+ checked: "2026-11-04",
200
+ },
201
+ capabilities: ["custom-image"],
202
+ limits: { maxVcpu: 16, maxMemoryGb: 32 },
203
+ credentials: [["EXE_TOKEN"]],
204
+ docs: "https://exe.dev/docs/https-api.md",
205
+ }, {
206
+ id: "northflank",
207
+ name: "Northflank",
208
+ pricing: {
209
+ vcpuHour: 0.01667,
210
+ gbHour: 0.00833,
211
+ note: "per second on Northflank Cloud, ephemeral disk included; sandboxes run as deployment services (microVMs)",
212
+ source: "https://northflank.com/pricing",
213
+ checked: "2026-11-04",
214
+ },
215
+ capabilities: ["custom-image"],
216
+ limits: { maxVcpu: 8, maxMemoryGb: 16 },
217
+ credentials: [["NORTHFLANK_API_TOKEN", "NORTHFLANK_PROJECT_ID"]],
218
+ docs: "https://northflank.com/docs/v1/application/overview",
219
+ }, {
220
+ id: "hopx",
221
+ name: "HOPX",
222
+ pricing: {
223
+ vcpuHour: 0.0504,
224
+ gbHour: 0.0162,
225
+ note: "per second, plus storage; size comes from the template, not the request",
226
+ source: "https://hopx.ai/pricing",
227
+ checked: "2026-11-04",
228
+ },
229
+ capabilities: ["pause", "custom-image"],
230
+ // HOPX takes no size at create; capped at the default size so bigger requests go elsewhere.
231
+ limits: { maxVcpu: 2, maxMemoryGb: 4 },
232
+ credentials: [["HOPX_API_KEY"]],
233
+ docs: "https://docs.hopx.ai",
234
+ }, {
235
+ id: "cloudflare",
236
+ name: "Cloudflare Sandbox",
237
+ pricing: {
238
+ vcpuHour: 0.072,
239
+ gbHour: 0.009,
240
+ note: "bills active CPU and provisioned memory of the instance type; this is the full-CPU price; needs Workers Paid ($5/mo) and the bridge Worker in bridges/cloudflare",
241
+ source: "https://developers.cloudflare.com/containers/platform/pricing/",
242
+ checked: "2026-11-04",
243
+ },
244
+ capabilities: [],
245
+ limits: { maxVcpu: 4, maxMemoryGb: 12 },
246
+ credentials: [["CLOUDFLARE_SANDBOX_URL", "CLOUDFLARE_SANDBOX_TOKEN"]],
247
+ docs: "https://developers.cloudflare.com/sandbox/",
248
+ });
249
+ export const CATALOG = [...ENTRIES].sort((a, b) => a.name.localeCompare(b.name));
250
+ /** Cloudflare only rents fixed instance types: https://developers.cloudflare.com/containers/platform/limits/ */
251
+ export const CLOUDFLARE_INSTANCES = [
252
+ { name: "lite", vcpu: 1 / 16, memoryGb: 0.25 },
253
+ { name: "basic", vcpu: 1 / 4, memoryGb: 1 },
254
+ { name: "standard-1", vcpu: 1 / 2, memoryGb: 4 },
255
+ { name: "standard-2", vcpu: 1, memoryGb: 6 },
256
+ { name: "standard-3", vcpu: 2, memoryGb: 8 },
257
+ { name: "standard-4", vcpu: 4, memoryGb: 12 },
258
+ ];
259
+ /** Boat's fixed machine sizes and hourly prices: https://docs.boat.dev/machines.md */
260
+ export const BOAT_SIZES = [
261
+ { type: "small", vcpu: 2, memoryGb: 4, usdHour: 0.018 },
262
+ { type: "default", vcpu: 4, memoryGb: 8, usdHour: 0.036 },
263
+ { type: "large", vcpu: 8, memoryGb: 16, usdHour: 0.072 },
264
+ { type: "xlarge", vcpu: 16, memoryGb: 32, usdHour: 0.2 },
265
+ ];
266
+ export function cloudflareInstance(vcpu, memoryGb) {
267
+ return CLOUDFLARE_INSTANCES.find((t) => t.vcpu >= vcpu && t.memoryGb >= memoryGb) ?? CLOUDFLARE_INSTANCES[CLOUDFLARE_INSTANCES.length - 1];
268
+ }
269
+ export function info(id) {
270
+ const p = CATALOG.find((c) => c.id === id);
271
+ if (!p)
272
+ throw new Error(`unknown provider: ${id}`);
273
+ return p;
274
+ }
275
+ export const DEFAULT_VCPU = 2;
276
+ export const DEFAULT_MEMORY_GB = 4;
277
+ /** Estimated USD per hour for this spec on this provider (list price, while running). */
278
+ export function hourlyCost(id, spec = {}) {
279
+ const { pricing } = info(id);
280
+ const vcpu = spec.vcpu ?? DEFAULT_VCPU;
281
+ let mem = spec.memoryGb ?? DEFAULT_MEMORY_GB;
282
+ // Vercel fixes memory at 2 GB per vCPU.
283
+ if (id === "vercel")
284
+ mem = Math.max(mem, vcpu * 2);
285
+ // Blaxel sizes by memory, about 2 GB per core.
286
+ if (id === "blaxel")
287
+ mem = Math.max(mem, vcpu * 2);
288
+ // Boat rents the smallest fixed size that fits.
289
+ if (id === "boat") {
290
+ const s = BOAT_SIZES.find((b) => b.vcpu >= vcpu && b.memoryGb >= mem) ?? BOAT_SIZES[BOAT_SIZES.length - 1];
291
+ return s.usdHour;
292
+ }
293
+ // exe.dev bills per vCPU with up to 2 GiB each, so memory beyond that adds vCPUs.
294
+ if (id === "exedev")
295
+ return Math.max(Math.ceil(vcpu), Math.ceil(mem / 2)) * pricing.vcpuHour;
296
+ // Cloudflare bills the instance type that fits, not the exact request.
297
+ if (id === "cloudflare") {
298
+ const t = cloudflareInstance(vcpu, mem);
299
+ return t.vcpu * pricing.vcpuHour + t.memoryGb * pricing.gbHour;
300
+ }
301
+ // Morph bills whole MCUs: the larger of vCPUs and 4 GB blocks of memory.
302
+ if (id === "morph")
303
+ return Math.max(vcpu, Math.ceil(mem / 4)) * pricing.vcpuHour;
304
+ return vcpu * pricing.vcpuHour + mem * pricing.gbHour;
305
+ }
306
+ /** Why a provider can't take this spec, or null if it can. */
307
+ export function unfit(id, spec, require = []) {
308
+ const p = info(id);
309
+ const needs = new Set(require);
310
+ if (spec.gpu)
311
+ needs.add("gpu");
312
+ for (const c of needs)
313
+ if (!p.capabilities.includes(c))
314
+ return `no ${c} support`;
315
+ if ((spec.vcpu ?? DEFAULT_VCPU) > p.limits.maxVcpu)
316
+ return `max ${p.limits.maxVcpu} vCPU`;
317
+ if ((spec.memoryGb ?? DEFAULT_MEMORY_GB) > p.limits.maxMemoryGb)
318
+ return `max ${p.limits.maxMemoryGb} GB memory`;
319
+ return null;
320
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { readFileSync, writeFileSync } from "node:fs";
4
+ import { bench, benchMarkdown } from "./bench.js";
5
+ import { loadReports, makeReport, renderLeaderboard } from "./leaderboard.js";
6
+ import { CATALOG, hourlyCost } from "./catalog.js";
7
+ import { Router } from "./router.js";
8
+ import { createApp } from "./server.js";
9
+ import { RouterError } from "./types.js";
10
+ const HELP = `sandboxrouter: one API for every sandbox provider
11
+
12
+ Usage:
13
+ sandboxrouter providers [--vcpu N] [--memory GB] price table + which keys are set
14
+ sandboxrouter route [spec flags] [route flags] dry run: the order providers would be tried
15
+ sandboxrouter run "<cmd>" [spec flags] [route flags] [--keep]
16
+ sandboxrouter bench [--runs 3] [--provider a,b] [--json] [--share [--label "where you ran it"]]
17
+ sandboxrouter leaderboard [results-dir] [--out leaderboard.html]
18
+ sandboxrouter desktop [--os linux|windows|android] [--user ID] [--control]
19
+ get or create a computer and print a link to watch (or take over) its screen
20
+ sandboxrouter screenshot <provider:id> [--out screen.png]
21
+ sandboxrouter serve [--port 8787] [--host 127.0.0.1] [--cors https://site.example,http://localhost:3000]
22
+
23
+ Spec flags: --vcpu N --memory GB --gpu T4 --timeout MIN
24
+ Route flags: --provider e2b[,modal,...] --strategy cheapest|fastest
25
+ --no-fallback --require gpu,custom-image
26
+
27
+ Keys: MARITIME_API_KEY, E2B_API_KEY, DAYTONA_API_KEY, MODAL_TOKEN_ID+MODAL_TOKEN_SECRET,
28
+ VERCEL_OIDC_TOKEN (or VERCEL_TOKEN+VERCEL_TEAM_ID+VERCEL_PROJECT_ID)`;
29
+ const { values, positionals } = parseArgs({
30
+ allowPositionals: true,
31
+ options: {
32
+ vcpu: { type: "string" },
33
+ memory: { type: "string" },
34
+ gpu: { type: "string" },
35
+ timeout: { type: "string" },
36
+ provider: { type: "string" },
37
+ strategy: { type: "string" },
38
+ "no-fallback": { type: "boolean" },
39
+ require: { type: "string" },
40
+ keep: { type: "boolean" },
41
+ runs: { type: "string" },
42
+ json: { type: "boolean" },
43
+ port: { type: "string" },
44
+ host: { type: "string" },
45
+ help: { type: "boolean", short: "h" },
46
+ os: { type: "string" },
47
+ user: { type: "string" },
48
+ control: { type: "boolean" },
49
+ out: { type: "string" },
50
+ share: { type: "boolean" },
51
+ cors: { type: "string" },
52
+ label: { type: "string" },
53
+ },
54
+ });
55
+ const list = (s) => (s ? s.split(",").map((x) => x.trim()).filter(Boolean) : undefined);
56
+ const num = (s) => (s === undefined ? undefined : Number(s));
57
+ const spec = {
58
+ vcpu: num(values.vcpu),
59
+ memoryGb: num(values.memory),
60
+ gpu: values.gpu,
61
+ timeoutMinutes: num(values.timeout),
62
+ };
63
+ const providers = list(values.provider);
64
+ const route = {
65
+ provider: providers,
66
+ strategy: values.strategy,
67
+ fallback: values["no-fallback"] ? false : undefined,
68
+ require: list(values.require),
69
+ };
70
+ async function main() {
71
+ const [cmd, ...rest] = positionals;
72
+ const router = new Router();
73
+ if (!cmd || values.help)
74
+ return console.log(HELP);
75
+ if (cmd === "providers") {
76
+ const rows = CATALOG.map((c) => ({
77
+ provider: c.name,
78
+ id: c.id,
79
+ "$/hr": `$${hourlyCost(c.id, spec).toFixed(4)}`,
80
+ configured: router.isConfigured(c.id) ? "yes" : "no",
81
+ capabilities: c.capabilities.join(", ") || "-",
82
+ }));
83
+ if (values.json)
84
+ return console.log(JSON.stringify(rows, null, 2));
85
+ console.log(`Estimated list price for ${spec.vcpu ?? 2} vCPU, ${spec.memoryGb ?? 4} GB while running:\n`);
86
+ console.table(rows);
87
+ return;
88
+ }
89
+ if (cmd === "route") {
90
+ const plan = router.plan(spec, route);
91
+ if (values.json)
92
+ return console.log(JSON.stringify(plan, null, 2));
93
+ let n = 0;
94
+ for (const c of plan) {
95
+ const mark = c.skip ? " -" : `${++n}.`.padStart(3);
96
+ console.log(`${mark} ${c.provider.padEnd(11)} $${c.estimatedHourlyUsd.toFixed(4)}/hr${c.skip ? ` (skipped: ${c.skip})` : ""}`);
97
+ }
98
+ return;
99
+ }
100
+ if (cmd === "run") {
101
+ const command = rest.join(" ");
102
+ if (!command)
103
+ throw new RouterError('usage: sandboxrouter run "<cmd>"');
104
+ const sbx = await router.create(spec, route);
105
+ for (const a of sbx.attempts.filter((a) => !a.ok))
106
+ console.error(`! ${a.provider} failed, fell back: ${a.error}`);
107
+ console.error(`> ${sbx.id} on ${sbx.provider} (~$${sbx.estimatedHourlyUsd}/hr, started in ${sbx.attempts.at(-1)?.ms} ms)`);
108
+ try {
109
+ const r = await sbx.exec(command, {
110
+ timeoutSeconds: 600,
111
+ onStdout: (c) => process.stdout.write(c),
112
+ onStderr: (c) => process.stderr.write(c),
113
+ });
114
+ process.exitCode = r.exitCode;
115
+ }
116
+ finally {
117
+ if (values.keep)
118
+ console.error(`> kept alive: ${sbx.id}`);
119
+ else
120
+ await sbx.kill();
121
+ }
122
+ return;
123
+ }
124
+ if (cmd === "bench") {
125
+ const rows = await bench({
126
+ router,
127
+ providers,
128
+ runs: num(values.runs) ?? 3,
129
+ spec: { ...spec, timeoutMinutes: spec.timeoutMinutes ?? 5 },
130
+ onProgress: (m) => console.error(m),
131
+ });
132
+ if (rows.length === 0)
133
+ return console.error("No providers configured. Set at least one API key (see --help).");
134
+ if (values.json)
135
+ return console.log(JSON.stringify(rows, null, 2));
136
+ console.log(`\nsandboxrouter bench: ${new Date().toISOString().slice(0, 10)}, ${rows[0].runs} runs each\n`);
137
+ console.log(benchMarkdown(rows));
138
+ for (const r of rows.filter((r) => r.lastError))
139
+ console.error(`\n${r.provider} last error: ${r.lastError}`);
140
+ if (values.share) {
141
+ const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
142
+ const report = makeReport(rows, { version, runs: rows[0].runs, spec, label: values.label });
143
+ const file = values.out ?? `sandboxrouter-bench-${report.date.slice(0, 10)}-${Math.random().toString(36).slice(2, 8)}.json`;
144
+ writeFileSync(file, JSON.stringify(report, null, 2) + "\n");
145
+ console.log(`\nwrote ${file}. Add it to results/ in a PR to github.com/gigialc/sandboxrouter to put it on the leaderboard.`);
146
+ }
147
+ return;
148
+ }
149
+ if (cmd === "leaderboard") {
150
+ const dir = rest[0] ?? "results";
151
+ const reports = loadReports(dir);
152
+ const out = values.out ?? "leaderboard.html";
153
+ writeFileSync(out, renderLeaderboard(reports));
154
+ console.log(`wrote ${out} from ${reports.length} report${reports.length === 1 ? "" : "s"} in ${dir}/`);
155
+ return;
156
+ }
157
+ if (cmd === "desktop") {
158
+ const os = (values.os ?? "linux");
159
+ const c = await router.computer({ os, externalUserId: values.user }, { provider: providers });
160
+ const info = await c.info();
161
+ const link = await c.viewer(values.control ? "control" : "watch");
162
+ console.log(`${c.id} ${info.os} ${info.status}${info.screen ? ` ${info.screen.width}x${info.screen.height}` : ""}`);
163
+ console.log(`${values.control ? "take over" : "watch"}: ${link.url} (expires ${link.expiresAt})`);
164
+ return;
165
+ }
166
+ if (cmd === "screenshot") {
167
+ const id = rest[0];
168
+ if (!id)
169
+ throw new RouterError("usage: sandboxrouter screenshot <provider:id> [--out screen.png]");
170
+ const img = await (await router.getComputer(id)).screenshot();
171
+ const out = values.out ?? "screen.png";
172
+ (await import("node:fs")).writeFileSync(out, img);
173
+ console.log(`wrote ${out} (${img.byteLength} bytes)`);
174
+ return;
175
+ }
176
+ if (cmd === "serve") {
177
+ const port = num(values.port) ?? Number(process.env.PORT ?? 8787);
178
+ const host = values.host ?? "127.0.0.1";
179
+ const origins = list(values.cors);
180
+ createApp(router, undefined, origins).listen(port, host, () => {
181
+ console.log(`sandboxrouter listening on http://${host}:${port}`);
182
+ if (origins?.length)
183
+ console.log(`browser access allowed from: ${origins.join(", ")}`);
184
+ if (!process.env.SANDBOXROUTER_TOKEN)
185
+ console.log("warning: SANDBOXROUTER_TOKEN is not set, so the API is unauthenticated");
186
+ });
187
+ return;
188
+ }
189
+ console.error(`unknown command: ${cmd}\n\n${HELP}`);
190
+ process.exitCode = 2;
191
+ }
192
+ main().catch((err) => {
193
+ console.error(`error: ${err.message}`);
194
+ process.exitCode = 1;
195
+ });
@@ -0,0 +1,124 @@
1
+ import { type ProviderId } from "./types.js";
2
+ /**
3
+ * Computers: machines with a screen that a model drives with clicks and keys (computer use).
4
+ * Linux and Windows desktops, Android phones. Maritime is the first implementation.
5
+ */
6
+ export type ComputerOs = "linux" | "windows" | "android";
7
+ export interface ComputerSpec {
8
+ os?: ComputerOs;
9
+ /** Your user's id. The same id returns the same computer (files and logins kept). */
10
+ externalUserId?: string;
11
+ name?: string;
12
+ /** Linux only, each side 640 to 1920. */
13
+ screen?: {
14
+ width: number;
15
+ height: number;
16
+ };
17
+ /** Android only. */
18
+ arch?: "x86_64" | "arm64";
19
+ }
20
+ export type ComputerActionName = "screenshot" | "left_click" | "right_click" | "middle_click" | "double_click" | "triple_click" | "mouse_move" | "left_click_drag" | "left_mouse_down" | "left_mouse_up" | "scroll" | "type" | "key" | "hold_key" | "wait" | "zoom" | "cursor_position";
21
+ /** The canonical action (Anthropic computer-use shaped). Coordinates are in the last screenshot's frame. */
22
+ export interface ComputerAction {
23
+ action: ComputerActionName;
24
+ coordinate?: [number, number];
25
+ startCoordinate?: [number, number];
26
+ /** `type`: text to type. `key` / `hold_key`: an xdotool key name like "Return" or "ctrl+s". */
27
+ text?: string;
28
+ modifier?: string;
29
+ repeat?: number;
30
+ scrollDirection?: "up" | "down" | "left" | "right";
31
+ scrollAmount?: number;
32
+ duration?: number;
33
+ region?: [number, number, number, number];
34
+ noScreenshot?: boolean;
35
+ }
36
+ export interface ActionResult {
37
+ action?: string;
38
+ ok?: boolean;
39
+ error?: string;
40
+ frameId?: number;
41
+ /** Post-action screenshot, base64. */
42
+ imageB64?: string;
43
+ mime?: string;
44
+ width?: number;
45
+ height?: number;
46
+ [key: string]: unknown;
47
+ }
48
+ export interface ComputerInfo {
49
+ id: string;
50
+ os: ComputerOs;
51
+ status: string;
52
+ screen?: {
53
+ width: number;
54
+ height: number;
55
+ modelWidth?: number;
56
+ };
57
+ externalUserId?: string | null;
58
+ }
59
+ export interface ProviderComputer {
60
+ readonly nativeId: string;
61
+ info(): Promise<ComputerInfo>;
62
+ act(action: ComputerAction): Promise<ActionResult>;
63
+ actBatch(actions: ComputerAction[]): Promise<ActionResult[]>;
64
+ screenshot(opts?: {
65
+ format?: "png" | "jpeg";
66
+ quality?: number;
67
+ }): Promise<Uint8Array>;
68
+ exec(command: string, timeoutSeconds?: number): Promise<{
69
+ exitCode: number;
70
+ stdout: string;
71
+ stderr: string;
72
+ }>;
73
+ readFile(path: string): Promise<Uint8Array>;
74
+ writeFile(path: string, content: string | Uint8Array): Promise<void>;
75
+ /** A link for a person: `watch` is view-only, `control` hands them the desktop (logins, CAPTCHAs). */
76
+ viewer(mode?: "watch" | "control", opts?: {
77
+ ttlSeconds?: number;
78
+ reason?: string;
79
+ }): Promise<{
80
+ url: string;
81
+ expiresAt: string;
82
+ }>;
83
+ sleep(): Promise<void>;
84
+ delete(): Promise<void>;
85
+ }
86
+ export interface ComputerProvider {
87
+ readonly id: ProviderId;
88
+ readonly computerOs: ComputerOs[];
89
+ configured(): boolean;
90
+ createComputer(spec: ComputerSpec): Promise<ProviderComputer>;
91
+ connectComputer(nativeId: string): Promise<ProviderComputer>;
92
+ }
93
+ export declare class RoutedComputer {
94
+ readonly provider: ProviderId;
95
+ private c;
96
+ readonly id: string;
97
+ constructor(provider: ProviderId, c: ProviderComputer);
98
+ get nativeId(): string;
99
+ info(): Promise<ComputerInfo>;
100
+ act(action: ComputerAction): Promise<ActionResult>;
101
+ actBatch(actions: ComputerAction[]): Promise<ActionResult[]>;
102
+ screenshot(opts?: {
103
+ format?: "png" | "jpeg";
104
+ quality?: number;
105
+ }): Promise<Uint8Array<ArrayBufferLike>>;
106
+ exec(command: string, timeoutSeconds?: number): Promise<{
107
+ exitCode: number;
108
+ stdout: string;
109
+ stderr: string;
110
+ }>;
111
+ readFile(path: string): Promise<Uint8Array<ArrayBufferLike>>;
112
+ writeFile(path: string, content: string | Uint8Array): Promise<void>;
113
+ viewer(mode?: "watch" | "control", opts?: {
114
+ ttlSeconds?: number;
115
+ reason?: string;
116
+ }): Promise<{
117
+ url: string;
118
+ expiresAt: string;
119
+ }>;
120
+ sleep(): Promise<void>;
121
+ delete(): Promise<void>;
122
+ }
123
+ /** Pick a computer provider that offers this OS and has credentials, in the given order. */
124
+ export declare function createComputer(providers: ComputerProvider[], spec?: ComputerSpec, prefer?: ProviderId[]): Promise<RoutedComputer>;