@sandboxrouter/sdk 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +224 -48
  2. package/dist/bench.js +4 -4
  3. package/dist/catalog.d.ts +159 -0
  4. package/dist/catalog.js +448 -15
  5. package/dist/check.d.ts +28 -0
  6. package/dist/check.js +185 -0
  7. package/dist/cli.js +148 -11
  8. package/dist/computers.d.ts +55 -13
  9. package/dist/computers.js +132 -6
  10. package/dist/conformance.d.ts +46 -0
  11. package/dist/conformance.js +65 -0
  12. package/dist/cost.d.ts +67 -0
  13. package/dist/cost.js +153 -0
  14. package/dist/gc.d.ts +29 -0
  15. package/dist/gc.js +54 -0
  16. package/dist/index.d.ts +28 -2
  17. package/dist/index.js +28 -2
  18. package/dist/leaderboard.d.ts +6 -0
  19. package/dist/leaderboard.js +5 -4
  20. package/dist/ledger.d.ts +38 -0
  21. package/dist/ledger.js +89 -0
  22. package/dist/providers/agent37.d.ts +40 -0
  23. package/dist/providers/agent37.js +224 -0
  24. package/dist/providers/agentcore.d.ts +71 -0
  25. package/dist/providers/agentcore.js +446 -0
  26. package/dist/providers/alibaba.d.ts +18 -0
  27. package/dist/providers/alibaba.js +106 -0
  28. package/dist/providers/azure.d.ts +43 -0
  29. package/dist/providers/azure.js +330 -0
  30. package/dist/providers/beam.d.ts +11 -0
  31. package/dist/providers/beam.js +179 -0
  32. package/dist/providers/blaxel.d.ts +3 -1
  33. package/dist/providers/blaxel.js +32 -22
  34. package/dist/providers/boat.js +8 -3
  35. package/dist/providers/cloudflare.js +8 -3
  36. package/dist/providers/codesandbox.d.ts +59 -0
  37. package/dist/providers/codesandbox.js +287 -0
  38. package/dist/providers/daytona.d.ts +25 -4
  39. package/dist/providers/daytona.js +449 -28
  40. package/dist/providers/dedalus.d.ts +12 -0
  41. package/dist/providers/dedalus.js +244 -0
  42. package/dist/providers/deno.d.ts +50 -0
  43. package/dist/providers/deno.js +170 -0
  44. package/dist/providers/docker.js +11 -5
  45. package/dist/providers/e2b.d.ts +70 -3
  46. package/dist/providers/e2b.js +376 -8
  47. package/dist/providers/exedev.js +17 -5
  48. package/dist/providers/freestyle.d.ts +49 -0
  49. package/dist/providers/freestyle.js +261 -0
  50. package/dist/providers/hopx.js +27 -18
  51. package/dist/providers/instabox.d.ts +76 -0
  52. package/dist/providers/instabox.js +360 -0
  53. package/dist/providers/koyeb.d.ts +71 -0
  54. package/dist/providers/koyeb.js +379 -0
  55. package/dist/providers/lambda.d.ts +51 -0
  56. package/dist/providers/lambda.js +467 -0
  57. package/dist/providers/langsmith.d.ts +16 -0
  58. package/dist/providers/langsmith.js +149 -0
  59. package/dist/providers/limrun.d.ts +77 -0
  60. package/dist/providers/limrun.js +626 -0
  61. package/dist/providers/maritime.d.ts +5 -1
  62. package/dist/providers/maritime.js +39 -8
  63. package/dist/providers/mobilerun.d.ts +44 -0
  64. package/dist/providers/mobilerun.js +417 -0
  65. package/dist/providers/modal.js +12 -4
  66. package/dist/providers/morph.js +12 -4
  67. package/dist/providers/northflank.js +11 -13
  68. package/dist/providers/novita.d.ts +12 -0
  69. package/dist/providers/novita.js +81 -0
  70. package/dist/providers/openscale.d.ts +17 -0
  71. package/dist/providers/openscale.js +87 -0
  72. package/dist/providers/orgo.d.ts +45 -0
  73. package/dist/providers/orgo.js +414 -0
  74. package/dist/providers/sail.d.ts +32 -0
  75. package/dist/providers/sail.js +372 -0
  76. package/dist/providers/superserve.d.ts +25 -0
  77. package/dist/providers/superserve.js +337 -0
  78. package/dist/providers/tensorlake.js +17 -14
  79. package/dist/providers/upstash.d.ts +30 -0
  80. package/dist/providers/upstash.js +258 -0
  81. package/dist/providers/vercel.js +14 -3
  82. package/dist/providers/workspaces.d.ts +60 -0
  83. package/dist/providers/workspaces.js +662 -0
  84. package/dist/router.d.ts +36 -5
  85. package/dist/router.js +149 -19
  86. package/dist/server.d.ts +1 -1
  87. package/dist/server.js +22 -3
  88. package/dist/types.d.ts +13 -5
  89. package/dist/util.d.ts +28 -1
  90. package/dist/util.js +56 -7
  91. package/package.json +80 -5
package/dist/check.js ADDED
@@ -0,0 +1,185 @@
1
+ import { fixedSize } from "./catalog.js";
2
+ /** Every step `check` runs, in order. Each provider's saved report is scored against this list. */
3
+ export const CHECK_STEPS = [
4
+ "create",
5
+ "exec: stdout, stderr, exit code",
6
+ "exec: env and cwd",
7
+ "exec: UTF-8 output",
8
+ "exec: streaming",
9
+ "files: binary round-trip",
10
+ "files: 1 MB round-trip",
11
+ "files: missing file is an error",
12
+ "files: visible to commands",
13
+ "exec: command timeout",
14
+ "exec: timed-out command was stopped",
15
+ "pause and resume",
16
+ "kill",
17
+ "killed sandbox is gone",
18
+ ];
19
+ /**
20
+ * One real pass over a provider: create, exec (stdout, stderr, exit code, env, cwd), streaming,
21
+ * a binary file round-trip, pause and resume where supported, and kill. Meant for testing an
22
+ * adapter against a real account; every step is recorded, and later steps still run after a
23
+ * failure when they can.
24
+ */
25
+ export async function checkProvider(router, provider, opts = {}) {
26
+ const results = [];
27
+ const record = (r) => {
28
+ results.push(r);
29
+ opts.onStep?.(r);
30
+ };
31
+ const step = async (name, fn) => {
32
+ const t0 = Date.now();
33
+ try {
34
+ const out = await fn();
35
+ const r = typeof out === "object" && out ? out : { status: "pass", detail: out || undefined };
36
+ record({ step: name, ms: Date.now() - t0, ...r });
37
+ return r.status !== "fail";
38
+ }
39
+ catch (err) {
40
+ record({ step: name, status: "fail", ms: Date.now() - t0, detail: err?.message ?? String(err) });
41
+ return false;
42
+ }
43
+ };
44
+ let sbx;
45
+ await step("create", async () => {
46
+ sbx = await router.create({ timeoutMinutes: 10, ...fixedSize(provider), ...opts.spec }, { provider, fallback: false });
47
+ return `${sbx.id}, ~$${sbx.estimatedHourlyUsd}/hr`;
48
+ });
49
+ if (!sbx)
50
+ return { provider, results, ok: false };
51
+ const box = sbx;
52
+ try {
53
+ await step("exec: stdout, stderr, exit code", async () => {
54
+ const r = await box.exec("printf hi; printf err >&2; exit 3");
55
+ const bad = [];
56
+ if (r.stdout !== "hi")
57
+ bad.push(`stdout ${JSON.stringify(r.stdout)}`);
58
+ if (r.stderr !== "err")
59
+ bad.push(`stderr ${JSON.stringify(r.stderr)}`);
60
+ if (r.exitCode !== 3)
61
+ bad.push(`exit ${r.exitCode}`);
62
+ if (bad.length)
63
+ throw new Error(`expected "hi" / "err" / 3, got ${bad.join(", ")}`);
64
+ });
65
+ await step("exec: env and cwd", async () => {
66
+ const r = await box.exec('printf "%s %s" "$SBR_CHECK" "$(pwd)"', { env: { SBR_CHECK: "v1" }, cwd: "/tmp" });
67
+ if (r.stdout.trim() !== "v1 /tmp")
68
+ throw new Error(`expected "v1 /tmp", got ${JSON.stringify(r.stdout)}`);
69
+ });
70
+ await step("exec: UTF-8 output", async () => {
71
+ // "café ✓" as octal escapes, so the command itself is plain ASCII.
72
+ const r = await box.exec("printf 'caf\\303\\251 \\342\\234\\223'");
73
+ if (r.stdout !== "café ✓")
74
+ throw new Error(`expected "café ✓", got ${JSON.stringify(r.stdout)}`);
75
+ });
76
+ await step("exec: streaming", async () => {
77
+ const t0 = Date.now();
78
+ let first = -1;
79
+ let chunks = 0;
80
+ const r = await box.exec("for i in 1 2 3; do echo $i; sleep 1; done", {
81
+ onStdout: () => {
82
+ chunks++;
83
+ if (first < 0)
84
+ first = Date.now() - t0;
85
+ },
86
+ });
87
+ const total = Date.now() - t0;
88
+ if (r.stdout.replace(/\s+/g, "") !== "123")
89
+ throw new Error(`expected 1 2 3, got ${JSON.stringify(r.stdout)}`);
90
+ if (chunks === 0)
91
+ throw new Error("onStdout was never called");
92
+ const live = first >= 0 && first < total - 1500;
93
+ return { status: live ? "pass" : "info", detail: live ? `first output after ${first} ms` : "output arrived only at the end (no live streaming)" };
94
+ });
95
+ const bytes = new Uint8Array(256 * 4).map((_, i) => i % 256);
96
+ const path = "/tmp/sbr-check/nested/bin.dat";
97
+ await step("files: binary round-trip", async () => {
98
+ await box.writeFile(path, bytes);
99
+ const back = await box.readFile(path);
100
+ if (back.length !== bytes.length || back.some((b, i) => b !== bytes[i]))
101
+ throw new Error(`read back ${back.length} bytes, content differs`);
102
+ });
103
+ await step("files: 1 MB round-trip", async () => {
104
+ // Big enough to cross every adapter's chunking; a pattern that isn't all one repeating byte.
105
+ const big = new Uint8Array(1024 * 1024).map((_, i) => (i * 31 + (i >> 10)) % 256);
106
+ await box.writeFile("/tmp/sbr-check/big.bin", big);
107
+ const back = await box.readFile("/tmp/sbr-check/big.bin");
108
+ if (back.length !== big.length)
109
+ throw new Error(`read back ${back.length} of ${big.length} bytes`);
110
+ const at = back.findIndex((b, i) => b !== big[i]);
111
+ if (at >= 0)
112
+ throw new Error(`content differs from byte ${at}`);
113
+ });
114
+ await step("files: missing file is an error", async () => {
115
+ let got;
116
+ try {
117
+ got = await box.readFile("/tmp/sbr-check/does-not-exist");
118
+ }
119
+ catch {
120
+ return;
121
+ }
122
+ throw new Error(`readFile of a missing file returned ${got.length} bytes instead of throwing`);
123
+ });
124
+ await step("files: visible to commands", async () => {
125
+ await box.writeFile("/tmp/sbr-check/hello.txt", "hello from writeFile\n");
126
+ const r = await box.exec("cat /tmp/sbr-check/hello.txt && printf made > /tmp/sbr-check/made.txt");
127
+ if (r.stdout.trim() !== "hello from writeFile")
128
+ throw new Error(`cat printed ${JSON.stringify(r.stdout)}`);
129
+ const made = await box.readText("/tmp/sbr-check/made.txt");
130
+ if (made !== "made")
131
+ throw new Error(`readFile of a command's file gave ${JSON.stringify(made)}`);
132
+ });
133
+ await step("exec: command timeout", async () => {
134
+ // The contract: the command is stopped and exec rejects with "command timed out after Ns".
135
+ const seconds = 3;
136
+ const t0 = Date.now();
137
+ let outcome;
138
+ try {
139
+ const r = await box.exec(`sleep 30; echo late`, { timeoutSeconds: seconds });
140
+ outcome = `returned exit ${r.exitCode}${r.stdout.includes("late") ? " and ran to completion" : ""}`;
141
+ }
142
+ catch (err) {
143
+ const message = err.message;
144
+ if (message.endsWith(`command timed out after ${seconds}s`))
145
+ return `threw after ${Date.now() - t0} ms`;
146
+ outcome = `threw "${message}"`;
147
+ }
148
+ throw new Error(`expected a "command timed out after ${seconds}s" error, ${outcome} after ${Date.now() - t0} ms`);
149
+ });
150
+ await step("exec: timed-out command was stopped", async () => {
151
+ // A command killed by its timeout must not finish on its own later.
152
+ await box.exec("rm -f /tmp/sbr-check/late; sleep 4 && echo late > /tmp/sbr-check/late", { timeoutSeconds: 1 }).catch(() => { });
153
+ await new Promise((r) => setTimeout(r, 5000));
154
+ const r = await box.exec("cat /tmp/sbr-check/late 2>/dev/null || echo stopped");
155
+ if (r.stdout.includes("late"))
156
+ throw new Error("the command kept running after its timeout");
157
+ });
158
+ if (box.canPause) {
159
+ await step("pause and resume", async () => {
160
+ await box.pause();
161
+ const again = await router.get(box.id);
162
+ const r = await again.exec("cat /tmp/sbr-check/hello.txt");
163
+ if (r.stdout.trim() !== "hello from writeFile")
164
+ throw new Error(`after resume, file read ${JSON.stringify(r.stdout)}`);
165
+ });
166
+ }
167
+ else {
168
+ record({ step: "pause and resume", status: "skip", ms: 0, detail: "adapter has no pause" });
169
+ }
170
+ }
171
+ finally {
172
+ await step("kill", () => box.kill());
173
+ }
174
+ await step("killed sandbox is gone", async () => {
175
+ try {
176
+ const again = await router.get(box.id);
177
+ await again.exec("echo still-here", { timeoutSeconds: 10 });
178
+ }
179
+ catch {
180
+ return;
181
+ }
182
+ return { status: "fail", detail: "the sandbox still ran a command after kill(): check the dashboard, it may still bill" };
183
+ });
184
+ return { provider, sandboxId: box.id, results, ok: !results.some((r) => r.status === "fail") };
185
+ }
package/dist/cli.js CHANGED
@@ -1,9 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
- import { readFileSync, writeFileSync } from "node:fs";
3
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { platform, arch } from "node:os";
4
5
  import { bench, benchMarkdown } from "./bench.js";
6
+ import { costMarkdown, costRun, settle } from "./cost.js";
7
+ import { checkProvider } from "./check.js";
8
+ import { gc, listOpen } from "./gc.js";
5
9
  import { loadReports, makeReport, renderLeaderboard } from "./leaderboard.js";
6
- import { CATALOG, hourlyCost } from "./catalog.js";
10
+ import { accessLabel, hourlyCost, listingOrder, planLabel, unfit } from "./catalog.js";
7
11
  import { Router } from "./router.js";
8
12
  import { createApp } from "./server.js";
9
13
  import { RouterError } from "./types.js";
@@ -13,19 +17,32 @@ Usage:
13
17
  sandboxrouter providers [--vcpu N] [--memory GB] price table + which keys are set
14
18
  sandboxrouter route [spec flags] [route flags] dry run: the order providers would be tried
15
19
  sandboxrouter run "<cmd>" [spec flags] [route flags] [--keep]
20
+ sandboxrouter check --provider ID[,ID] | --all [--json] [--save [dir]]
21
+ test providers for real: exec, env, UTF-8, streaming, files, timeout, pause,
22
+ kill; exits 1 if any step fails. --all = every configured provider;
23
+ --save writes results/conformance/<id>.json for the compatibility table
24
+ sandboxrouter ls [--json] sandboxes this machine started that weren't killed
25
+ sandboxrouter gc [--all | --older-than MIN] [--dry-run]
26
+ kill leaked sandboxes: by default those past their lifetime
16
27
  sandboxrouter bench [--runs 3] [--provider a,b] [--json] [--share [--label "where you ran it"]]
28
+ sandboxrouter cost run [--provider a,b] [--minutes 10] [--vcpu 2 --memory 4] [--no-load] [--out FILE]
29
+ start one sandbox per provider for a fixed time with every core busy, record
30
+ the size it really got and the list-price prediction
31
+ sandboxrouter cost settle FILE
32
+ read what each provider actually billed for those sandboxes (rerun until all
33
+ bills have posted) and print predicted vs billed
17
34
  sandboxrouter leaderboard [results-dir] [--out leaderboard.html]
18
- sandboxrouter desktop [--os linux|windows|android] [--user ID] [--control]
35
+ sandboxrouter desktop [--os linux|windows|android|ios] [--user ID] [--control]
19
36
  get or create a computer and print a link to watch (or take over) its screen
20
37
  sandboxrouter screenshot <provider:id> [--out screen.png]
21
38
  sandboxrouter serve [--port 8787] [--host 127.0.0.1] [--cors https://site.example,http://localhost:3000]
22
39
 
23
40
  Spec flags: --vcpu N --memory GB --gpu T4 --timeout MIN
24
41
  Route flags: --provider e2b[,modal,...] --strategy cheapest|fastest
25
- --no-fallback --require gpu,custom-image
42
+ --no-fallback --fallback-any (also on setup errors like a bad key)
43
+ --create-timeout SEC (per provider, default 120) --require gpu,custom-image
26
44
 
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)`;
45
+ Keys: one env var group per provider; "sandboxrouter providers" lists them and shows which are set.`;
29
46
  const { values, positionals } = parseArgs({
30
47
  allowPositionals: true,
31
48
  options: {
@@ -36,6 +53,8 @@ const { values, positionals } = parseArgs({
36
53
  provider: { type: "string" },
37
54
  strategy: { type: "string" },
38
55
  "no-fallback": { type: "boolean" },
56
+ "fallback-any": { type: "boolean" },
57
+ "create-timeout": { type: "string" },
39
58
  require: { type: "string" },
40
59
  keep: { type: "boolean" },
41
60
  runs: { type: "string" },
@@ -50,6 +69,12 @@ const { values, positionals } = parseArgs({
50
69
  share: { type: "boolean" },
51
70
  cors: { type: "string" },
52
71
  label: { type: "string" },
72
+ all: { type: "boolean" },
73
+ save: { type: "boolean" },
74
+ "older-than": { type: "string" },
75
+ "dry-run": { type: "boolean" },
76
+ minutes: { type: "string" },
77
+ "no-load": { type: "boolean" },
53
78
  },
54
79
  });
55
80
  const list = (s) => (s ? s.split(",").map((x) => x.trim()).filter(Boolean) : undefined);
@@ -64,7 +89,8 @@ const providers = list(values.provider);
64
89
  const route = {
65
90
  provider: providers,
66
91
  strategy: values.strategy,
67
- fallback: values["no-fallback"] ? false : undefined,
92
+ fallback: values["no-fallback"] ? false : values["fallback-any"] ? "any" : undefined,
93
+ createTimeoutSeconds: num(values["create-timeout"]),
68
94
  require: list(values.require),
69
95
  };
70
96
  async function main() {
@@ -73,16 +99,19 @@ async function main() {
73
99
  if (!cmd || values.help)
74
100
  return console.log(HELP);
75
101
  if (cmd === "providers") {
76
- const rows = CATALOG.map((c) => ({
102
+ const rows = listingOrder(spec).map((c) => ({
77
103
  provider: c.name,
78
104
  id: c.id,
79
- "$/hr": `$${hourlyCost(c.id, spec).toFixed(4)}`,
105
+ "$/hr": unfit(c.id, spec) ?? `${c.billing === "active-cpu" ? "up to " : ""}$${hourlyCost(c.id, spec).toFixed(4)}`,
106
+ plan: c.local ? "none" : planLabel(c.id),
80
107
  configured: router.isConfigured(c.id) ? "yes" : "no",
81
108
  capabilities: c.capabilities.join(", ") || "-",
109
+ "sign-up": accessLabel(c.id) ?? "open",
82
110
  }));
83
111
  if (values.json)
84
112
  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`);
113
+ console.log(`Estimated list price for ${spec.vcpu ?? 2} vCPU, ${spec.memoryGb ?? 4} GB while running, cheapest first.`);
114
+ console.log(`"up to" = billed on active CPU only, shown with every core busy. plan = monthly plan needed to use the API.\n`);
86
115
  console.table(rows);
87
116
  return;
88
117
  }
@@ -102,6 +131,13 @@ async function main() {
102
131
  if (!command)
103
132
  throw new RouterError('usage: sandboxrouter run "<cmd>"');
104
133
  const sbx = await router.create(spec, route);
134
+ // Ctrl-C: kill the sandbox before exiting instead of leaving it billing.
135
+ const stop = () => {
136
+ console.error(`\n> interrupted, killing ${sbx.id}`);
137
+ sbx.kill().finally(() => process.exit(130));
138
+ };
139
+ if (!values.keep)
140
+ process.once("SIGINT", stop);
105
141
  for (const a of sbx.attempts.filter((a) => !a.ok))
106
142
  console.error(`! ${a.provider} failed, fell back: ${a.error}`);
107
143
  console.error(`> ${sbx.id} on ${sbx.provider} (~$${sbx.estimatedHourlyUsd}/hr, started in ${sbx.attempts.at(-1)?.ms} ms)`);
@@ -121,6 +157,73 @@ async function main() {
121
157
  }
122
158
  return;
123
159
  }
160
+ if (cmd === "check") {
161
+ const ids = values.all ? router.registered().filter((id) => router.isConfigured(id)) : providers;
162
+ if (!ids?.length) {
163
+ if (values.all)
164
+ return console.log("No providers configured: set at least one provider's keys (sandboxrouter providers lists them).");
165
+ throw new RouterError("usage: sandboxrouter check --provider <id>[,<id>...] | --all");
166
+ }
167
+ const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
168
+ const saveDir = values.save ? (rest[0] ?? "results/conformance") : undefined;
169
+ const mark = { pass: "ok ", fail: "FAIL", skip: "skip", info: "note" };
170
+ const reports = [];
171
+ for (const p of ids) {
172
+ if (!values.json)
173
+ console.log(`\n${p}`);
174
+ const report = await checkProvider(router, p, {
175
+ spec,
176
+ onStep: (r) => {
177
+ if (!values.json)
178
+ console.log(` ${mark[r.status]} ${r.step.padEnd(32)} ${String(r.ms).padStart(6)} ms ${r.detail ?? ""}`);
179
+ },
180
+ });
181
+ reports.push(report);
182
+ if (saveDir) {
183
+ mkdirSync(saveDir, { recursive: true });
184
+ const saved = { tool: "sandboxrouter-check", version, date: new Date().toISOString(), platform: `${platform()}-${arch()}`, ...report };
185
+ writeFileSync(`${saveDir}/${p}.json`, JSON.stringify(saved, null, 2) + "\n");
186
+ }
187
+ }
188
+ if (values.json)
189
+ console.log(JSON.stringify(reports, null, 2));
190
+ else
191
+ console.log(`\n${reports.filter((r) => r.ok).length}/${reports.length} passed`);
192
+ if (reports.some((r) => !r.ok))
193
+ process.exitCode = 1;
194
+ return;
195
+ }
196
+ if (cmd === "ls") {
197
+ if (!router.ledger)
198
+ return console.log("The sandbox ledger is off (SANDBOXROUTER_LEDGER=off), so there is nothing to list.");
199
+ const open = listOpen(router);
200
+ if (values.json)
201
+ return console.log(JSON.stringify(open, null, 2));
202
+ if (!open.length)
203
+ return console.log(`No open sandboxes in ${router.ledger.file}.`);
204
+ for (const s of open) {
205
+ const left = Math.round((Date.parse(s.expiresAt) - Date.now()) / 60_000);
206
+ const when = s.expired ? `past its lifetime by ${-left} min` : `${left} min of lifetime left`;
207
+ console.log(`${s.id.padEnd(40)} up ${String(s.ageMinutes).padStart(5)} min ${when.padEnd(28)} ~$${s.estimatedUsdSoFar.toFixed(4)} so far`);
208
+ }
209
+ const expired = open.filter((s) => s.expired).length;
210
+ console.log(`\n${open.length} open, ${expired} past their lifetime. "sandboxrouter gc" kills those; "gc --all" kills every one.`);
211
+ return;
212
+ }
213
+ if (cmd === "gc") {
214
+ if (!router.ledger)
215
+ return console.log("The sandbox ledger is off (SANDBOXROUTER_LEDGER=off), so there is nothing to clean up.");
216
+ const results = await gc(router, { all: values.all, olderThanMinutes: num(values["older-than"]), dryRun: values["dry-run"] });
217
+ if (values.json)
218
+ return console.log(JSON.stringify(results, null, 2));
219
+ if (!results.length)
220
+ return console.log("Nothing to clean up.");
221
+ for (const r of results)
222
+ console.log(`${r.action.padEnd(10)} ${r.id}${r.detail ? ` (${r.detail})` : ""}`);
223
+ if (results.some((r) => r.action === "failed"))
224
+ process.exitCode = 1;
225
+ return;
226
+ }
124
227
  if (cmd === "bench") {
125
228
  const rows = await bench({
126
229
  router,
@@ -146,6 +249,36 @@ async function main() {
146
249
  }
147
250
  return;
148
251
  }
252
+ if (cmd === "cost") {
253
+ const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
254
+ if (rest[0] === "run") {
255
+ const ids = providers ?? router.registered().filter((id) => router.isConfigured(id) && id !== "docker");
256
+ if (!ids.length)
257
+ return console.error("No providers configured. Set at least one API key (see --help).");
258
+ const run = await costRun({
259
+ router,
260
+ providers: ids,
261
+ minutes: num(values.minutes) ?? 10,
262
+ spec,
263
+ load: !values["no-load"],
264
+ version,
265
+ onProgress: (m) => console.error(m),
266
+ });
267
+ const file = values.out ?? `results/cost/cost-${run.date.slice(0, 16).replace(/[:T]/g, "-")}.json`;
268
+ mkdirSync(file.replace(/\/[^/]*$/, "") || ".", { recursive: true });
269
+ writeFileSync(file, JSON.stringify(await settle(run), null, 2) + "\n");
270
+ console.log(costMarkdown(run));
271
+ console.log(`\nwrote ${file}. Bills post late on some providers: run "sandboxrouter cost settle ${file}" again later.`);
272
+ return;
273
+ }
274
+ if (rest[0] === "settle" && rest[1]) {
275
+ const run = (await settle(JSON.parse(readFileSync(rest[1], "utf8"))));
276
+ writeFileSync(rest[1], JSON.stringify(run, null, 2) + "\n");
277
+ console.log(costMarkdown(run));
278
+ return;
279
+ }
280
+ return console.error('Usage: sandboxrouter cost run [...] | sandboxrouter cost settle FILE');
281
+ }
149
282
  if (cmd === "leaderboard") {
150
283
  const dir = rest[0] ?? "results";
151
284
  const reports = loadReports(dir);
@@ -177,12 +310,16 @@ async function main() {
177
310
  const port = num(values.port) ?? Number(process.env.PORT ?? 8787);
178
311
  const host = values.host ?? "127.0.0.1";
179
312
  const origins = list(values.cors);
313
+ const loopback = /^(127\.\d+\.\d+\.\d+|::1|localhost)$/.test(host);
314
+ if (!process.env.SANDBOXROUTER_TOKEN && !loopback) {
315
+ throw new RouterError(`refusing to listen on ${host} without SANDBOXROUTER_TOKEN: anyone who can reach it could start sandboxes on your keys`);
316
+ }
180
317
  createApp(router, undefined, origins).listen(port, host, () => {
181
318
  console.log(`sandboxrouter listening on http://${host}:${port}`);
182
319
  if (origins?.length)
183
320
  console.log(`browser access allowed from: ${origins.join(", ")}`);
184
321
  if (!process.env.SANDBOXROUTER_TOKEN)
185
- console.log("warning: SANDBOXROUTER_TOKEN is not set, so the API is unauthenticated");
322
+ console.log("note: SANDBOXROUTER_TOKEN is not set; any program on this machine can use the API");
186
323
  });
187
324
  return;
188
325
  }
@@ -1,9 +1,11 @@
1
- import { type ProviderId } from "./types.js";
2
1
  /**
3
2
  * 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.
3
+ * Linux and Windows desktops, Android phones. Routed like sandboxes: cheapest published price for
4
+ * the requested OS first, ties alphabetical.
5
5
  */
6
- export type ComputerOs = "linux" | "windows" | "android";
6
+ /** Computer providers include sandbox providers (maritime, e2b, daytona) and desktop-only ones. */
7
+ export type ComputerProviderId = string;
8
+ export type ComputerOs = "linux" | "windows" | "android" | "ios";
7
9
  export interface ComputerSpec {
8
10
  os?: ComputerOs;
9
11
  /** Your user's id. The same id returns the same computer (files and logins kept). */
@@ -72,29 +74,33 @@ export interface ProviderComputer {
72
74
  }>;
73
75
  readFile(path: string): Promise<Uint8Array>;
74
76
  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
+ /**
78
+ * A link for a person: `watch` is view-only, `control` hands them the desktop (logins, CAPTCHAs).
79
+ * Optional: only on providers that offer a shareable live view. `expiresAt` may be null.
80
+ */
81
+ viewer?(mode?: "watch" | "control", opts?: {
77
82
  ttlSeconds?: number;
78
83
  reason?: string;
79
84
  }): Promise<{
80
85
  url: string;
81
- expiresAt: string;
86
+ expiresAt: string | null;
82
87
  }>;
83
- sleep(): Promise<void>;
88
+ /** Optional: stop billing while keeping the computer, where the provider supports it. */
89
+ sleep?(): Promise<void>;
84
90
  delete(): Promise<void>;
85
91
  }
86
92
  export interface ComputerProvider {
87
- readonly id: ProviderId;
93
+ readonly id: ComputerProviderId;
88
94
  readonly computerOs: ComputerOs[];
89
95
  configured(): boolean;
90
96
  createComputer(spec: ComputerSpec): Promise<ProviderComputer>;
91
97
  connectComputer(nativeId: string): Promise<ProviderComputer>;
92
98
  }
93
99
  export declare class RoutedComputer {
94
- readonly provider: ProviderId;
100
+ readonly provider: ComputerProviderId;
95
101
  private c;
96
102
  readonly id: string;
97
- constructor(provider: ProviderId, c: ProviderComputer);
103
+ constructor(provider: ComputerProviderId, c: ProviderComputer);
98
104
  get nativeId(): string;
99
105
  info(): Promise<ComputerInfo>;
100
106
  act(action: ComputerAction): Promise<ActionResult>;
@@ -110,15 +116,51 @@ export declare class RoutedComputer {
110
116
  }>;
111
117
  readFile(path: string): Promise<Uint8Array<ArrayBufferLike>>;
112
118
  writeFile(path: string, content: string | Uint8Array): Promise<void>;
119
+ /** What this computer's provider supports beyond the core actions. */
120
+ get supports(): {
121
+ viewer: boolean;
122
+ sleep: boolean;
123
+ };
113
124
  viewer(mode?: "watch" | "control", opts?: {
114
125
  ttlSeconds?: number;
115
126
  reason?: string;
116
127
  }): Promise<{
117
128
  url: string;
118
- expiresAt: string;
129
+ expiresAt: string | null;
119
130
  }>;
120
131
  sleep(): Promise<void>;
121
132
  delete(): Promise<void>;
122
133
  }
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>;
134
+ /** Published prices per OS for every computer provider. Same rules as the sandbox catalog. */
135
+ export interface ComputerCatalogEntry {
136
+ id: ComputerProviderId;
137
+ name: string;
138
+ /** USD per hour while awake, per OS offered. */
139
+ os: Partial<Record<ComputerOs, number>>;
140
+ note?: string;
141
+ source: string;
142
+ checked: string;
143
+ /** A person can watch or take over the screen through a link. */
144
+ takeover: boolean;
145
+ /** The machine you get by default, so prices compare like for like, e.g. "2 vCPU · 4 GB". */
146
+ size: string;
147
+ /** OSes offered only on a monthly plan, with no hourly price. Routed last for that OS. */
148
+ planOnly?: ComputerOs[];
149
+ credentials: string[][];
150
+ docs: string;
151
+ }
152
+ export declare const COMPUTER_ENTRIES: ComputerCatalogEntry[];
153
+ export declare function computerCatalog(): ComputerCatalogEntry[];
154
+ export declare function computerHourly(id: ComputerProviderId, os: ComputerOs): number | undefined;
155
+ export interface ComputerCandidate {
156
+ provider: ComputerProviderId;
157
+ estimatedHourlyUsd?: number;
158
+ skip?: string;
159
+ }
160
+ /** The order a computer create would try, with the reason any provider is skipped. */
161
+ export declare function planComputer(providers: ComputerProvider[], os?: ComputerOs, prefer?: ComputerProviderId[]): ComputerCandidate[];
162
+ /**
163
+ * Start a computer on the cheapest provider that offers this OS and has credentials (ties
164
+ * alphabetical), falling back down the list. An explicit `prefer` list keeps its order.
165
+ */
166
+ export declare function createComputer(providers: ComputerProvider[], spec?: ComputerSpec, prefer?: ComputerProviderId[]): Promise<RoutedComputer>;