@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.
- package/README.md +224 -48
- package/dist/bench.js +4 -4
- package/dist/catalog.d.ts +159 -0
- package/dist/catalog.js +448 -15
- package/dist/check.d.ts +28 -0
- package/dist/check.js +185 -0
- package/dist/cli.js +148 -11
- 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 +28 -2
- package/dist/index.js +28 -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.js +8 -3
- package/dist/providers/cloudflare.js +8 -3
- 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.js +11 -5
- package/dist/providers/e2b.d.ts +70 -3
- package/dist/providers/e2b.js +376 -8
- package/dist/providers/exedev.js +17 -5
- package/dist/providers/freestyle.d.ts +49 -0
- package/dist/providers/freestyle.js +261 -0
- package/dist/providers/hopx.js +27 -18
- 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.js +11 -13
- 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 +149 -19
- package/dist/server.d.ts +1 -1
- package/dist/server.js +22 -3
- package/dist/types.d.ts +13 -5
- package/dist/util.d.ts +28 -1
- package/dist/util.js +56 -7
- package/package.json +80 -5
package/dist/router.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { RoutedComputer, type ComputerProvider, type ComputerSpec } from "./computers.js";
|
|
1
|
+
import { RoutedComputer, type ComputerOs, type ComputerProvider, type ComputerProviderId, type ComputerSpec } from "./computers.js";
|
|
2
|
+
import { Ledger } from "./ledger.js";
|
|
2
3
|
import { type Attempt, type ExecOptions, type ExecResult, type Provider, type ProviderId, type ProviderSandbox, type RouteOptions, type SandboxSpec } from "./types.js";
|
|
3
4
|
export interface Candidate {
|
|
4
5
|
provider: ProviderId;
|
|
@@ -12,11 +13,24 @@ export interface RouterOptions {
|
|
|
12
13
|
providers?: Provider[];
|
|
13
14
|
/** Path of the bench results file used by strategy "fastest". */
|
|
14
15
|
benchFile?: string;
|
|
16
|
+
/** Desktop-only computer providers (sandbox providers that also sell computers are found automatically). */
|
|
17
|
+
computers?: ComputerProvider[];
|
|
18
|
+
/**
|
|
19
|
+
* Record created sandboxes so leaks can be found and killed (`sandboxrouter ls` / `gc`).
|
|
20
|
+
* Default: ~/.sandboxrouter/sandboxes.jsonl (or $SANDBOXROUTER_HOME); a path for another file;
|
|
21
|
+
* false to keep no record. SANDBOXROUTER_LEDGER=off also turns it off.
|
|
22
|
+
*/
|
|
23
|
+
ledger?: string | false;
|
|
15
24
|
}
|
|
25
|
+
/** Desktop-only providers. Filled in as adapters are added. */
|
|
26
|
+
export declare function defaultComputerProviders(): ComputerProvider[];
|
|
16
27
|
export declare function defaultProviders(): Provider[];
|
|
17
28
|
export declare class Router {
|
|
18
29
|
private providers;
|
|
19
30
|
private benchFile?;
|
|
31
|
+
private computerOnly;
|
|
32
|
+
/** The local record of sandboxes this router created, or undefined when turned off. */
|
|
33
|
+
readonly ledger?: Ledger;
|
|
20
34
|
constructor(opts?: RouterOptions);
|
|
21
35
|
/** Registered and has credentials. False for providers this router doesn't know. */
|
|
22
36
|
isConfigured(id: ProviderId): boolean;
|
|
@@ -27,11 +41,17 @@ export declare class Router {
|
|
|
27
41
|
plan(spec?: SandboxSpec, route?: RouteOptions): Candidate[];
|
|
28
42
|
/** Start a sandbox on the best available provider, falling back down the list on failure. */
|
|
29
43
|
create(spec?: SandboxSpec, route?: RouteOptions): Promise<RoutedSandbox>;
|
|
30
|
-
|
|
44
|
+
private record;
|
|
45
|
+
private discard;
|
|
46
|
+
/** Every provider that sells computers (desktops/phones): sandbox providers that do, plus desktop-only ones. */
|
|
31
47
|
computerProviders(): ComputerProvider[];
|
|
32
|
-
/**
|
|
48
|
+
/** The order a computer create would try for this OS. */
|
|
49
|
+
planComputer(os?: ComputerOs, opts?: {
|
|
50
|
+
provider?: ComputerProviderId | ComputerProviderId[];
|
|
51
|
+
}): import("./computers.js").ComputerCandidate[];
|
|
52
|
+
/** Start a desktop or phone on the cheapest provider for its OS (ties alphabetical), with fallback. */
|
|
33
53
|
computer(spec?: ComputerSpec, opts?: {
|
|
34
|
-
provider?:
|
|
54
|
+
provider?: ComputerProviderId | ComputerProviderId[];
|
|
35
55
|
}): Promise<RoutedComputer>;
|
|
36
56
|
getComputer(id: string): Promise<RoutedComputer>;
|
|
37
57
|
/** Reattach to a sandbox by its routed id (`provider:nativeId`). */
|
|
@@ -43,12 +63,19 @@ export declare class RoutedSandbox {
|
|
|
43
63
|
/** Every create attempt, including failed fallbacks. */
|
|
44
64
|
readonly attempts: Attempt[];
|
|
45
65
|
readonly estimatedHourlyUsd: number;
|
|
66
|
+
private ledger?;
|
|
46
67
|
/** Stable id across processes: `provider:nativeId`. */
|
|
47
68
|
readonly id: string;
|
|
48
69
|
constructor(provider: ProviderId, box: ProviderSandbox,
|
|
49
70
|
/** Every create attempt, including failed fallbacks. */
|
|
50
|
-
attempts: Attempt[], estimatedHourlyUsd: number);
|
|
71
|
+
attempts: Attempt[], estimatedHourlyUsd: number, ledger?: Ledger | undefined);
|
|
51
72
|
get nativeId(): string;
|
|
73
|
+
/**
|
|
74
|
+
* Run a command. If it outlives `opts.timeoutSeconds` the adapter stops it and this rejects with
|
|
75
|
+
* `ProviderError("<provider>: command timed out after Ns")`. Should an adapter fail to notice its
|
|
76
|
+
* own deadline, the router gives up `EXEC_GRACE_S` later with the same error, so callers can
|
|
77
|
+
* always rely on the timeout being bounded and the message being the same on every provider.
|
|
78
|
+
*/
|
|
52
79
|
exec(cmd: string, opts?: ExecOptions): Promise<ExecResult>;
|
|
53
80
|
writeFile(path: string, content: string | Uint8Array): Promise<void>;
|
|
54
81
|
readFile(path: string): Promise<Uint8Array>;
|
|
@@ -63,3 +90,7 @@ export declare function parseId(id: string): {
|
|
|
63
90
|
provider: ProviderId;
|
|
64
91
|
nativeId: string;
|
|
65
92
|
};
|
|
93
|
+
/** Seconds to wait for one provider's create before trying the next (RouteOptions.createTimeoutSeconds). */
|
|
94
|
+
export declare const DEFAULT_CREATE_TIMEOUT_S = 120;
|
|
95
|
+
/** Seconds past a command's timeout before the router stops waiting for the adapter. */
|
|
96
|
+
export declare const EXEC_GRACE_S = 30;
|
package/dist/router.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { CATALOG, hourlyCost, unfit } from "./catalog.js";
|
|
2
|
-
import { RoutedComputer, createComputer } from "./computers.js";
|
|
1
|
+
import { CATALOG, accessLabel, hourlyCost, unfit } from "./catalog.js";
|
|
2
|
+
import { RoutedComputer, createComputer, planComputer, } from "./computers.js";
|
|
3
3
|
import { loadBench, median } from "./bench-store.js";
|
|
4
|
+
import { Ledger, defaultLedgerFile } from "./ledger.js";
|
|
5
|
+
import { DEFAULT_EXEC_TIMEOUT_S, timeoutError } from "./util.js";
|
|
4
6
|
import { DaytonaProvider } from "./providers/daytona.js";
|
|
5
7
|
import { E2BProvider } from "./providers/e2b.js";
|
|
6
8
|
import { MaritimeProvider } from "./providers/maritime.js";
|
|
@@ -17,7 +19,33 @@ import { CloudflareProvider } from "./providers/cloudflare.js";
|
|
|
17
19
|
import { NorthflankProvider } from "./providers/northflank.js";
|
|
18
20
|
import { BoatProvider } from "./providers/boat.js";
|
|
19
21
|
import { ExeDevProvider } from "./providers/exedev.js";
|
|
20
|
-
import {
|
|
22
|
+
import { AgentCoreProvider } from "./providers/agentcore.js";
|
|
23
|
+
import { AzureSessionsProvider } from "./providers/azure.js";
|
|
24
|
+
import { LambdaMicrovmProvider } from "./providers/lambda.js";
|
|
25
|
+
import { AlibabaProvider } from "./providers/alibaba.js";
|
|
26
|
+
import { BeamProvider } from "./providers/beam.js";
|
|
27
|
+
import { CodeSandboxProvider } from "./providers/codesandbox.js";
|
|
28
|
+
import { DenoProvider } from "./providers/deno.js";
|
|
29
|
+
import { FreestyleProvider } from "./providers/freestyle.js";
|
|
30
|
+
import { KoyebProvider } from "./providers/koyeb.js";
|
|
31
|
+
import { DedalusProvider } from "./providers/dedalus.js";
|
|
32
|
+
import { Agent37Provider } from "./providers/agent37.js";
|
|
33
|
+
import { OpenScaleProvider } from "./providers/openscale.js";
|
|
34
|
+
import { SuperserveProvider } from "./providers/superserve.js";
|
|
35
|
+
import { SailProvider } from "./providers/sail.js";
|
|
36
|
+
import { InstaboxProvider } from "./providers/instabox.js";
|
|
37
|
+
import { NovitaProvider } from "./providers/novita.js";
|
|
38
|
+
import { LangSmithProvider } from "./providers/langsmith.js";
|
|
39
|
+
import { UpstashProvider } from "./providers/upstash.js";
|
|
40
|
+
import { OrgoProvider } from "./providers/orgo.js";
|
|
41
|
+
import { MobilerunProvider } from "./providers/mobilerun.js";
|
|
42
|
+
import { LimrunProvider } from "./providers/limrun.js";
|
|
43
|
+
import { WorkspacesAgentProvider } from "./providers/workspaces.js";
|
|
44
|
+
import { ProviderError, RouterError, } from "./types.js";
|
|
45
|
+
/** Desktop-only providers. Filled in as adapters are added. */
|
|
46
|
+
export function defaultComputerProviders() {
|
|
47
|
+
return [new OrgoProvider(), new MobilerunProvider(), new LimrunProvider(), new WorkspacesAgentProvider()];
|
|
48
|
+
}
|
|
21
49
|
export function defaultProviders() {
|
|
22
50
|
return [
|
|
23
51
|
new MaritimeProvider(),
|
|
@@ -35,15 +63,40 @@ export function defaultProviders() {
|
|
|
35
63
|
new NorthflankProvider(),
|
|
36
64
|
new BoatProvider(),
|
|
37
65
|
new ExeDevProvider(),
|
|
66
|
+
new AgentCoreProvider(),
|
|
67
|
+
new AzureSessionsProvider(),
|
|
68
|
+
new LambdaMicrovmProvider(),
|
|
69
|
+
new Agent37Provider(),
|
|
70
|
+
new AlibabaProvider(),
|
|
71
|
+
new BeamProvider(),
|
|
72
|
+
new CodeSandboxProvider(),
|
|
73
|
+
new DedalusProvider(),
|
|
74
|
+
new DenoProvider(),
|
|
75
|
+
new FreestyleProvider(),
|
|
76
|
+
new InstaboxProvider(),
|
|
77
|
+
new KoyebProvider(),
|
|
78
|
+
new NovitaProvider(),
|
|
79
|
+
new LangSmithProvider(),
|
|
80
|
+
new OpenScaleProvider(),
|
|
81
|
+
new SuperserveProvider(),
|
|
82
|
+
new SailProvider(),
|
|
83
|
+
new UpstashProvider(),
|
|
38
84
|
new DockerProvider(),
|
|
39
85
|
];
|
|
40
86
|
}
|
|
41
87
|
export class Router {
|
|
42
88
|
providers;
|
|
43
89
|
benchFile;
|
|
90
|
+
computerOnly;
|
|
91
|
+
/** The local record of sandboxes this router created, or undefined when turned off. */
|
|
92
|
+
ledger;
|
|
44
93
|
constructor(opts = {}) {
|
|
94
|
+
const file = opts.ledger === false ? null : (opts.ledger ?? defaultLedgerFile());
|
|
95
|
+
if (file)
|
|
96
|
+
this.ledger = new Ledger(file);
|
|
45
97
|
this.providers = new Map((opts.providers ?? defaultProviders()).map((p) => [p.id, p]));
|
|
46
98
|
this.benchFile = opts.benchFile;
|
|
99
|
+
this.computerOnly = opts.computers ?? (opts.providers ? [] : defaultComputerProviders());
|
|
47
100
|
}
|
|
48
101
|
/** Registered and has credentials. False for providers this router doesn't know. */
|
|
49
102
|
isConfigured(id) {
|
|
@@ -67,6 +120,9 @@ export class Router {
|
|
|
67
120
|
const ids = explicit ?? CATALOG.filter((c) => !c.local && this.providers.has(c.id)).map((c) => c.id);
|
|
68
121
|
const bench = route.strategy === "fastest" ? loadBench(this.benchFile) : {};
|
|
69
122
|
const list = ids.map((id) => {
|
|
123
|
+
// An id the catalog has never heard of (a typo in an explicit list) has no price to look up.
|
|
124
|
+
if (!CATALOG.some((c) => c.id === id))
|
|
125
|
+
return { provider: id, estimatedHourlyUsd: 0, skip: "unknown provider" };
|
|
70
126
|
const p = this.providers.get(id);
|
|
71
127
|
const c = {
|
|
72
128
|
provider: id,
|
|
@@ -74,9 +130,11 @@ export class Router {
|
|
|
74
130
|
medianCreateMs: median(bench[id]?.createMs),
|
|
75
131
|
};
|
|
76
132
|
if (!p)
|
|
77
|
-
c.skip = "
|
|
78
|
-
else
|
|
79
|
-
|
|
133
|
+
c.skip = "no adapter registered";
|
|
134
|
+
else {
|
|
135
|
+
const gated = accessLabel(id);
|
|
136
|
+
c.skip = unfit(id, spec, route.require) ?? (p.configured() ? undefined : gated ? `not configured; ${gated}` : "not configured");
|
|
137
|
+
}
|
|
80
138
|
return c;
|
|
81
139
|
});
|
|
82
140
|
// Neutral by construction: price (or measured speed) decides, ties break alphabetically by id.
|
|
@@ -102,40 +160,81 @@ export class Router {
|
|
|
102
160
|
}
|
|
103
161
|
const attempts = [];
|
|
104
162
|
const tries = route.fallback === false ? usable.slice(0, 1) : usable;
|
|
163
|
+
const limitS = route.createTimeoutSeconds ?? DEFAULT_CREATE_TIMEOUT_S;
|
|
105
164
|
for (const c of tries) {
|
|
106
165
|
const t0 = Date.now();
|
|
166
|
+
const pending = this.provider(c.provider).create(spec);
|
|
167
|
+
let timedOut = false;
|
|
107
168
|
try {
|
|
108
|
-
const box = await
|
|
169
|
+
const box = await withDeadline(pending, limitS * 1000, () => {
|
|
170
|
+
timedOut = true;
|
|
171
|
+
return new ProviderError(c.provider, `create timed out after ${limitS}s`);
|
|
172
|
+
});
|
|
109
173
|
attempts.push({ provider: c.provider, ok: true, ms: Date.now() - t0 });
|
|
110
|
-
|
|
174
|
+
const sbx = new RoutedSandbox(c.provider, box, attempts, c.estimatedHourlyUsd, this.ledger);
|
|
175
|
+
this.record(sbx, c.estimatedHourlyUsd, spec.timeoutMinutes);
|
|
176
|
+
return sbx;
|
|
111
177
|
}
|
|
112
178
|
catch (err) {
|
|
179
|
+
// A create that answers after we gave up still made a sandbox: record it, then kill it,
|
|
180
|
+
// so it can't bill unseen. If the kill fails, the ledger entry is left for `gc`.
|
|
181
|
+
if (timedOut)
|
|
182
|
+
pending.then((late) => this.discard(c.provider, late, c.estimatedHourlyUsd, spec.timeoutMinutes), () => { });
|
|
113
183
|
attempts.push({ provider: c.provider, ok: false, ms: Date.now() - t0, error: err.message });
|
|
184
|
+
const status = err instanceof ProviderError ? err.status : undefined;
|
|
185
|
+
if (route.fallback !== "any" && status !== undefined && SETUP_ERRORS.has(status)) {
|
|
186
|
+
throw new RouterError(`${err.message.replace(/[.\s]+$/, "")}. This looks like a setup problem with ${c.provider} (key, billing, image or request), ` +
|
|
187
|
+
`so the router stopped instead of moving the work to another provider; pass fallback: "any" to fall back on every error`, attempts);
|
|
188
|
+
}
|
|
114
189
|
}
|
|
115
190
|
}
|
|
116
191
|
throw new RouterError(`all providers failed: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts);
|
|
117
192
|
}
|
|
118
|
-
|
|
193
|
+
record(sbx, usdHour, timeoutMinutes) {
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
this.ledger?.open({
|
|
196
|
+
id: sbx.id,
|
|
197
|
+
provider: sbx.provider,
|
|
198
|
+
createdAt: new Date(now).toISOString(),
|
|
199
|
+
expiresAt: new Date(now + (timeoutMinutes ?? 30) * 60_000).toISOString(),
|
|
200
|
+
usdHour,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
discard(provider, box, usdHour, timeoutMinutes) {
|
|
204
|
+
const sbx = new RoutedSandbox(provider, box, [], usdHour, this.ledger);
|
|
205
|
+
this.record(sbx, usdHour, timeoutMinutes);
|
|
206
|
+
return sbx.kill().catch(() => { });
|
|
207
|
+
}
|
|
208
|
+
/** Every provider that sells computers (desktops/phones): sandbox providers that do, plus desktop-only ones. */
|
|
119
209
|
computerProviders() {
|
|
120
|
-
|
|
210
|
+
const fromSandboxes = [...this.providers.values()].filter((p) => "createComputer" in p);
|
|
211
|
+
return [...fromSandboxes, ...this.computerOnly];
|
|
121
212
|
}
|
|
122
|
-
/**
|
|
213
|
+
/** The order a computer create would try for this OS. */
|
|
214
|
+
planComputer(os = "linux", opts = {}) {
|
|
215
|
+
const prefer = opts.provider ? [].concat(opts.provider) : undefined;
|
|
216
|
+
return planComputer(this.computerProviders(), os, prefer);
|
|
217
|
+
}
|
|
218
|
+
/** Start a desktop or phone on the cheapest provider for its OS (ties alphabetical), with fallback. */
|
|
123
219
|
computer(spec = {}, opts = {}) {
|
|
124
220
|
const prefer = opts.provider ? [].concat(opts.provider) : undefined;
|
|
125
221
|
return createComputer(this.computerProviders(), spec, prefer);
|
|
126
222
|
}
|
|
127
223
|
async getComputer(id) {
|
|
128
|
-
const
|
|
224
|
+
const i = id.indexOf(":");
|
|
225
|
+
if (i <= 0)
|
|
226
|
+
throw new RouterError(`bad computer id "${id}": expected provider:id`);
|
|
227
|
+
const provider = id.slice(0, i);
|
|
129
228
|
const p = this.computerProviders().find((c) => c.id === provider);
|
|
130
229
|
if (!p)
|
|
131
230
|
throw new RouterError(`${provider} does not offer computers`);
|
|
132
|
-
return new RoutedComputer(provider, await p.connectComputer(
|
|
231
|
+
return new RoutedComputer(provider, await p.connectComputer(id.slice(i + 1)));
|
|
133
232
|
}
|
|
134
233
|
/** Reattach to a sandbox by its routed id (`provider:nativeId`). */
|
|
135
234
|
async get(id) {
|
|
136
235
|
const { provider, nativeId } = parseId(id);
|
|
137
236
|
const box = await this.provider(provider).connect(nativeId);
|
|
138
|
-
return new RoutedSandbox(provider, box, [], round(hourlyCost(provider)));
|
|
237
|
+
return new RoutedSandbox(provider, box, [], round(hourlyCost(provider)), this.ledger);
|
|
139
238
|
}
|
|
140
239
|
}
|
|
141
240
|
export class RoutedSandbox {
|
|
@@ -143,22 +242,31 @@ export class RoutedSandbox {
|
|
|
143
242
|
box;
|
|
144
243
|
attempts;
|
|
145
244
|
estimatedHourlyUsd;
|
|
245
|
+
ledger;
|
|
146
246
|
/** Stable id across processes: `provider:nativeId`. */
|
|
147
247
|
id;
|
|
148
248
|
constructor(provider, box,
|
|
149
249
|
/** Every create attempt, including failed fallbacks. */
|
|
150
|
-
attempts, estimatedHourlyUsd) {
|
|
250
|
+
attempts, estimatedHourlyUsd, ledger) {
|
|
151
251
|
this.provider = provider;
|
|
152
252
|
this.box = box;
|
|
153
253
|
this.attempts = attempts;
|
|
154
254
|
this.estimatedHourlyUsd = estimatedHourlyUsd;
|
|
255
|
+
this.ledger = ledger;
|
|
155
256
|
this.id = `${provider}:${box.nativeId}`;
|
|
156
257
|
}
|
|
157
258
|
get nativeId() {
|
|
158
259
|
return this.box.nativeId;
|
|
159
260
|
}
|
|
160
|
-
|
|
161
|
-
|
|
261
|
+
/**
|
|
262
|
+
* Run a command. If it outlives `opts.timeoutSeconds` the adapter stops it and this rejects with
|
|
263
|
+
* `ProviderError("<provider>: command timed out after Ns")`. Should an adapter fail to notice its
|
|
264
|
+
* own deadline, the router gives up `EXEC_GRACE_S` later with the same error, so callers can
|
|
265
|
+
* always rely on the timeout being bounded and the message being the same on every provider.
|
|
266
|
+
*/
|
|
267
|
+
exec(cmd, opts = {}) {
|
|
268
|
+
const seconds = opts.timeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_S;
|
|
269
|
+
return withDeadline(this.box.exec(cmd, opts), (seconds + EXEC_GRACE_S) * 1000, () => timeoutError(this.provider, seconds));
|
|
162
270
|
}
|
|
163
271
|
writeFile(path, content) {
|
|
164
272
|
return this.box.writeFile(path, content);
|
|
@@ -169,8 +277,9 @@ export class RoutedSandbox {
|
|
|
169
277
|
async readText(path) {
|
|
170
278
|
return new TextDecoder().decode(await this.box.readFile(path));
|
|
171
279
|
}
|
|
172
|
-
kill() {
|
|
173
|
-
|
|
280
|
+
async kill() {
|
|
281
|
+
await this.box.kill();
|
|
282
|
+
this.ledger?.close(this.id);
|
|
174
283
|
}
|
|
175
284
|
/** Pause, keeping the disk. Resume with router.get(id) or, on Maritime, any call. */
|
|
176
285
|
async pause() {
|
|
@@ -197,3 +306,24 @@ export function parseId(id) {
|
|
|
197
306
|
function round(n) {
|
|
198
307
|
return Math.round(n * 10000) / 10000;
|
|
199
308
|
}
|
|
309
|
+
/** Seconds to wait for one provider's create before trying the next (RouteOptions.createTimeoutSeconds). */
|
|
310
|
+
export const DEFAULT_CREATE_TIMEOUT_S = 120;
|
|
311
|
+
/** HTTP statuses that mean the request or the account is wrong, not that the provider is down. */
|
|
312
|
+
const SETUP_ERRORS = new Set([400, 401, 402, 403, 404, 422]);
|
|
313
|
+
/** Seconds past a command's timeout before the router stops waiting for the adapter. */
|
|
314
|
+
export const EXEC_GRACE_S = 30;
|
|
315
|
+
/** Resolve with `work`, or reject with `onExpire()` after `ms`. A late `work` rejection is swallowed. */
|
|
316
|
+
async function withDeadline(work, ms, onExpire) {
|
|
317
|
+
let timer;
|
|
318
|
+
const expired = new Promise((_, reject) => {
|
|
319
|
+
timer = setTimeout(() => reject(onExpire()), ms);
|
|
320
|
+
timer.unref();
|
|
321
|
+
});
|
|
322
|
+
try {
|
|
323
|
+
return await Promise.race([work, expired]);
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
clearTimeout(timer);
|
|
327
|
+
work.catch(() => { });
|
|
328
|
+
}
|
|
329
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { Router } from "./router.js";
|
|
|
6
6
|
*
|
|
7
7
|
* GET /v1/providers
|
|
8
8
|
* POST /v1/route dry run: which providers would be tried, in order
|
|
9
|
-
* POST /v1/sandboxes { vcpu, memoryGb, ..., provider, strategy, fallback, require }
|
|
9
|
+
* POST /v1/sandboxes { vcpu, memoryGb, ..., provider, strategy, fallback, require, createTimeoutSeconds }
|
|
10
10
|
* POST /v1/sandboxes/:id/exec { cmd, cwd, env, timeoutSeconds, stream }
|
|
11
11
|
* stream: true answers text/event-stream: `stdout`/`stderr` events (JSON string chunks),
|
|
12
12
|
* then one `result` event (the ExecResult), or an `error` event
|
package/dist/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
1
2
|
import { createServer } from "node:http";
|
|
2
3
|
import { CATALOG, hourlyCost } from "./catalog.js";
|
|
3
4
|
import { Router } from "./router.js";
|
|
@@ -8,7 +9,7 @@ import { ProviderError, RouterError } from "./types.js";
|
|
|
8
9
|
*
|
|
9
10
|
* GET /v1/providers
|
|
10
11
|
* POST /v1/route dry run: which providers would be tried, in order
|
|
11
|
-
* POST /v1/sandboxes { vcpu, memoryGb, ..., provider, strategy, fallback, require }
|
|
12
|
+
* POST /v1/sandboxes { vcpu, memoryGb, ..., provider, strategy, fallback, require, createTimeoutSeconds }
|
|
12
13
|
* POST /v1/sandboxes/:id/exec { cmd, cwd, env, timeoutSeconds, stream }
|
|
13
14
|
* stream: true answers text/event-stream: `stdout`/`stderr` events (JSON string chunks),
|
|
14
15
|
* then one `result` event (the ExecResult), or an `error` event
|
|
@@ -31,6 +32,19 @@ import { ProviderError, RouterError } from "./types.js";
|
|
|
31
32
|
export function createApp(router = new Router(), token = process.env.SANDBOXROUTER_TOKEN,
|
|
32
33
|
/** Browser origins allowed to call this server (e.g. the website's Playground). Off by default. */
|
|
33
34
|
corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
35
|
+
// With CORS on, any page open in a browser on this machine can call the server (Chrome even
|
|
36
|
+
// lets a public page reach localhost, which the Private-Network header below allows), so it
|
|
37
|
+
// would start sandboxes on these provider keys. A token is required for that.
|
|
38
|
+
if (corsOrigins.length && !token) {
|
|
39
|
+
throw new Error("CORS is on but SANDBOXROUTER_TOKEN is not set: browser pages could start sandboxes on your keys. Set a token, or turn CORS off.");
|
|
40
|
+
}
|
|
41
|
+
const expected = token ? Buffer.from(`Bearer ${token}`) : undefined;
|
|
42
|
+
const authorized = (req) => {
|
|
43
|
+
if (!expected)
|
|
44
|
+
return true;
|
|
45
|
+
const got = Buffer.from(req.headers.authorization ?? "");
|
|
46
|
+
return got.length === expected.length && timingSafeEqual(got, expected);
|
|
47
|
+
};
|
|
34
48
|
const live = new Map();
|
|
35
49
|
const cors = (req, res) => {
|
|
36
50
|
const origin = req.headers.origin;
|
|
@@ -57,7 +71,7 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
57
71
|
}
|
|
58
72
|
if (url.pathname === "/health")
|
|
59
73
|
return json(res, 200, { ok: true });
|
|
60
|
-
if (
|
|
74
|
+
if (!authorized(req))
|
|
61
75
|
return json(res, 401, { error: "unauthorized" });
|
|
62
76
|
if (parts[0] !== "v1")
|
|
63
77
|
return json(res, 404, { error: "not found" });
|
|
@@ -220,12 +234,17 @@ class HttpError extends Error {
|
|
|
220
234
|
this.status = status;
|
|
221
235
|
}
|
|
222
236
|
}
|
|
223
|
-
const ROUTE_KEYS = ["provider", "strategy", "fallback", "require"];
|
|
237
|
+
const ROUTE_KEYS = ["provider", "strategy", "fallback", "require", "createTimeoutSeconds"];
|
|
224
238
|
function splitBody(body) {
|
|
225
239
|
const route = {};
|
|
226
240
|
const spec = {};
|
|
227
241
|
for (const [k, v] of Object.entries(body))
|
|
228
242
|
(ROUTE_KEYS.includes(k) ? route : spec)[k] = v;
|
|
243
|
+
// A provider id the catalog does not know is the client's mistake, not a routing failure.
|
|
244
|
+
const ids = route.provider === undefined ? [] : [].concat(route.provider);
|
|
245
|
+
const unknown = ids.filter((id) => !CATALOG.some((c) => c.id === id));
|
|
246
|
+
if (unknown.length)
|
|
247
|
+
throw new HttpError(400, `unknown provider: ${unknown.join(", ")}`);
|
|
229
248
|
return { spec: spec, route: route };
|
|
230
249
|
}
|
|
231
250
|
const MAX_BODY = 100 * 1024 * 1024;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ProviderId = "maritime" | "e2b" | "daytona" | "modal" | "vercel" | "morph" | "sprites" | "blaxel" | "tensorlake" | "runloop" | "hopx" | "cloudflare" | "northflank" | "boat" | "exedev" | "docker";
|
|
1
|
+
export type ProviderId = "maritime" | "e2b" | "daytona" | "modal" | "vercel" | "morph" | "sprites" | "blaxel" | "tensorlake" | "runloop" | "hopx" | "cloudflare" | "northflank" | "boat" | "exedev" | "docker" | "novita" | "langsmith" | "alibaba" | "upstash" | "koyeb" | "freestyle" | "deno" | "beam" | "codesandbox" | "azure" | "agentcore" | "lambda" | "dedalus" | "agent37" | "superserve" | "instabox" | "openscale" | "sail";
|
|
2
2
|
/** Things a caller can require of a provider. The router skips providers that lack one. */
|
|
3
3
|
export type Capability = "gpu" | "pause" | "custom-image";
|
|
4
4
|
/** What the caller wants. Every field is optional; providers map it to their own knobs. */
|
|
@@ -31,8 +31,16 @@ export interface RouteOptions {
|
|
|
31
31
|
provider?: ProviderId | ProviderId[];
|
|
32
32
|
/** How to order candidates when no explicit list is given. Default: cheapest. */
|
|
33
33
|
strategy?: Strategy;
|
|
34
|
-
/**
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* What to do when a create fails. `true` (default): try the next candidate when the provider
|
|
36
|
+
* looks unavailable (timeout, network error, 429, 5xx, or an error without a status), but stop on
|
|
37
|
+
* a setup error (400, 401, 402, 403, 404, 422: a bad key, no billing, a bad image or request), so
|
|
38
|
+
* a mistake isn't hidden by quietly moving the work to another, maybe pricier, provider.
|
|
39
|
+
* `"any"`: try the next candidate on every error. `false`: only the first candidate.
|
|
40
|
+
*/
|
|
41
|
+
fallback?: boolean | "any";
|
|
42
|
+
/** Seconds to wait for each provider's create before moving on (default 120). */
|
|
43
|
+
createTimeoutSeconds?: number;
|
|
36
44
|
/** Capabilities every candidate must have. */
|
|
37
45
|
require?: Capability[];
|
|
38
46
|
}
|
|
@@ -81,7 +89,7 @@ export declare class RouterError extends Error {
|
|
|
81
89
|
}
|
|
82
90
|
/** Thrown by adapters for HTTP failures so the router can report status codes. */
|
|
83
91
|
export declare class ProviderError extends Error {
|
|
84
|
-
readonly provider: ProviderId;
|
|
92
|
+
readonly provider: ProviderId | (string & {});
|
|
85
93
|
readonly status?: number | undefined;
|
|
86
|
-
constructor(provider: ProviderId, message: string, status?: number | undefined);
|
|
94
|
+
constructor(provider: ProviderId | (string & {}), message: string, status?: number | undefined);
|
|
87
95
|
}
|
package/dist/util.d.ts
CHANGED
|
@@ -1,10 +1,37 @@
|
|
|
1
|
+
import { ProviderError, type ProviderId } from "./types.js";
|
|
2
|
+
/** Per-command timeout when ExecOptions.timeoutSeconds is not given. */
|
|
3
|
+
export declare const DEFAULT_EXEC_TIMEOUT_S = 60;
|
|
1
4
|
/** Single-quote a string for POSIX sh. */
|
|
2
5
|
export declare function shq(s: string): string;
|
|
3
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Build `cd dir && export VAR=x && { cmd }` for providers whose exec API lacks cwd/env.
|
|
8
|
+
*
|
|
9
|
+
* Every step is chained with `&&`, so a missing cwd stops the command from running somewhere
|
|
10
|
+
* else, and the shell exits with cd's status instead. The command itself is grouped so its own
|
|
11
|
+
* `;` or `||` can't escape the chain; the newline before `}` keeps a trailing `#` comment in the
|
|
12
|
+
* command from swallowing the brace.
|
|
13
|
+
*/
|
|
4
14
|
export declare function wrapCommand(cmd: string, opts?: {
|
|
5
15
|
cwd?: string;
|
|
6
16
|
env?: Record<string, string>;
|
|
7
17
|
}): string;
|
|
18
|
+
/** The one error every adapter throws when a command outlives ExecOptions.timeoutSeconds. */
|
|
19
|
+
export declare function timeoutError(provider: ProviderId | (string & {}), seconds: number): ProviderError;
|
|
20
|
+
/**
|
|
21
|
+
* True when an exit code is the one left behind by a timeout kill and the command really ran
|
|
22
|
+
* for the whole timeout: 124 from coreutils `timeout`, 137 after SIGKILL, 143 after SIGTERM, or a
|
|
23
|
+
* negative code (how some SDKs report a signal death, and what adapters use for "no exit code").
|
|
24
|
+
* The elapsed check keeps a command that itself exits 124 quickly from being reported as a timeout.
|
|
25
|
+
*/
|
|
26
|
+
export declare function killedByTimeout(exitCode: number, elapsedMs: number, seconds: number): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Shell text that runs `cmd` and kills it after `seconds`, for exec APIs that cannot stop a
|
|
29
|
+
* running command themselves. Prefers coreutils `timeout`, which stops the whole process group
|
|
30
|
+
* (exit 124, then SIGKILL 5 s later, exit 137). When the image has none, a plain-sh watchdog
|
|
31
|
+
* kills the command's shell and, where `pkill` exists, its children (exit 137). Callers detect
|
|
32
|
+
* the kill with `killedByTimeout`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function guardTimeout(cmd: string, seconds: number): string;
|
|
8
35
|
export declare function toBytes(content: string | Uint8Array): Uint8Array;
|
|
9
36
|
export declare function dirname(path: string): string;
|
|
10
37
|
/** Load an optional peer dependency with a helpful error. */
|
package/dist/util.js
CHANGED
|
@@ -1,19 +1,66 @@
|
|
|
1
|
+
import { ProviderError } from "./types.js";
|
|
2
|
+
/** Per-command timeout when ExecOptions.timeoutSeconds is not given. */
|
|
3
|
+
export const DEFAULT_EXEC_TIMEOUT_S = 60;
|
|
1
4
|
/** Single-quote a string for POSIX sh. */
|
|
2
5
|
export function shq(s) {
|
|
3
6
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
4
7
|
}
|
|
5
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Build `cd dir && export VAR=x && { cmd }` for providers whose exec API lacks cwd/env.
|
|
10
|
+
*
|
|
11
|
+
* Every step is chained with `&&`, so a missing cwd stops the command from running somewhere
|
|
12
|
+
* else, and the shell exits with cd's status instead. The command itself is grouped so its own
|
|
13
|
+
* `;` or `||` can't escape the chain; the newline before `}` keeps a trailing `#` comment in the
|
|
14
|
+
* command from swallowing the brace.
|
|
15
|
+
*/
|
|
6
16
|
export function wrapCommand(cmd, opts = {}) {
|
|
7
|
-
const
|
|
17
|
+
const prelude = [];
|
|
8
18
|
if (opts.cwd)
|
|
9
|
-
|
|
19
|
+
prelude.push(`cd ${shq(opts.cwd)}`);
|
|
10
20
|
for (const [k, v] of Object.entries(opts.env ?? {})) {
|
|
11
21
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k))
|
|
12
22
|
throw new Error(`invalid env var name: ${k}`);
|
|
13
|
-
|
|
23
|
+
prelude.push(`export ${k}=${shq(v)}`);
|
|
14
24
|
}
|
|
15
|
-
|
|
16
|
-
|
|
25
|
+
if (prelude.length === 0)
|
|
26
|
+
return cmd;
|
|
27
|
+
return `${prelude.join(" && ")} && {\n${cmd}\n}`;
|
|
28
|
+
}
|
|
29
|
+
/** The one error every adapter throws when a command outlives ExecOptions.timeoutSeconds. */
|
|
30
|
+
export function timeoutError(provider, seconds) {
|
|
31
|
+
return new ProviderError(provider, `command timed out after ${seconds}s`);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* True when an exit code is the one left behind by a timeout kill and the command really ran
|
|
35
|
+
* for the whole timeout: 124 from coreutils `timeout`, 137 after SIGKILL, 143 after SIGTERM, or a
|
|
36
|
+
* negative code (how some SDKs report a signal death, and what adapters use for "no exit code").
|
|
37
|
+
* The elapsed check keeps a command that itself exits 124 quickly from being reported as a timeout.
|
|
38
|
+
*/
|
|
39
|
+
export function killedByTimeout(exitCode, elapsedMs, seconds) {
|
|
40
|
+
const killed = exitCode === 124 || exitCode === 137 || exitCode === 143 || exitCode < 0;
|
|
41
|
+
return killed && elapsedMs >= seconds * 1000 - TIMEOUT_SLACK_MS;
|
|
42
|
+
}
|
|
43
|
+
/** Clocks on the two sides of an exec call differ by a little; a kill this close to the deadline still counts. */
|
|
44
|
+
const TIMEOUT_SLACK_MS = 250;
|
|
45
|
+
/**
|
|
46
|
+
* Shell text that runs `cmd` and kills it after `seconds`, for exec APIs that cannot stop a
|
|
47
|
+
* running command themselves. Prefers coreutils `timeout`, which stops the whole process group
|
|
48
|
+
* (exit 124, then SIGKILL 5 s later, exit 137). When the image has none, a plain-sh watchdog
|
|
49
|
+
* kills the command's shell and, where `pkill` exists, its children (exit 137). Callers detect
|
|
50
|
+
* the kill with `killedByTimeout`.
|
|
51
|
+
*/
|
|
52
|
+
export function guardTimeout(cmd, seconds) {
|
|
53
|
+
const n = Math.max(1, Math.ceil(seconds));
|
|
54
|
+
const inner = shq(cmd);
|
|
55
|
+
return [
|
|
56
|
+
`if command -v timeout >/dev/null 2>&1; then exec timeout -k 5 ${n} sh -c ${inner}; fi`,
|
|
57
|
+
`sh -c ${inner} & p=$!`,
|
|
58
|
+
// Freeze the shell first so it can't run its next command while its children are being killed.
|
|
59
|
+
`( sleep ${n}; kill -STOP $p; command -v pkill >/dev/null 2>&1 && pkill -KILL -P $p .; kill -9 $p ) >/dev/null 2>&1 & w=$!`,
|
|
60
|
+
`wait $p; c=$?`,
|
|
61
|
+
`kill $w >/dev/null 2>&1`,
|
|
62
|
+
`exit $c`,
|
|
63
|
+
].join("\n");
|
|
17
64
|
}
|
|
18
65
|
export function toBytes(content) {
|
|
19
66
|
return typeof content === "string" ? new TextEncoder().encode(content) : content;
|
|
@@ -29,7 +76,9 @@ export async function optionalImport(pkg, provider) {
|
|
|
29
76
|
}
|
|
30
77
|
catch (err) {
|
|
31
78
|
const code = err.code;
|
|
32
|
-
|
|
79
|
+
// Bundlers and test runners (Vite, esbuild) report a missing package without Node's error code.
|
|
80
|
+
const missing = /Cannot find (module|package)|Could not resolve|Failed to (resolve|load url)/.test(String(err?.message));
|
|
81
|
+
if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || missing) {
|
|
33
82
|
throw new Error(`${provider} needs the "${pkg}" package: npm i ${pkg}`);
|
|
34
83
|
}
|
|
35
84
|
throw err;
|