@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.
Files changed (97) hide show
  1. package/README.md +271 -62
  2. package/dist/bench.js +4 -4
  3. package/dist/catalog.d.ts +232 -3
  4. package/dist/catalog.js +569 -14
  5. package/dist/check.d.ts +28 -0
  6. package/dist/check.js +185 -0
  7. package/dist/cli.js +149 -12
  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 +34 -2
  17. package/dist/index.js +34 -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.d.ts +25 -0
  35. package/dist/providers/boat.js +263 -0
  36. package/dist/providers/cloudflare.d.ts +30 -0
  37. package/dist/providers/cloudflare.js +152 -0
  38. package/dist/providers/codesandbox.d.ts +59 -0
  39. package/dist/providers/codesandbox.js +287 -0
  40. package/dist/providers/daytona.d.ts +25 -4
  41. package/dist/providers/daytona.js +449 -28
  42. package/dist/providers/dedalus.d.ts +12 -0
  43. package/dist/providers/dedalus.js +244 -0
  44. package/dist/providers/deno.d.ts +50 -0
  45. package/dist/providers/deno.js +170 -0
  46. package/dist/providers/docker.d.ts +17 -0
  47. package/dist/providers/docker.js +154 -0
  48. package/dist/providers/e2b.d.ts +70 -3
  49. package/dist/providers/e2b.js +376 -8
  50. package/dist/providers/exedev.d.ts +23 -0
  51. package/dist/providers/exedev.js +290 -0
  52. package/dist/providers/freestyle.d.ts +49 -0
  53. package/dist/providers/freestyle.js +261 -0
  54. package/dist/providers/hopx.d.ts +15 -0
  55. package/dist/providers/hopx.js +177 -0
  56. package/dist/providers/instabox.d.ts +76 -0
  57. package/dist/providers/instabox.js +360 -0
  58. package/dist/providers/koyeb.d.ts +71 -0
  59. package/dist/providers/koyeb.js +379 -0
  60. package/dist/providers/lambda.d.ts +51 -0
  61. package/dist/providers/lambda.js +467 -0
  62. package/dist/providers/langsmith.d.ts +16 -0
  63. package/dist/providers/langsmith.js +149 -0
  64. package/dist/providers/limrun.d.ts +77 -0
  65. package/dist/providers/limrun.js +626 -0
  66. package/dist/providers/maritime.d.ts +5 -1
  67. package/dist/providers/maritime.js +39 -8
  68. package/dist/providers/mobilerun.d.ts +44 -0
  69. package/dist/providers/mobilerun.js +417 -0
  70. package/dist/providers/modal.js +12 -4
  71. package/dist/providers/morph.js +12 -4
  72. package/dist/providers/northflank.d.ts +21 -0
  73. package/dist/providers/northflank.js +259 -0
  74. package/dist/providers/novita.d.ts +12 -0
  75. package/dist/providers/novita.js +81 -0
  76. package/dist/providers/openscale.d.ts +17 -0
  77. package/dist/providers/openscale.js +87 -0
  78. package/dist/providers/orgo.d.ts +45 -0
  79. package/dist/providers/orgo.js +414 -0
  80. package/dist/providers/sail.d.ts +32 -0
  81. package/dist/providers/sail.js +372 -0
  82. package/dist/providers/superserve.d.ts +25 -0
  83. package/dist/providers/superserve.js +337 -0
  84. package/dist/providers/tensorlake.js +17 -14
  85. package/dist/providers/upstash.d.ts +30 -0
  86. package/dist/providers/upstash.js +258 -0
  87. package/dist/providers/vercel.js +14 -3
  88. package/dist/providers/workspaces.d.ts +60 -0
  89. package/dist/providers/workspaces.js +662 -0
  90. package/dist/router.d.ts +36 -5
  91. package/dist/router.js +174 -25
  92. package/dist/server.d.ts +1 -1
  93. package/dist/server.js +22 -3
  94. package/dist/types.d.ts +18 -6
  95. package/dist/util.d.ts +28 -1
  96. package/dist/util.js +56 -7
  97. package/package.json +106 -17
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
- /** Providers that also sell computers (desktops/phones). */
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
- /** Get or create a desktop/phone. With `externalUserId`, the same id returns the same computer. */
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?: ProviderId | ProviderId[];
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";
@@ -11,7 +13,39 @@ import { SpritesProvider } from "./providers/sprites.js";
11
13
  import { BlaxelProvider } from "./providers/blaxel.js";
12
14
  import { TensorlakeProvider } from "./providers/tensorlake.js";
13
15
  import { RunloopProvider } from "./providers/runloop.js";
14
- import { RouterError, } from "./types.js";
16
+ import { DockerProvider } from "./providers/docker.js";
17
+ import { HopxProvider } from "./providers/hopx.js";
18
+ import { CloudflareProvider } from "./providers/cloudflare.js";
19
+ import { NorthflankProvider } from "./providers/northflank.js";
20
+ import { BoatProvider } from "./providers/boat.js";
21
+ import { ExeDevProvider } from "./providers/exedev.js";
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
+ }
15
49
  export function defaultProviders() {
16
50
  return [
17
51
  new MaritimeProvider(),
@@ -24,14 +58,45 @@ export function defaultProviders() {
24
58
  new BlaxelProvider(),
25
59
  new TensorlakeProvider(),
26
60
  new RunloopProvider(),
61
+ new HopxProvider(),
62
+ new CloudflareProvider(),
63
+ new NorthflankProvider(),
64
+ new BoatProvider(),
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(),
84
+ new DockerProvider(),
27
85
  ];
28
86
  }
29
87
  export class Router {
30
88
  providers;
31
89
  benchFile;
90
+ computerOnly;
91
+ /** The local record of sandboxes this router created, or undefined when turned off. */
92
+ ledger;
32
93
  constructor(opts = {}) {
94
+ const file = opts.ledger === false ? null : (opts.ledger ?? defaultLedgerFile());
95
+ if (file)
96
+ this.ledger = new Ledger(file);
33
97
  this.providers = new Map((opts.providers ?? defaultProviders()).map((p) => [p.id, p]));
34
98
  this.benchFile = opts.benchFile;
99
+ this.computerOnly = opts.computers ?? (opts.providers ? [] : defaultComputerProviders());
35
100
  }
36
101
  /** Registered and has credentials. False for providers this router doesn't know. */
37
102
  isConfigured(id) {
@@ -50,9 +115,14 @@ export class Router {
50
115
  /** The ordered list of providers a create would try, with the reason any are skipped. */
51
116
  plan(spec = {}, route = {}) {
52
117
  const explicit = route.provider ? [].concat(route.provider) : null;
53
- const ids = explicit ?? CATALOG.map((c) => c.id).filter((id) => this.providers.has(id));
118
+ // Local providers (Docker) are never picked automatically: they always "cost" $0 and isolate
119
+ // less than a cloud micro-VM. Name them explicitly to use them.
120
+ const ids = explicit ?? CATALOG.filter((c) => !c.local && this.providers.has(c.id)).map((c) => c.id);
54
121
  const bench = route.strategy === "fastest" ? loadBench(this.benchFile) : {};
55
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" };
56
126
  const p = this.providers.get(id);
57
127
  const c = {
58
128
  provider: id,
@@ -60,16 +130,23 @@ export class Router {
60
130
  medianCreateMs: median(bench[id]?.createMs),
61
131
  };
62
132
  if (!p)
63
- c.skip = "unknown provider";
64
- else
65
- c.skip = unfit(id, spec, route.require) ?? (p.configured() ? undefined : "not configured");
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
+ }
66
138
  return c;
67
139
  });
68
- if (!explicit && route.strategy === "cheapest") {
69
- list.sort((a, b) => a.estimatedHourlyUsd - b.estimatedHourlyUsd);
70
- }
71
- else if (!explicit && route.strategy === "fastest") {
72
- list.sort((a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity));
140
+ // Neutral by construction: price (or measured speed) decides, ties break alphabetically by id.
141
+ // An explicit provider list keeps the caller's order.
142
+ if (!explicit) {
143
+ const byPrice = (a, b) => a.estimatedHourlyUsd - b.estimatedHourlyUsd || a.provider.localeCompare(b.provider);
144
+ if (route.strategy === "fastest") {
145
+ list.sort((a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity) || byPrice(a, b));
146
+ }
147
+ else {
148
+ list.sort(byPrice);
149
+ }
73
150
  }
74
151
  return list;
75
152
  }
@@ -83,40 +160,81 @@ export class Router {
83
160
  }
84
161
  const attempts = [];
85
162
  const tries = route.fallback === false ? usable.slice(0, 1) : usable;
163
+ const limitS = route.createTimeoutSeconds ?? DEFAULT_CREATE_TIMEOUT_S;
86
164
  for (const c of tries) {
87
165
  const t0 = Date.now();
166
+ const pending = this.provider(c.provider).create(spec);
167
+ let timedOut = false;
88
168
  try {
89
- const box = await this.provider(c.provider).create(spec);
169
+ const box = await withDeadline(pending, limitS * 1000, () => {
170
+ timedOut = true;
171
+ return new ProviderError(c.provider, `create timed out after ${limitS}s`);
172
+ });
90
173
  attempts.push({ provider: c.provider, ok: true, ms: Date.now() - t0 });
91
- return new RoutedSandbox(c.provider, box, attempts, c.estimatedHourlyUsd);
174
+ const sbx = new RoutedSandbox(c.provider, box, attempts, c.estimatedHourlyUsd, this.ledger);
175
+ this.record(sbx, c.estimatedHourlyUsd, spec.timeoutMinutes);
176
+ return sbx;
92
177
  }
93
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), () => { });
94
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
+ }
95
189
  }
96
190
  }
97
191
  throw new RouterError(`all providers failed: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts);
98
192
  }
99
- /** Providers that also sell computers (desktops/phones). */
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. */
100
209
  computerProviders() {
101
- return [...this.providers.values()].filter((p) => "createComputer" in p);
210
+ const fromSandboxes = [...this.providers.values()].filter((p) => "createComputer" in p);
211
+ return [...fromSandboxes, ...this.computerOnly];
212
+ }
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);
102
217
  }
103
- /** Get or create a desktop/phone. With `externalUserId`, the same id returns the same computer. */
218
+ /** Start a desktop or phone on the cheapest provider for its OS (ties alphabetical), with fallback. */
104
219
  computer(spec = {}, opts = {}) {
105
220
  const prefer = opts.provider ? [].concat(opts.provider) : undefined;
106
221
  return createComputer(this.computerProviders(), spec, prefer);
107
222
  }
108
223
  async getComputer(id) {
109
- const { provider, nativeId } = parseId(id);
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);
110
228
  const p = this.computerProviders().find((c) => c.id === provider);
111
229
  if (!p)
112
230
  throw new RouterError(`${provider} does not offer computers`);
113
- return new RoutedComputer(provider, await p.connectComputer(nativeId));
231
+ return new RoutedComputer(provider, await p.connectComputer(id.slice(i + 1)));
114
232
  }
115
233
  /** Reattach to a sandbox by its routed id (`provider:nativeId`). */
116
234
  async get(id) {
117
235
  const { provider, nativeId } = parseId(id);
118
236
  const box = await this.provider(provider).connect(nativeId);
119
- return new RoutedSandbox(provider, box, [], round(hourlyCost(provider)));
237
+ return new RoutedSandbox(provider, box, [], round(hourlyCost(provider)), this.ledger);
120
238
  }
121
239
  }
122
240
  export class RoutedSandbox {
@@ -124,22 +242,31 @@ export class RoutedSandbox {
124
242
  box;
125
243
  attempts;
126
244
  estimatedHourlyUsd;
245
+ ledger;
127
246
  /** Stable id across processes: `provider:nativeId`. */
128
247
  id;
129
248
  constructor(provider, box,
130
249
  /** Every create attempt, including failed fallbacks. */
131
- attempts, estimatedHourlyUsd) {
250
+ attempts, estimatedHourlyUsd, ledger) {
132
251
  this.provider = provider;
133
252
  this.box = box;
134
253
  this.attempts = attempts;
135
254
  this.estimatedHourlyUsd = estimatedHourlyUsd;
255
+ this.ledger = ledger;
136
256
  this.id = `${provider}:${box.nativeId}`;
137
257
  }
138
258
  get nativeId() {
139
259
  return this.box.nativeId;
140
260
  }
141
- exec(cmd, opts) {
142
- return this.box.exec(cmd, opts);
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));
143
270
  }
144
271
  writeFile(path, content) {
145
272
  return this.box.writeFile(path, content);
@@ -150,8 +277,9 @@ export class RoutedSandbox {
150
277
  async readText(path) {
151
278
  return new TextDecoder().decode(await this.box.readFile(path));
152
279
  }
153
- kill() {
154
- return this.box.kill();
280
+ async kill() {
281
+ await this.box.kill();
282
+ this.ledger?.close(this.id);
155
283
  }
156
284
  /** Pause, keeping the disk. Resume with router.get(id) or, on Maritime, any call. */
157
285
  async pause() {
@@ -178,3 +306,24 @@ export function parseId(id) {
178
306
  function round(n) {
179
307
  return Math.round(n * 10000) / 10000;
180
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 (token && req.headers.authorization !== `Bearer ${token}`)
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";
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. */
@@ -21,14 +21,26 @@ export interface SandboxSpec {
21
21
  /** Free-form tags stored on the sandbox where the provider supports it. */
22
22
  metadata?: Record<string, string>;
23
23
  }
24
+ /**
25
+ * "cheapest" (and "default", the same thing): lowest list price for the spec, ties alphabetical.
26
+ * "fastest": lowest median cold start you measured with `bench`, then cheapest.
27
+ */
24
28
  export type Strategy = "default" | "cheapest" | "fastest";
25
29
  export interface RouteOptions {
26
30
  /** Pin to one provider, or give an ordered list to try. Omit to let the router choose. */
27
31
  provider?: ProviderId | ProviderId[];
28
- /** How to order candidates when no explicit list is given. Default: "default" (catalog order). */
32
+ /** How to order candidates when no explicit list is given. Default: cheapest. */
29
33
  strategy?: Strategy;
30
- /** Try the next candidate if a create fails (default true). */
31
- fallback?: boolean;
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;
32
44
  /** Capabilities every candidate must have. */
33
45
  require?: Capability[];
34
46
  }
@@ -77,7 +89,7 @@ export declare class RouterError extends Error {
77
89
  }
78
90
  /** Thrown by adapters for HTTP failures so the router can report status codes. */
79
91
  export declare class ProviderError extends Error {
80
- readonly provider: ProviderId;
92
+ readonly provider: ProviderId | (string & {});
81
93
  readonly status?: number | undefined;
82
- constructor(provider: ProviderId, message: string, status?: number | undefined);
94
+ constructor(provider: ProviderId | (string & {}), message: string, status?: number | undefined);
83
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
- /** Build `cd dir && VAR=x cmd` for providers whose exec API lacks cwd/env. */
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
- /** Build `cd dir && VAR=x cmd` for providers whose exec API lacks cwd/env. */
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 parts = [];
17
+ const prelude = [];
8
18
  if (opts.cwd)
9
- parts.push(`cd ${shq(opts.cwd)} &&`);
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
- parts.push(`export ${k}=${shq(v)};`);
23
+ prelude.push(`export ${k}=${shq(v)}`);
14
24
  }
15
- parts.push(cmd);
16
- return parts.join(" ");
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
- if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") {
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;