@sandboxrouter/sdk 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +271 -62
- package/dist/bench.js +4 -4
- package/dist/catalog.d.ts +232 -3
- package/dist/catalog.js +569 -14
- package/dist/check.d.ts +28 -0
- package/dist/check.js +185 -0
- package/dist/cli.js +149 -12
- package/dist/computers.d.ts +55 -13
- package/dist/computers.js +132 -6
- package/dist/conformance.d.ts +46 -0
- package/dist/conformance.js +65 -0
- package/dist/cost.d.ts +67 -0
- package/dist/cost.js +153 -0
- package/dist/gc.d.ts +29 -0
- package/dist/gc.js +54 -0
- package/dist/index.d.ts +34 -2
- package/dist/index.js +34 -2
- package/dist/leaderboard.d.ts +6 -0
- package/dist/leaderboard.js +5 -4
- package/dist/ledger.d.ts +38 -0
- package/dist/ledger.js +89 -0
- package/dist/providers/agent37.d.ts +40 -0
- package/dist/providers/agent37.js +224 -0
- package/dist/providers/agentcore.d.ts +71 -0
- package/dist/providers/agentcore.js +446 -0
- package/dist/providers/alibaba.d.ts +18 -0
- package/dist/providers/alibaba.js +106 -0
- package/dist/providers/azure.d.ts +43 -0
- package/dist/providers/azure.js +330 -0
- package/dist/providers/beam.d.ts +11 -0
- package/dist/providers/beam.js +179 -0
- package/dist/providers/blaxel.d.ts +3 -1
- package/dist/providers/blaxel.js +32 -22
- package/dist/providers/boat.d.ts +25 -0
- package/dist/providers/boat.js +263 -0
- package/dist/providers/cloudflare.d.ts +30 -0
- package/dist/providers/cloudflare.js +152 -0
- package/dist/providers/codesandbox.d.ts +59 -0
- package/dist/providers/codesandbox.js +287 -0
- package/dist/providers/daytona.d.ts +25 -4
- package/dist/providers/daytona.js +449 -28
- package/dist/providers/dedalus.d.ts +12 -0
- package/dist/providers/dedalus.js +244 -0
- package/dist/providers/deno.d.ts +50 -0
- package/dist/providers/deno.js +170 -0
- package/dist/providers/docker.d.ts +17 -0
- package/dist/providers/docker.js +154 -0
- package/dist/providers/e2b.d.ts +70 -3
- package/dist/providers/e2b.js +376 -8
- package/dist/providers/exedev.d.ts +23 -0
- package/dist/providers/exedev.js +290 -0
- package/dist/providers/freestyle.d.ts +49 -0
- package/dist/providers/freestyle.js +261 -0
- package/dist/providers/hopx.d.ts +15 -0
- package/dist/providers/hopx.js +177 -0
- package/dist/providers/instabox.d.ts +76 -0
- package/dist/providers/instabox.js +360 -0
- package/dist/providers/koyeb.d.ts +71 -0
- package/dist/providers/koyeb.js +379 -0
- package/dist/providers/lambda.d.ts +51 -0
- package/dist/providers/lambda.js +467 -0
- package/dist/providers/langsmith.d.ts +16 -0
- package/dist/providers/langsmith.js +149 -0
- package/dist/providers/limrun.d.ts +77 -0
- package/dist/providers/limrun.js +626 -0
- package/dist/providers/maritime.d.ts +5 -1
- package/dist/providers/maritime.js +39 -8
- package/dist/providers/mobilerun.d.ts +44 -0
- package/dist/providers/mobilerun.js +417 -0
- package/dist/providers/modal.js +12 -4
- package/dist/providers/morph.js +12 -4
- package/dist/providers/northflank.d.ts +21 -0
- package/dist/providers/northflank.js +259 -0
- package/dist/providers/novita.d.ts +12 -0
- package/dist/providers/novita.js +81 -0
- package/dist/providers/openscale.d.ts +17 -0
- package/dist/providers/openscale.js +87 -0
- package/dist/providers/orgo.d.ts +45 -0
- package/dist/providers/orgo.js +414 -0
- package/dist/providers/sail.d.ts +32 -0
- package/dist/providers/sail.js +372 -0
- package/dist/providers/superserve.d.ts +25 -0
- package/dist/providers/superserve.js +337 -0
- package/dist/providers/tensorlake.js +17 -14
- package/dist/providers/upstash.d.ts +30 -0
- package/dist/providers/upstash.js +258 -0
- package/dist/providers/vercel.js +14 -3
- package/dist/providers/workspaces.d.ts +60 -0
- package/dist/providers/workspaces.js +662 -0
- package/dist/router.d.ts +36 -5
- package/dist/router.js +174 -25
- package/dist/server.d.ts +1 -1
- package/dist/server.js +22 -3
- package/dist/types.d.ts +18 -6
- package/dist/util.d.ts +28 -1
- package/dist/util.js +56 -7
- package/package.json +106 -17
package/dist/check.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Router } from "./router.js";
|
|
2
|
+
import type { ProviderId, SandboxSpec } from "./types.js";
|
|
3
|
+
/** Every step `check` runs, in order. Each provider's saved report is scored against this list. */
|
|
4
|
+
export declare const CHECK_STEPS: readonly ["create", "exec: stdout, stderr, exit code", "exec: env and cwd", "exec: UTF-8 output", "exec: streaming", "files: binary round-trip", "files: 1 MB round-trip", "files: missing file is an error", "files: visible to commands", "exec: command timeout", "exec: timed-out command was stopped", "pause and resume", "kill", "killed sandbox is gone"];
|
|
5
|
+
export type CheckStatus = "pass" | "fail" | "skip" | "info";
|
|
6
|
+
export interface CheckResult {
|
|
7
|
+
step: string;
|
|
8
|
+
status: CheckStatus;
|
|
9
|
+
ms: number;
|
|
10
|
+
detail?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface CheckReport {
|
|
13
|
+
provider: ProviderId;
|
|
14
|
+
sandboxId?: string;
|
|
15
|
+
results: CheckResult[];
|
|
16
|
+
/** True when no step failed. */
|
|
17
|
+
ok: boolean;
|
|
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 declare function checkProvider(router: Router, provider: ProviderId, opts?: {
|
|
26
|
+
spec?: SandboxSpec;
|
|
27
|
+
onStep?: (r: CheckResult) => void;
|
|
28
|
+
}): Promise<CheckReport>;
|
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 {
|
|
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
|
-
Route flags: --provider
|
|
25
|
-
--no-fallback --
|
|
41
|
+
Route flags: --provider e2b[,modal,...] --strategy cheapest|fastest
|
|
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:
|
|
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 =
|
|
102
|
+
const rows = listingOrder(spec).map((c) => ({
|
|
77
103
|
provider: c.name,
|
|
78
104
|
id: c.id,
|
|
79
|
-
"$/hr":
|
|
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
|
|
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("
|
|
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
|
}
|
package/dist/computers.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
* Linux and Windows desktops, Android phones. Routed like sandboxes: cheapest published price for
|
|
4
|
+
* the requested OS first, ties alphabetical.
|
|
5
5
|
*/
|
|
6
|
-
|
|
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
|
-
/**
|
|
76
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
100
|
+
readonly provider: ComputerProviderId;
|
|
95
101
|
private c;
|
|
96
102
|
readonly id: string;
|
|
97
|
-
constructor(provider:
|
|
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
|
-
/**
|
|
124
|
-
export
|
|
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>;
|