@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
@@ -0,0 +1,244 @@
1
+ import { ProviderError } from "../types.js";
2
+ import { dirname, shq, toBytes } from "../util.js";
3
+ /**
4
+ * Dedalus Machines (Dedalus Labs, YC S25) over plain REST. Persistent Linux microVMs that sleep
5
+ * when idle and bill compute only while awake. The Machines API was in private beta when this was
6
+ * written: an org that has not been admitted gets a 403.
7
+ *
8
+ * https://dcs.dedaluslabs.ai, Authorization: Bearer <DEDALUS_API_KEY> (dsk_...):
9
+ * POST /v1/machines {vcpu, memory_mib, storage_gib, autosleep} -> {machine_id, phase, ...}
10
+ * GET /v1/machines/:id -> {machine_id, desired_state, status:{phase, reason, last_error}}
11
+ * POST /v1/machines/:id/sleep, POST /v1/machines/:id/wake, DELETE /v1/machines/:id
12
+ * POST /v1/machines/:id/executions {command:[argv], cwd, env, stdin, timeout_ms}
13
+ * -> {execution_id, status}
14
+ * GET /v1/machines/:id/executions/:eid -> {status, exit_code, signal, error_code, error_message}
15
+ * GET /v1/machines/:id/executions/:eid/events?cursor= -> {items:[{type, chunk, status, exit_code}], next_cursor}
16
+ * GET /v1/machines/:id/executions/:eid/output -> {stdout, stderr, stdout_truncated, stderr_truncated}
17
+ * Execution status: wake_in_progress, queued, running, succeeded, failed, cancelled, expired.
18
+ * Machine phase: accepted, placement_pending, starting, running, stopping, sleeping, destroying,
19
+ * destroyed, failed. Errors are {error_code, message, retryable} or problem+json {title, detail}.
20
+ * There is no file API, so files move through an execution's stdin and stdout as base64.
21
+ * There is no image choice: every machine is the same Linux base.
22
+ * Docs: https://docs.dedaluslabs.ai/dcs/dm/quickstart, https://docs.dedaluslabs.ai/dcs/dm/lifecycle,
23
+ * https://docs.dedaluslabs.ai/dcs/dm/executions, https://docs.dedaluslabs.ai/dcs/dm/pricing,
24
+ * https://docs.dedaluslabs.ai/dcs-openapi.json
25
+ */
26
+ const ID = "dedalus";
27
+ const READY_TIMEOUT_MS = 5 * 60_000;
28
+ const POLL_MS = 250;
29
+ const TERMINAL = new Set(["succeeded", "failed", "cancelled", "expired"]);
30
+ /** The API returns at most this much of each stream inline from /output; longer output is flagged truncated. */
31
+ const OUTPUT_CAP = 64 * 1024;
32
+ /** Raw bytes per readFile chunk: base64 with line breaks (45 KiB -> about 61 KiB) stays under OUTPUT_CAP. */
33
+ const READ_CHUNK = 45 * 1024;
34
+ export class DedalusProvider {
35
+ apiKey;
36
+ baseUrl;
37
+ id = ID;
38
+ constructor(apiKey = process.env.DEDALUS_API_KEY, baseUrl = process.env.DEDALUS_MACHINES_URL ?? "https://dcs.dedaluslabs.ai") {
39
+ this.apiKey = apiKey;
40
+ this.baseUrl = baseUrl;
41
+ }
42
+ configured() {
43
+ return !!this.apiKey;
44
+ }
45
+ /** @internal */
46
+ async api(method, path, json) {
47
+ if (!this.apiKey)
48
+ throw new ProviderError(ID, "DEDALUS_API_KEY is not set");
49
+ const res = await fetch(`${this.baseUrl}${path}`, {
50
+ method,
51
+ headers: { Authorization: `Bearer ${this.apiKey}`, ...(json !== undefined ? { "Content-Type": "application/json" } : {}) },
52
+ body: json !== undefined ? JSON.stringify(json) : undefined,
53
+ });
54
+ const text = await res.text();
55
+ if (!res.ok) {
56
+ let detail = text.slice(0, 300);
57
+ try {
58
+ const j = JSON.parse(text);
59
+ detail = j?.message ? `${j.error_code ? `${j.error_code}: ` : ""}${j.message}` : (j?.detail ?? j?.title ?? detail);
60
+ }
61
+ catch {
62
+ /* plain-text body */
63
+ }
64
+ throw new ProviderError(ID, `${method} ${path.split("?")[0]} -> ${res.status} ${detail}`, res.status);
65
+ }
66
+ return (text ? JSON.parse(text) : {});
67
+ }
68
+ async create(spec) {
69
+ const minutes = spec.timeoutMinutes ?? 30;
70
+ const m = await this.api("POST", "/v1/machines", {
71
+ vcpu: spec.vcpu ?? 2,
72
+ memory_mib: Math.round((spec.memoryGb ?? 4) * 1024),
73
+ storage_gib: 10,
74
+ // Sleep (compute billing stops, disk kept) once idle for the requested lifetime.
75
+ autosleep: `${Math.max(60, Math.round(minutes * 60))}s`,
76
+ });
77
+ if (!m.machine_id)
78
+ throw new ProviderError(ID, "create returned no machine_id");
79
+ const box = new DedalusBox(this, m.machine_id, spec.env ?? {});
80
+ try {
81
+ await box.waitRunning();
82
+ }
83
+ catch (err) {
84
+ await box.kill().catch(() => { });
85
+ throw err;
86
+ }
87
+ return box;
88
+ }
89
+ async connect(nativeId) {
90
+ const box = new DedalusBox(this, nativeId, {});
91
+ await box.waitRunning();
92
+ return box;
93
+ }
94
+ }
95
+ class DedalusBox {
96
+ p;
97
+ nativeId;
98
+ env;
99
+ constructor(p, nativeId, env) {
100
+ this.p = p;
101
+ this.nativeId = nativeId;
102
+ this.env = env;
103
+ }
104
+ get path() {
105
+ return `/v1/machines/${encodeURIComponent(this.nativeId)}`;
106
+ }
107
+ /** Poll until the machine runs, waking it if it sleeps. */
108
+ async waitRunning() {
109
+ const deadline = Date.now() + READY_TIMEOUT_MS;
110
+ let woke = false;
111
+ for (;;) {
112
+ const m = await this.p.api("GET", this.path);
113
+ const phase = m.status?.phase ?? m.phase ?? "";
114
+ if (phase === "running")
115
+ return;
116
+ if (phase === "failed" || phase === "destroyed" || phase === "destroying") {
117
+ const why = m.status?.last_error || m.status?.reason;
118
+ throw new ProviderError(ID, `machine ${this.nativeId} is ${phase}${why ? `: ${why}` : ""}`, phase === "failed" ? undefined : 404);
119
+ }
120
+ if (phase === "sleeping" && !woke) {
121
+ await this.p.api("POST", `${this.path}/wake`);
122
+ woke = true;
123
+ }
124
+ if (Date.now() > deadline)
125
+ throw new ProviderError(ID, `machine ${this.nativeId} not running after ${READY_TIMEOUT_MS / 1000}s (${phase || "unknown"})`);
126
+ await sleep(POLL_MS);
127
+ }
128
+ }
129
+ /** Run argv to completion, streaming stdout/stderr from the events feed. */
130
+ async run(command, opts) {
131
+ const t0 = Date.now();
132
+ const secs = opts.timeoutSeconds ?? 60;
133
+ const env = { ...this.env, ...opts.env };
134
+ const created = await this.p.api("POST", `${this.path}/executions`, {
135
+ command,
136
+ ...(opts.cwd ? { cwd: opts.cwd } : {}),
137
+ ...(Object.keys(env).length ? { env } : {}),
138
+ ...(opts.stdin !== undefined ? { stdin: opts.stdin } : {}),
139
+ timeout_ms: secs * 1000,
140
+ });
141
+ const eid = created.execution_id;
142
+ if (!eid)
143
+ throw new ProviderError(ID, "create execution returned no execution_id");
144
+ const ex = `${this.path}/executions/${encodeURIComponent(eid)}`;
145
+ // A wake can sit in front of the command, so allow extra time before giving up locally.
146
+ const deadline = t0 + secs * 1000 + 30_000;
147
+ let status = created.status ?? "";
148
+ const streaming = !!(opts.onStdout || opts.onStderr);
149
+ // Everything the events feed delivered so far, kept so output past the /output cap is still complete.
150
+ let eventsOut = "";
151
+ let eventsErr = "";
152
+ let cursor;
153
+ // A page may come back without next_cursor (it is optional); the next drain then re-reads the
154
+ // same page, so remember how many of its items were already consumed.
155
+ let consumedAtCursor = 0;
156
+ /** Read every events page from the cursor on, passing output chunks to the callbacks. */
157
+ const drain = async () => {
158
+ for (;;) {
159
+ const q = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
160
+ const page = await this.p.api("GET", `${ex}/events${q}`);
161
+ const items = (page.items ?? []).slice(consumedAtCursor);
162
+ for (const e of items) {
163
+ if (e.type === "stdout" && e.chunk)
164
+ (eventsOut += e.chunk), opts.onStdout?.(e.chunk);
165
+ else if (e.type === "stderr" && e.chunk)
166
+ (eventsErr += e.chunk), opts.onStderr?.(e.chunk);
167
+ else if (e.type === "lifecycle" && e.status)
168
+ status = e.status;
169
+ }
170
+ const next = page.next_cursor;
171
+ if (!next || next === cursor) {
172
+ consumedAtCursor += items.length;
173
+ return;
174
+ }
175
+ cursor = next;
176
+ consumedAtCursor = 0;
177
+ if (!items.length)
178
+ return;
179
+ }
180
+ };
181
+ while (!TERMINAL.has(status)) {
182
+ if (Date.now() > deadline) {
183
+ await this.p.api("DELETE", ex).catch(() => { });
184
+ throw new ProviderError(ID, `command timed out after ${secs}s`);
185
+ }
186
+ await sleep(POLL_MS);
187
+ if (streaming)
188
+ await drain();
189
+ if (!TERMINAL.has(status))
190
+ status = (await this.p.api("GET", ex)).status ?? status;
191
+ }
192
+ const final = await this.p.api("GET", ex);
193
+ // Deliver anything written between the last events page and the end.
194
+ if (streaming)
195
+ await drain();
196
+ const durationMs = Date.now() - t0;
197
+ const why = `${final.error_code ?? ""} ${final.error_message ?? ""}`;
198
+ if (final.status === "expired" || /timeout|timed out|deadline/i.test(why) || (final.signal && durationMs >= secs * 1000)) {
199
+ throw new ProviderError(ID, `command timed out after ${secs}s`);
200
+ }
201
+ if (final.exit_code === undefined || final.exit_code === null) {
202
+ throw new ProviderError(ID, `command ${final.status ?? "ended"} without an exit code${why.trim() ? `: ${why.trim()}` : ""}`);
203
+ }
204
+ const out = await this.p.api("GET", `${ex}/output`);
205
+ if (!out.stdout_truncated && !out.stderr_truncated)
206
+ return { exitCode: final.exit_code, stdout: out.stdout ?? "", stderr: out.stderr ?? "", durationMs };
207
+ // /output cut a stream at OUTPUT_CAP: the events feed has all of it.
208
+ if (!streaming)
209
+ await drain();
210
+ return { exitCode: final.exit_code, stdout: eventsOut, stderr: eventsErr, durationMs };
211
+ }
212
+ exec(cmd, opts = {}) {
213
+ return this.run(["/bin/sh", "-c", cmd], opts);
214
+ }
215
+ async writeFile(path, content) {
216
+ const b64 = Buffer.from(toBytes(content)).toString("base64");
217
+ const r = await this.run(["/bin/sh", "-c", `mkdir -p ${shq(dirname(path))} && base64 -d > ${shq(path)}`], { stdin: b64, timeoutSeconds: 120 });
218
+ if (r.exitCode !== 0)
219
+ throw new ProviderError(ID, `write ${path}: ${r.stderr.trim() || `exit ${r.exitCode}`}`);
220
+ }
221
+ async readFile(path) {
222
+ const size = await this.run(["/bin/sh", "-c", `wc -c < ${shq(path)}`], {});
223
+ if (size.exitCode !== 0)
224
+ throw new ProviderError(ID, `read ${path}: ${size.stderr.trim() || "no such file"}`, 404);
225
+ const total = Number(size.stdout.trim());
226
+ const parts = [];
227
+ for (let off = 0; off < total; off += READ_CHUNK) {
228
+ const r = await this.run(["/bin/sh", "-c", `tail -c +${off + 1} ${shq(path)} | head -c ${READ_CHUNK} | base64`], { timeoutSeconds: 120 });
229
+ if (r.exitCode !== 0)
230
+ throw new ProviderError(ID, `read ${path}: ${r.stderr.trim() || `exit ${r.exitCode}`}`);
231
+ parts.push(Buffer.from(r.stdout.replace(/\s+/g, ""), "base64"));
232
+ }
233
+ return new Uint8Array(Buffer.concat(parts));
234
+ }
235
+ async pause() {
236
+ await this.p.api("POST", `${this.path}/sleep`);
237
+ }
238
+ async kill() {
239
+ await this.p.api("DELETE", `${this.path}?force=true`);
240
+ }
241
+ }
242
+ function sleep(ms) {
243
+ return new Promise((r) => setTimeout(r, ms));
244
+ }
@@ -0,0 +1,50 @@
1
+ import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
2
+ export declare const DENO_VCPU = 2;
3
+ /** The parts of `@deno/sandbox` this adapter uses (local types: the package is optional). */
4
+ interface DenoChild {
5
+ stdout: ReadableStream<Uint8Array> | null;
6
+ stderr: ReadableStream<Uint8Array> | null;
7
+ status: Promise<{
8
+ code: number;
9
+ signal: string | null;
10
+ success: boolean;
11
+ }>;
12
+ kill(signal?: string): Promise<void>;
13
+ }
14
+ interface DenoSdkSandbox {
15
+ readonly id: string;
16
+ spawn(command: string, options?: Record<string, unknown>): Promise<DenoChild>;
17
+ fs: {
18
+ writeFile(path: string, data: Uint8Array): Promise<void>;
19
+ readFile(path: string): Promise<Uint8Array>;
20
+ mkdir(path: string, options?: {
21
+ recursive?: boolean;
22
+ }): Promise<void>;
23
+ };
24
+ kill(): Promise<void>;
25
+ close(): Promise<void>;
26
+ }
27
+ export interface DenoSdk {
28
+ Sandbox: {
29
+ create(options?: Record<string, unknown>): Promise<DenoSdkSandbox>;
30
+ connect(options: Record<string, unknown> & {
31
+ id: string;
32
+ }): Promise<DenoSdkSandbox>;
33
+ };
34
+ }
35
+ export declare class DenoProvider implements Provider {
36
+ private token;
37
+ private org;
38
+ private region;
39
+ /** @internal Loads the SDK; tests pass a fake. */
40
+ private load;
41
+ readonly id: "deno";
42
+ constructor(token?: string | undefined, org?: string | undefined, region?: string | undefined,
43
+ /** @internal Loads the SDK; tests pass a fake. */
44
+ load?: () => Promise<DenoSdk>);
45
+ configured(): boolean;
46
+ private auth;
47
+ create(spec: SandboxSpec): Promise<ProviderSandbox>;
48
+ connect(nativeId: string): Promise<ProviderSandbox>;
49
+ }
50
+ export {};
@@ -0,0 +1,170 @@
1
+ import { ProviderError } from "../types.js";
2
+ import { dirname, optionalImport, toBytes } from "../util.js";
3
+ /**
4
+ * Deno Sandbox (Linux microVMs on Deno Deploy) via the official `@deno/sandbox` SDK. The SDK is
5
+ * required: sandboxes are driven over an SDK-private WebSocket RPC protocol that Deno does not
6
+ * document, so there is no plain REST path. Optional peer dependency: `@deno/sandbox` (>=0.13 <0.14,
7
+ * a 0.x API; verified against 0.13.2).
8
+ * Auth: DENO_DEPLOY_TOKEN, an organization token (ddo_...), or a personal token (ddp_...) plus
9
+ * DENO_DEPLOY_ORG. Sandboxes need a paid Deploy plan (Pro or Builder).
10
+ * Fixed limits (https://docs.deno.com/sandbox/#limits): 2 vCPU, 768-4096 MB memory (default
11
+ * 1280), 10 GB disk, lifetime up to 30 minutes, regions `ord` and `ams`. Outbound network is open
12
+ * unless allowNet is set. image.deno is a snapshot or volume id/slug used as the root filesystem.
13
+ * The default timeout ("session") would end the VM when this process disconnects, so the adapter
14
+ * always passes a duration, which is what lets connect() reattach. exec streams stdout/stderr from
15
+ * a spawned `/bin/sh -c`. No pause.
16
+ * Docs: https://docs.deno.com/sandbox/, https://docs.deno.com/sandbox/create/,
17
+ * https://docs.deno.com/sandbox/timeouts/, https://docs.deno.com/sandbox/security/,
18
+ * https://deno.com/sandbox (pricing), https://jsr.io/@deno/sandbox
19
+ */
20
+ const ID = "deno";
21
+ const MIN_MEMORY_MB = 768;
22
+ const MAX_MEMORY_MB = 4096;
23
+ const MAX_LIFETIME_MIN = 30;
24
+ export const DENO_VCPU = 2;
25
+ export class DenoProvider {
26
+ token;
27
+ org;
28
+ region;
29
+ load;
30
+ id = ID;
31
+ constructor(token = process.env.DENO_DEPLOY_TOKEN, org = process.env.DENO_DEPLOY_ORG, region = process.env.DENO_SANDBOX_REGION,
32
+ /** @internal Loads the SDK; tests pass a fake. */
33
+ load = () => optionalImport("@deno/sandbox", "deno")) {
34
+ this.token = token;
35
+ this.org = org;
36
+ this.region = region;
37
+ this.load = load;
38
+ }
39
+ configured() {
40
+ return !!this.token && (this.token.startsWith("ddo_") || !!this.org);
41
+ }
42
+ auth() {
43
+ if (!this.token)
44
+ throw new ProviderError(ID, "DENO_DEPLOY_TOKEN is not set");
45
+ if (!this.token.startsWith("ddo_") && !this.org)
46
+ throw new ProviderError(ID, "a personal DENO_DEPLOY_TOKEN needs DENO_DEPLOY_ORG");
47
+ return { token: this.token, ...(this.org ? { org: this.org } : {}) };
48
+ }
49
+ async create(spec) {
50
+ const vcpu = spec.vcpu ?? 2;
51
+ if (vcpu > DENO_VCPU)
52
+ throw new ProviderError(ID, `Deno Sandbox has ${DENO_VCPU} vCPU, not ${vcpu}`);
53
+ const memoryMb = Math.ceil((spec.memoryGb ?? 4) * 1024);
54
+ if (memoryMb > MAX_MEMORY_MB)
55
+ throw new ProviderError(ID, `Deno Sandbox memory is at most ${MAX_MEMORY_MB} MB, not ${memoryMb}`);
56
+ const minutes = Math.min(MAX_LIFETIME_MIN, Math.max(1, Math.ceil(spec.timeoutMinutes ?? 30)));
57
+ const options = {
58
+ ...this.auth(),
59
+ memory: `${Math.max(MIN_MEMORY_MB, memoryMb)}MiB`,
60
+ timeout: `${minutes}m`,
61
+ };
62
+ if (this.region)
63
+ options.region = this.region;
64
+ if (spec.env)
65
+ options.env = spec.env;
66
+ if (spec.metadata)
67
+ options.labels = spec.metadata;
68
+ if (spec.image?.deno)
69
+ options.root = spec.image.deno;
70
+ const { Sandbox } = await this.load();
71
+ return new DenoBox(await sdk(() => Sandbox.create(options)));
72
+ }
73
+ async connect(nativeId) {
74
+ const { Sandbox } = await this.load();
75
+ return new DenoBox(await sdk(() => Sandbox.connect({ ...this.auth(), id: nativeId })));
76
+ }
77
+ }
78
+ class DenoBox {
79
+ sbx;
80
+ nativeId;
81
+ constructor(sbx) {
82
+ this.sbx = sbx;
83
+ this.nativeId = sbx.id;
84
+ }
85
+ async exec(cmd, opts = {}) {
86
+ const t0 = Date.now();
87
+ const secs = opts.timeoutSeconds ?? 60;
88
+ const child = await sdk(() => this.sbx.spawn("/bin/sh", {
89
+ args: ["-c", cmd],
90
+ ...(opts.cwd ? { cwd: opts.cwd } : {}),
91
+ ...(opts.env ? { env: opts.env } : {}),
92
+ stdin: "null",
93
+ stdout: "piped",
94
+ stderr: "piped",
95
+ }));
96
+ let timedOut = false;
97
+ let giveUp;
98
+ // Killing the shell can leave its children holding stdout open, so stop waiting for the
99
+ // streams shortly after the kill instead of hanging until those children exit.
100
+ const abandoned = new Promise((_, reject) => {
101
+ giveUp = setTimeout(() => reject(new ProviderError(ID, `command timed out after ${secs}s`)), secs * 1000 + 1000);
102
+ });
103
+ const timer = setTimeout(() => {
104
+ timedOut = true;
105
+ child.kill("SIGKILL").catch(() => { });
106
+ }, secs * 1000);
107
+ try {
108
+ const [stdout, stderr, status] = await Promise.race([
109
+ Promise.all([drain(child.stdout, opts.onStdout), drain(child.stderr, opts.onStderr), sdk(() => child.status)]),
110
+ abandoned,
111
+ ]);
112
+ if (timedOut)
113
+ throw new ProviderError(ID, `command timed out after ${secs}s`);
114
+ // Deno reports a signal death as the signal's name plus a negative code (minus its number);
115
+ // shells report 128 + n, so callers see the same code they would from any other provider.
116
+ const exitCode = status.signal ? 128 + (SIGNALS[status.signal] ?? Math.max(0, -status.code)) : status.code;
117
+ return { exitCode, stdout, stderr, durationMs: Date.now() - t0 };
118
+ }
119
+ finally {
120
+ clearTimeout(timer);
121
+ clearTimeout(giveUp);
122
+ }
123
+ }
124
+ async writeFile(path, content) {
125
+ const dir = dirname(path);
126
+ if (dir !== "/" && dir !== path)
127
+ await sdk(() => this.sbx.fs.mkdir(dir, { recursive: true }));
128
+ await sdk(() => this.sbx.fs.writeFile(path, toBytes(content)));
129
+ }
130
+ async readFile(path) {
131
+ return new Uint8Array(await sdk(() => this.sbx.fs.readFile(path)));
132
+ }
133
+ async kill() {
134
+ await sdk(() => this.sbx.kill());
135
+ }
136
+ }
137
+ /** Read a byte stream to a string, passing each decoded chunk on as it arrives. */
138
+ async function drain(stream, on) {
139
+ if (!stream)
140
+ return "";
141
+ const reader = stream.getReader();
142
+ const decoder = new TextDecoder();
143
+ let out = "";
144
+ for (;;) {
145
+ const { done, value } = await reader.read();
146
+ const text = done ? decoder.decode() : decoder.decode(value, { stream: true });
147
+ if (text) {
148
+ out += text;
149
+ on?.(text);
150
+ }
151
+ if (done)
152
+ return out;
153
+ }
154
+ }
155
+ /** Run an SDK call, turning its errors into ProviderErrors with an HTTP-like status where known. */
156
+ async function sdk(fn) {
157
+ try {
158
+ return await fn();
159
+ }
160
+ catch (err) {
161
+ if (err instanceof ProviderError)
162
+ throw err;
163
+ const msg = err?.message ?? String(err);
164
+ let status = typeof err?.status === "number" ? err.status : undefined;
165
+ if (status === undefined && (err?.name === "NotFound" || err?.code === "ENOENT" || /not found|no such file/i.test(msg)))
166
+ status = 404;
167
+ throw new ProviderError(ID, `${err?.name && err.name !== "Error" ? `${err.name}: ` : ""}${msg}`, status);
168
+ }
169
+ }
170
+ const SIGNALS = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGABRT: 6, SIGKILL: 9, SIGSEGV: 11, SIGPIPE: 13, SIGALRM: 14, SIGTERM: 15 };
@@ -0,0 +1,17 @@
1
+ import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
2
+ /**
3
+ * Local sandboxes in Docker containers, through the `docker` CLI. Free and needs no account, so it
4
+ * is good for development and tests. A container shares your machine's kernel: it is weaker
5
+ * isolation than a cloud micro-VM, so don't point untrusted code at it with secrets mounted.
6
+ * Never chosen automatically; route to it with { provider: "docker" }.
7
+ */
8
+ export declare class DockerProvider implements Provider {
9
+ private bin;
10
+ readonly id: "docker";
11
+ private available?;
12
+ constructor(bin?: string);
13
+ /** The docker CLI exists and its daemon answers. Cached for the life of the process. */
14
+ configured(): boolean;
15
+ create(spec: SandboxSpec): Promise<ProviderSandbox>;
16
+ connect(nativeId: string): Promise<ProviderSandbox>;
17
+ }
@@ -0,0 +1,154 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { DEFAULT_MEMORY_GB, DEFAULT_VCPU } from "../catalog.js";
4
+ import { ProviderError } from "../types.js";
5
+ import { DEFAULT_EXEC_TIMEOUT_S, dirname, guardTimeout, killedByTimeout, shq, timeoutError, toBytes } from "../util.js";
6
+ /**
7
+ * Local sandboxes in Docker containers, through the `docker` CLI. Free and needs no account, so it
8
+ * is good for development and tests. A container shares your machine's kernel: it is weaker
9
+ * isolation than a cloud micro-VM, so don't point untrusted code at it with secrets mounted.
10
+ * Never chosen automatically; route to it with { provider: "docker" }.
11
+ */
12
+ export class DockerProvider {
13
+ bin;
14
+ id = "docker";
15
+ available;
16
+ constructor(bin = process.env.SANDBOXROUTER_DOCKER_BIN ?? "docker") {
17
+ this.bin = bin;
18
+ }
19
+ /** The docker CLI exists and its daemon answers. Cached for the life of the process. */
20
+ configured() {
21
+ if (this.available === undefined) {
22
+ const r = spawnSync(this.bin, ["info", "--format", "{{.ServerVersion}}"], { timeout: 4000, encoding: "utf8" });
23
+ this.available = r.status === 0 && !!r.stdout.trim();
24
+ }
25
+ return this.available;
26
+ }
27
+ async create(spec) {
28
+ const name = `sbr-${randomUUID().slice(0, 12)}`;
29
+ const lifetime = Math.max(60, Math.round((spec.timeoutMinutes ?? 30) * 60));
30
+ const args = [
31
+ "run",
32
+ "-d",
33
+ "--rm",
34
+ "--name",
35
+ name,
36
+ "--label",
37
+ "sandboxrouter=1",
38
+ "--cpus",
39
+ String(spec.vcpu ?? DEFAULT_VCPU),
40
+ "--memory",
41
+ `${Math.round((spec.memoryGb ?? DEFAULT_MEMORY_GB) * 1024)}m`,
42
+ ];
43
+ for (const [k, v] of Object.entries(spec.env ?? {}))
44
+ args.push("-e", `${k}=${v}`);
45
+ for (const [k, v] of Object.entries(spec.metadata ?? {}))
46
+ args.push("--label", `${k}=${v}`);
47
+ // The container's main process is a timer: it exits at the lifetime and --rm removes it.
48
+ args.push(spec.image?.docker ?? "python:3.12-slim", "sleep", String(lifetime));
49
+ const r = await run(this.bin, args);
50
+ if (r.code !== 0)
51
+ throw new ProviderError("docker", `docker run failed: ${r.stderr.trim().slice(0, 300)}`);
52
+ return new DockerBox(this.bin, name);
53
+ }
54
+ async connect(nativeId) {
55
+ const r = await run(this.bin, ["inspect", "-f", "{{.State.Status}}", nativeId]);
56
+ if (r.code !== 0)
57
+ throw new ProviderError("docker", `no container ${nativeId}`, 404);
58
+ if (r.stdout.trim() === "paused")
59
+ await run(this.bin, ["unpause", nativeId]);
60
+ return new DockerBox(this.bin, nativeId);
61
+ }
62
+ }
63
+ class DockerBox {
64
+ bin;
65
+ nativeId;
66
+ constructor(bin, nativeId) {
67
+ this.bin = bin;
68
+ this.nativeId = nativeId;
69
+ }
70
+ exec(cmd, opts = {}) {
71
+ const seconds = opts.timeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_S;
72
+ const args = ["exec", "-i"];
73
+ if (opts.cwd)
74
+ args.push("-w", opts.cwd);
75
+ for (const [k, v] of Object.entries(opts.env ?? {}))
76
+ args.push("-e", `${k}=${v}`);
77
+ // The command is killed inside the container: Docker does not stop an exec'd process when its
78
+ // client dies, so killing `docker exec` alone would leave it running. The client-side limit
79
+ // only covers a container that stops answering.
80
+ args.push(this.nativeId, "sh", "-c", guardTimeout(cmd, seconds));
81
+ return run(this.bin, args, { timeoutMs: (seconds + 10) * 1000, onStdout: opts.onStdout, onStderr: opts.onStderr }).then((r) => {
82
+ if (r.timedOut || killedByTimeout(r.code ?? -1, r.ms, seconds))
83
+ throw timeoutError("docker", seconds);
84
+ return { exitCode: r.code ?? -1, stdout: r.stdout, stderr: r.stderr, durationMs: r.ms };
85
+ });
86
+ }
87
+ async writeFile(path, content) {
88
+ const r = await run(this.bin, ["exec", "-i", this.nativeId, "sh", "-c", `mkdir -p ${shq(dirname(path))} && cat > ${shq(path)}`], {
89
+ stdin: toBytes(content),
90
+ });
91
+ if (r.code !== 0)
92
+ throw new ProviderError("docker", `write ${path} failed: ${r.stderr.trim().slice(0, 300)}`);
93
+ }
94
+ async readFile(path) {
95
+ const r = await run(this.bin, ["exec", this.nativeId, "cat", path], { binary: true });
96
+ if (r.code !== 0)
97
+ throw new ProviderError("docker", `read ${path} failed: ${r.stderr.trim().slice(0, 300)}`, 404);
98
+ return r.bytes;
99
+ }
100
+ async pause() {
101
+ const r = await run(this.bin, ["pause", this.nativeId]);
102
+ if (r.code !== 0)
103
+ throw new ProviderError("docker", `pause failed: ${r.stderr.trim()}`);
104
+ }
105
+ async kill() {
106
+ await run(this.bin, ["rm", "-f", this.nativeId]);
107
+ }
108
+ }
109
+ function run(bin, args, opts = {}) {
110
+ const t0 = Date.now();
111
+ return new Promise((resolve, reject) => {
112
+ const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"] });
113
+ const out = [];
114
+ let stdout = "";
115
+ let stderr = "";
116
+ let timedOut = false;
117
+ const dec = new TextDecoder();
118
+ const decErr = new TextDecoder();
119
+ child.stdout.on("data", (b) => {
120
+ if (opts.binary)
121
+ return void out.push(b);
122
+ const c = dec.decode(b, { stream: true });
123
+ stdout += c;
124
+ if (c)
125
+ opts.onStdout?.(c);
126
+ });
127
+ child.stderr.on("data", (b) => {
128
+ const c = decErr.decode(b, { stream: true });
129
+ stderr += c;
130
+ if (c)
131
+ opts.onStderr?.(c);
132
+ });
133
+ const timer = opts.timeoutMs
134
+ ? setTimeout(() => {
135
+ timedOut = true;
136
+ child.kill("SIGKILL");
137
+ }, opts.timeoutMs)
138
+ : undefined;
139
+ child.on("error", (e) => {
140
+ clearTimeout(timer);
141
+ reject(new ProviderError("docker", `could not run ${bin}: ${e.message}`));
142
+ });
143
+ child.on("close", (code) => {
144
+ clearTimeout(timer);
145
+ resolve({ code, stdout, stderr, bytes: opts.binary ? new Uint8Array(Buffer.concat(out)) : undefined, ms: Date.now() - t0, timedOut });
146
+ });
147
+ // A command that exits before reading its stdin makes the pipe raise EPIPE; the exit code already tells the story.
148
+ child.stdin.on("error", () => { });
149
+ if (opts.stdin)
150
+ child.stdin.end(Buffer.from(opts.stdin));
151
+ else
152
+ child.stdin.end();
153
+ });
154
+ }