@hotcell/daemon 0.1.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 (142) hide show
  1. package/dist/agent-check.d.ts +1 -0
  2. package/dist/agent-check.js +105 -0
  3. package/dist/agent-check.js.map +1 -0
  4. package/dist/api/server.d.ts +64 -0
  5. package/dist/api/server.js +1368 -0
  6. package/dist/api/server.js.map +1 -0
  7. package/dist/api/ws.d.ts +14 -0
  8. package/dist/api/ws.js +214 -0
  9. package/dist/api/ws.js.map +1 -0
  10. package/dist/applevz-check.d.ts +1 -0
  11. package/dist/applevz-check.js +280 -0
  12. package/dist/applevz-check.js.map +1 -0
  13. package/dist/backups.d.ts +29 -0
  14. package/dist/backups.js +76 -0
  15. package/dist/backups.js.map +1 -0
  16. package/dist/capacity.d.ts +63 -0
  17. package/dist/capacity.js +95 -0
  18. package/dist/capacity.js.map +1 -0
  19. package/dist/config.d.ts +241 -0
  20. package/dist/config.js +152 -0
  21. package/dist/config.js.map +1 -0
  22. package/dist/cost.d.ts +9 -0
  23. package/dist/cost.js +19 -0
  24. package/dist/cost.js.map +1 -0
  25. package/dist/density-bench.d.ts +1 -0
  26. package/dist/density-bench.js +97 -0
  27. package/dist/density-bench.js.map +1 -0
  28. package/dist/driver/agent-driver.d.ts +67 -0
  29. package/dist/driver/agent-driver.js +197 -0
  30. package/dist/driver/agent-driver.js.map +1 -0
  31. package/dist/driver/agent.d.ts +94 -0
  32. package/dist/driver/agent.js +319 -0
  33. package/dist/driver/agent.js.map +1 -0
  34. package/dist/driver/applevz.d.ts +95 -0
  35. package/dist/driver/applevz.js +527 -0
  36. package/dist/driver/applevz.js.map +1 -0
  37. package/dist/driver/container.d.ts +99 -0
  38. package/dist/driver/container.js +733 -0
  39. package/dist/driver/container.js.map +1 -0
  40. package/dist/driver/fc-api.d.ts +85 -0
  41. package/dist/driver/fc-api.js +173 -0
  42. package/dist/driver/fc-api.js.map +1 -0
  43. package/dist/driver/firecracker.d.ts +126 -0
  44. package/dist/driver/firecracker.js +560 -0
  45. package/dist/driver/firecracker.js.map +1 -0
  46. package/dist/driver/index.d.ts +17 -0
  47. package/dist/driver/index.js +61 -0
  48. package/dist/driver/index.js.map +1 -0
  49. package/dist/driver/router.d.ts +82 -0
  50. package/dist/driver/router.js +159 -0
  51. package/dist/driver/router.js.map +1 -0
  52. package/dist/driver/types.d.ts +234 -0
  53. package/dist/driver/types.js +2 -0
  54. package/dist/driver/types.js.map +1 -0
  55. package/dist/driver/unsupported.d.ts +50 -0
  56. package/dist/driver/unsupported.js +81 -0
  57. package/dist/driver/unsupported.js.map +1 -0
  58. package/dist/driver/vz-image.d.ts +35 -0
  59. package/dist/driver/vz-image.js +75 -0
  60. package/dist/driver/vz-image.js.map +1 -0
  61. package/dist/e2e-harness.d.ts +1 -0
  62. package/dist/e2e-harness.js +99 -0
  63. package/dist/e2e-harness.js.map +1 -0
  64. package/dist/egress-check.d.ts +8 -0
  65. package/dist/egress-check.js +317 -0
  66. package/dist/egress-check.js.map +1 -0
  67. package/dist/fc-check.d.ts +1 -0
  68. package/dist/fc-check.js +113 -0
  69. package/dist/fc-check.js.map +1 -0
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.js +114 -0
  72. package/dist/index.js.map +1 -0
  73. package/dist/kernels.d.ts +22 -0
  74. package/dist/kernels.js +134 -0
  75. package/dist/kernels.js.map +1 -0
  76. package/dist/lifecycle.d.ts +49 -0
  77. package/dist/lifecycle.js +111 -0
  78. package/dist/lifecycle.js.map +1 -0
  79. package/dist/logger.d.ts +16 -0
  80. package/dist/logger.js +67 -0
  81. package/dist/logger.js.map +1 -0
  82. package/dist/metrics.d.ts +46 -0
  83. package/dist/metrics.js +92 -0
  84. package/dist/metrics.js.map +1 -0
  85. package/dist/net/firewall.d.ts +4 -0
  86. package/dist/net/firewall.js +80 -0
  87. package/dist/net/firewall.js.map +1 -0
  88. package/dist/pricing.d.ts +36 -0
  89. package/dist/pricing.js +81 -0
  90. package/dist/pricing.js.map +1 -0
  91. package/dist/proxy/allowlist-default.d.ts +23 -0
  92. package/dist/proxy/allowlist-default.js +53 -0
  93. package/dist/proxy/allowlist-default.js.map +1 -0
  94. package/dist/proxy/allowlist.d.ts +43 -0
  95. package/dist/proxy/allowlist.js +87 -0
  96. package/dist/proxy/allowlist.js.map +1 -0
  97. package/dist/proxy/egress.d.ts +49 -0
  98. package/dist/proxy/egress.js +599 -0
  99. package/dist/proxy/egress.js.map +1 -0
  100. package/dist/proxy/ratelimit.d.ts +49 -0
  101. package/dist/proxy/ratelimit.js +74 -0
  102. package/dist/proxy/ratelimit.js.map +1 -0
  103. package/dist/proxy/server.d.ts +26 -0
  104. package/dist/proxy/server.js +202 -0
  105. package/dist/proxy/server.js.map +1 -0
  106. package/dist/router-check.d.ts +1 -0
  107. package/dist/router-check.js +97 -0
  108. package/dist/router-check.js.map +1 -0
  109. package/dist/smoke-fc.d.ts +1 -0
  110. package/dist/smoke-fc.js +183 -0
  111. package/dist/smoke-fc.js.map +1 -0
  112. package/dist/smoke-microvm.d.ts +1 -0
  113. package/dist/smoke-microvm.js +187 -0
  114. package/dist/smoke-microvm.js.map +1 -0
  115. package/dist/smoke-remote.d.ts +20 -0
  116. package/dist/smoke-remote.js +155 -0
  117. package/dist/smoke-remote.js.map +1 -0
  118. package/dist/smoke-vz.d.ts +1 -0
  119. package/dist/smoke-vz.js +161 -0
  120. package/dist/smoke-vz.js.map +1 -0
  121. package/dist/smoke.d.ts +7 -0
  122. package/dist/smoke.js +962 -0
  123. package/dist/smoke.js.map +1 -0
  124. package/dist/store.d.ts +124 -0
  125. package/dist/store.js +578 -0
  126. package/dist/store.js.map +1 -0
  127. package/dist/tracing.d.ts +45 -0
  128. package/dist/tracing.js +144 -0
  129. package/dist/tracing.js.map +1 -0
  130. package/dist/types.d.ts +274 -0
  131. package/dist/types.js +3 -0
  132. package/dist/types.js.map +1 -0
  133. package/dist/util.d.ts +27 -0
  134. package/dist/util.js +63 -0
  135. package/dist/util.js.map +1 -0
  136. package/dist/warmpool-check.d.ts +1 -0
  137. package/dist/warmpool-check.js +83 -0
  138. package/dist/warmpool-check.js.map +1 -0
  139. package/dist/web/dashboard.d.ts +1 -0
  140. package/dist/web/dashboard.js +456 -0
  141. package/dist/web/dashboard.js.map +1 -0
  142. package/package.json +53 -0
@@ -0,0 +1,1368 @@
1
+ import { createServer } from "node:http";
2
+ import { previewUrl } from "../config.js";
3
+ import { kernelFor } from "../kernels.js";
4
+ import { pauseSandbox, resumeSandbox } from "../lifecycle.js";
5
+ import { computeCost } from "../cost.js";
6
+ import { log } from "../logger.js";
7
+ import { recentSpans, startSpan } from "../tracing.js";
8
+ import { buildProviders } from "../proxy/egress.js";
9
+ import { DRIVER_NAMES } from "../driver/index.js";
10
+ import { emptyUsage, SandboxStore } from "../store.js";
11
+ import { acceptWebSocket } from "./ws.js";
12
+ import { BodyTooLargeError, errorMessage, readBodyCapped, safeEqual, sendJson, shellEscape, } from "../util.js";
13
+ import { DASHBOARD_HTML } from "../web/dashboard.js";
14
+ /**
15
+ * Phase 0/1 REST surface:
16
+ * GET /healthz
17
+ * POST /sandboxes -> create
18
+ * GET /sandboxes -> list
19
+ * GET /sandboxes/:id -> get
20
+ * DELETE /sandboxes/:id -> destroy (removes volume too)
21
+ * GET /sandboxes/:id/metrics -> live stats + cumulative usage + cost
22
+ * POST /sandboxes/:id/stop -> stop (remove container, keep volume)
23
+ * POST /sandboxes/:id/pause -> fast-pause (memory snapshot on microVM drivers; else stop-with-volume); any op auto-resumes
24
+ * POST /sandboxes/:id/start -> start (recreate container, reattach volume)
25
+ * POST /sandboxes/:id/backups -> back up /workspace, returns BackupInfo
26
+ * GET /sandboxes/:id/backups -> list this sandbox's backups
27
+ * POST /sandboxes/:id/restore -> restore /workspace from {backupId}
28
+ * GET /backups -> list all backups
29
+ * DELETE /backups/:backupId -> delete a backup
30
+ * POST /sandboxes/:id/code-contexts -> create a code-interpreter context
31
+ * GET /sandboxes/:id/code-contexts -> list code contexts
32
+ * DELETE /sandboxes/:id/code-contexts/:ctxId -> destroy a code context
33
+ * POST /sandboxes/:id/run-code -> run code, returns CodeResult
34
+ * POST /sandboxes/:id/exec -> run command, stream output as SSE
35
+ * POST /sandboxes/:id/files/write -> write file
36
+ * POST /sandboxes/:id/files/read -> read file
37
+ * POST /sandboxes/:id/files/mkdir -> create directory
38
+ * POST /sandboxes/:id/files/list -> list directory
39
+ * GET /sandboxes/:id/watch -> stream file-change events (SSE)
40
+ * GET /sandboxes/:id/terminal -> interactive PTY shell (WebSocket upgrade)
41
+ * GET /sandboxes/:id/metrics/history -> recent live-metrics samples (sparklines)
42
+ * POST /sandboxes/:id/egress-tokens -> mint an egress (LLM gateway) token
43
+ * GET /sandboxes/:id/egress-tokens -> list egress tokens + provider URLs
44
+ * DELETE /sandboxes/:id/egress-tokens/:token -> revoke an egress token
45
+ * GET /traces -> recent finished trace spans
46
+ * POST /sandboxes/:id/processes -> start background process
47
+ * GET /sandboxes/:id/processes -> list processes
48
+ * DELETE /sandboxes/:id/processes/:procId -> signal/kill process
49
+ * GET /sandboxes/:id/processes/:procId/logs -> stream logs (SSE)
50
+ * POST /sandboxes/:id/wait-port -> wait for a TCP port
51
+ * POST /sandboxes/:id/expose -> expose a port (preview URL)
52
+ * GET /sandboxes/:id/expose -> list exposed ports
53
+ * DELETE /sandboxes/:id/expose/:port -> unexpose a port
54
+ * GET /sandboxes/:id/env -> get sandbox env vars
55
+ * POST /sandboxes/:id/env -> merge sandbox env vars
56
+ * POST /sandboxes/:id/sessions -> create a session
57
+ * GET /sandboxes/:id/sessions -> list sessions
58
+ * DELETE /sandboxes/:id/sessions/:sid -> delete a session
59
+ * POST /sandboxes/:id/sessions/:sid/env -> merge session env vars
60
+ */
61
+ export function createApiServer(deps) {
62
+ const server = createServer((req, res) => {
63
+ const method = req.method ?? "GET";
64
+ const rawPath = (req.url ?? "/").split("?")[0];
65
+ // One server span per request — the create→exec→destroy trace the plan asks
66
+ // for falls out naturally (route is normalized so ids don't explode names).
67
+ const span = startSpan(`${method} ${normalizeRoute(rawPath)}`, {
68
+ "http.method": method,
69
+ "http.target": rawPath,
70
+ });
71
+ const startedMs = Date.now();
72
+ res.on("finish", () => {
73
+ span.setAttribute("http.status_code", res.statusCode);
74
+ if (res.statusCode >= 500)
75
+ span.setStatus("error");
76
+ span.end();
77
+ const line = {
78
+ method,
79
+ path: rawPath,
80
+ status: res.statusCode,
81
+ durMs: Date.now() - startedMs,
82
+ traceId: span.traceId,
83
+ };
84
+ if (res.statusCode >= 500)
85
+ log.error("request", line);
86
+ else
87
+ log.info("request", line);
88
+ });
89
+ handle(req, res, deps).catch((err) => {
90
+ span.setStatus("error").setAttribute("error", String(err?.message ?? err));
91
+ if (err instanceof BodyTooLargeError) {
92
+ return sendJson(res, 413, { error: "request body too large" });
93
+ }
94
+ sendJson(res, 500, { error: String(err?.message ?? err) });
95
+ });
96
+ });
97
+ // Live terminal: a WebSocket upgrade bridged to an in-sandbox PTY.
98
+ server.on("upgrade", (req, socket, head) => {
99
+ handleTerminalUpgrade(req, socket, head, deps).catch(() => socket.destroy());
100
+ });
101
+ return server;
102
+ }
103
+ /** Pull a Bearer token from the Authorization header, if present. */
104
+ function bearerFromHeader(req) {
105
+ const header = req.headers["authorization"];
106
+ return typeof header === "string" && header.startsWith("Bearer ")
107
+ ? header.slice(7)
108
+ : undefined;
109
+ }
110
+ /**
111
+ * Handle a WebSocket upgrade for `GET /sandboxes/:id/terminal`: authenticate,
112
+ * resolve (and auto-resume) the sandbox, open a PTY, and splice bytes both ways.
113
+ * Inbound binary frames are stdin; inbound text frames are control JSON (resize).
114
+ * Browsers can't set headers on a WebSocket, so the API key may arrive as `?key=`.
115
+ */
116
+ async function handleTerminalUpgrade(req, socket, head, deps) {
117
+ const { config, driver, store } = deps;
118
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
119
+ const match = url.pathname.match(/^\/sandboxes\/([^/]+)\/terminal$/);
120
+ if (!match) {
121
+ socket.destroy();
122
+ return;
123
+ }
124
+ if (!isAllowedHost(req, config)) {
125
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
126
+ socket.destroy();
127
+ return;
128
+ }
129
+ if (config.apiKey) {
130
+ const provided = url.searchParams.get("key") ?? bearerFromHeader(req);
131
+ if (!provided || !safeEqual(provided, config.apiKey)) {
132
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
133
+ socket.destroy();
134
+ return;
135
+ }
136
+ }
137
+ const id = match[1];
138
+ const record = store.get(id);
139
+ if (!record) {
140
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
141
+ socket.destroy();
142
+ return;
143
+ }
144
+ if (record.status === "stopped") {
145
+ socket.write("HTTP/1.1 409 Conflict\r\n\r\n");
146
+ socket.destroy();
147
+ return;
148
+ }
149
+ if (record.status === "paused")
150
+ await resumeSandbox(driver, store, record);
151
+ else
152
+ store.touch(id);
153
+ const ws = acceptWebSocket(req, socket, head);
154
+ if (!ws)
155
+ return;
156
+ const cols = Number(url.searchParams.get("cols")) || 80;
157
+ const rows = Number(url.searchParams.get("rows")) || 24;
158
+ const span = startSpan("WS /sandboxes/:id/terminal", { "sandbox.id": id });
159
+ let term;
160
+ try {
161
+ term = await driver.openTerminal(id, { cols, rows, env: record.env });
162
+ }
163
+ catch (err) {
164
+ ws.send(`\r\n[sbx] failed to open terminal: ${errorMessage(err)}\r\n`);
165
+ ws.close();
166
+ span.setStatus("error").end();
167
+ return;
168
+ }
169
+ log.info("terminal opened", { sandbox: id, traceId: span.traceId });
170
+ term.stream.on("data", (chunk) => ws.send(chunk));
171
+ term.stream.on("end", () => ws.close());
172
+ term.stream.on("error", () => ws.close());
173
+ ws.onMessage((data, isBinary) => {
174
+ if (isBinary) {
175
+ term.stream.write(data);
176
+ store.touch(id);
177
+ return;
178
+ }
179
+ try {
180
+ const msg = JSON.parse(data.toString("utf8"));
181
+ if (msg && msg.type === "resize") {
182
+ term.resize(Number(msg.cols) || cols, Number(msg.rows) || rows);
183
+ }
184
+ }
185
+ catch {
186
+ // Non-JSON text frames are ignored; keystrokes arrive as binary frames.
187
+ }
188
+ });
189
+ ws.onClose(() => {
190
+ term.close();
191
+ span.end();
192
+ log.info("terminal closed", { sandbox: id });
193
+ });
194
+ }
195
+ /**
196
+ * Build the egress-gateway client config for a token: per-provider base URLs the
197
+ * sandbox points its SDK at, plus suggested env vars. The token is used in place
198
+ * of each provider's real API key (the daemon swaps it for the real one).
199
+ */
200
+ function egressConfig(config, advertiseHost) {
201
+ const base = `http://${advertiseHost ?? config.egressAdvertiseHost}:${config.egressPort}`;
202
+ const names = Object.keys(buildProviders(config));
203
+ const ENV_HINT = {
204
+ openai: { baseUrlEnv: "OPENAI_BASE_URL", keyEnv: "OPENAI_API_KEY" },
205
+ anthropic: { baseUrlEnv: "ANTHROPIC_BASE_URL", keyEnv: "ANTHROPIC_API_KEY" },
206
+ openrouter: { baseUrlEnv: "OPENROUTER_BASE_URL", keyEnv: "OPENROUTER_API_KEY" },
207
+ google: { baseUrlEnv: "GOOGLE_BASE_URL", keyEnv: "GOOGLE_API_KEY" },
208
+ gemini: { baseUrlEnv: "GEMINI_BASE_URL", keyEnv: "GEMINI_API_KEY" },
209
+ };
210
+ // For custom/operator-defined providers, synthesize conventional env names
211
+ // (`<NAME>_BASE_URL` / `<NAME>_API_KEY`) so auto-wiring still injects something
212
+ // sensible. Operators can ignore these and wire their SDK by hand.
213
+ const providers = names.map((name) => {
214
+ const envName = name.toUpperCase().replace(/[^A-Z0-9]/g, "_");
215
+ const hint = ENV_HINT[name] ?? {
216
+ baseUrlEnv: `${envName}_BASE_URL`,
217
+ keyEnv: `${envName}_API_KEY`,
218
+ };
219
+ return { name, baseUrl: `${base}/${name}`, ...hint };
220
+ });
221
+ return { providers };
222
+ }
223
+ /**
224
+ * Env vars that point a sandbox's LLM SDKs at the egress gateway, with `token`
225
+ * standing in for each provider's real key. Injected at create time when a
226
+ * sandbox opts into egress, so the gateway is drop-in (no in-sandbox config).
227
+ */
228
+ function egressEnv(config, token, advertiseHost) {
229
+ const env = {};
230
+ for (const p of egressConfig(config, advertiseHost).providers) {
231
+ if (p.baseUrlEnv)
232
+ env[p.baseUrlEnv] = p.baseUrl;
233
+ if (p.keyEnv)
234
+ env[p.keyEnv] = token;
235
+ }
236
+ return env;
237
+ }
238
+ /**
239
+ * Forward-proxy env for a sandbox under egress enforcement: point `HTTP(S)_PROXY`
240
+ * at the gateway (the token is the proxy credential), and exclude the gateway host
241
+ * + loopback via `NO_PROXY` so the sandbox's own LLM base-URL calls (which target
242
+ * the gateway directly, origin-form) don't loop back through the proxy. Lower-case
243
+ * variants too, since many tools (curl, pip, apt) read those.
244
+ */
245
+ function proxyEnv(config, token, advertiseHost) {
246
+ const host = advertiseHost ?? config.egressAdvertiseHost;
247
+ // Token as BOTH user and password: many clients (pip/urllib3, some others) only
248
+ // emit `Proxy-Authorization` on a CONNECT when the proxy URL has a non-empty
249
+ // password. The gateway reads the username half, so the password value is moot.
250
+ const proxy = `http://${token}:${token}@${host}:${config.egressPort}`;
251
+ const noProxy = `localhost,127.0.0.1,${host}`;
252
+ return {
253
+ HTTP_PROXY: proxy,
254
+ HTTPS_PROXY: proxy,
255
+ http_proxy: proxy,
256
+ https_proxy: proxy,
257
+ NO_PROXY: noProxy,
258
+ no_proxy: noProxy,
259
+ };
260
+ }
261
+ /**
262
+ * Validate + normalise an egress-token policy from a request body. Accepts the
263
+ * `ttlMs` sugar (→ `expiresAt = now + ttlMs`). An empty/blank body yields `{}`
264
+ * (unlimited — the pre-policy default). Returns `{ error }` (→ 400) on bad shape.
265
+ */
266
+ function parseEgressPolicy(body) {
267
+ const policy = {};
268
+ const num = (key) => {
269
+ const v = body[key];
270
+ if (v === undefined)
271
+ return undefined;
272
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0)
273
+ return null; // null = invalid
274
+ return v;
275
+ };
276
+ const ttlMs = num("ttlMs");
277
+ if (ttlMs === null)
278
+ return { error: "ttlMs must be a non-negative number" };
279
+ if (typeof ttlMs === "number" && ttlMs > 0) {
280
+ policy.expiresAt = new Date(Date.now() + ttlMs).toISOString();
281
+ }
282
+ if (typeof body.expiresAt === "string" && body.expiresAt) {
283
+ if (Number.isNaN(Date.parse(body.expiresAt)))
284
+ return { error: "expiresAt must be an ISO timestamp" };
285
+ policy.expiresAt = body.expiresAt;
286
+ }
287
+ const spendCapUsd = num("spendCapUsd");
288
+ if (spendCapUsd === null)
289
+ return { error: "spendCapUsd must be a non-negative number" };
290
+ if (typeof spendCapUsd === "number")
291
+ policy.spendCapUsd = spendCapUsd;
292
+ if (body.rateLimit !== undefined) {
293
+ const r = body.rateLimit;
294
+ if (typeof r !== "object" || r === null)
295
+ return { error: "rateLimit must be an object" };
296
+ const windowMs = r.windowMs;
297
+ if (typeof windowMs !== "number" || !Number.isFinite(windowMs) || windowMs <= 0) {
298
+ return { error: "rateLimit.windowMs must be a positive number" };
299
+ }
300
+ const rateLimit = { windowMs };
301
+ for (const k of ["calls", "tokens"]) {
302
+ const v = r[k];
303
+ if (v === undefined)
304
+ continue;
305
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
306
+ return { error: `rateLimit.${k} must be a non-negative number` };
307
+ }
308
+ rateLimit[k] = v;
309
+ }
310
+ policy.rateLimit = rateLimit;
311
+ }
312
+ for (const key of ["models", "providers"]) {
313
+ const v = body[key];
314
+ if (v === undefined)
315
+ continue;
316
+ if (!Array.isArray(v) || !v.every((s) => typeof s === "string")) {
317
+ return { error: `${key} must be an array of strings` };
318
+ }
319
+ if (v.length > 0)
320
+ policy[key] = v;
321
+ }
322
+ return { policy };
323
+ }
324
+ /**
325
+ * Resolve hard resource caps for a new sandbox: each of `memoryMb`/`cpus`/
326
+ * `pidsLimit` is the per-create body value if given, else the daemon default.
327
+ * Only active (>0) caps are kept, so an unlimited sandbox has `limits: {}`.
328
+ * Returns `{ error }` (→ 400) on an invalid value.
329
+ */
330
+ function resolveLimits(body, config) {
331
+ const read = (key) => {
332
+ const v = body[key];
333
+ if (v === undefined)
334
+ return undefined;
335
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0)
336
+ return Number.NaN;
337
+ return v;
338
+ };
339
+ for (const key of ["memoryMb", "cpus", "pidsLimit"]) {
340
+ if (Number.isNaN(read(key)))
341
+ return { error: `${key} must be a non-negative number` };
342
+ }
343
+ const memoryMb = read("memoryMb") ?? config.defaultMemoryMb;
344
+ const cpus = read("cpus") ?? config.defaultCpus;
345
+ const pidsLimit = read("pidsLimit") ?? config.defaultPidsLimit;
346
+ if (memoryMb > 0 && memoryMb < 6) {
347
+ return { error: "memoryMb must be at least 6 (Docker minimum)" };
348
+ }
349
+ const limits = {};
350
+ if (memoryMb > 0)
351
+ limits.memoryMb = memoryMb;
352
+ if (cpus > 0)
353
+ limits.cpus = cpus;
354
+ if (pidsLimit > 0)
355
+ limits.pidsLimit = pidsLimit;
356
+ return { limits };
357
+ }
358
+ /** Collapse high-cardinality id segments so span/route names stay bounded. */
359
+ function normalizeRoute(path) {
360
+ return path
361
+ .replace(/^\/sandboxes\/[^/]+/, "/sandboxes/:id")
362
+ .replace(/\/(processes|sessions|code-contexts|expose|backups)\/[^/]+/, "/$1/:sub");
363
+ }
364
+ /**
365
+ * Enforce the API key when one is configured. `/healthz` and the dashboard HTML
366
+ * stay open (probes + first paint, so the dashboard can prompt for the key).
367
+ * Accepts `Authorization: Bearer <key>` or `X-API-Key: <key>`.
368
+ */
369
+ function isAuthorized(req, config, path) {
370
+ if (!config.apiKey)
371
+ return true;
372
+ // Open: health probes, the dashboard shell, and /info (non-sensitive — it only
373
+ // reports driver/image/cost-rates and whether auth is on, so the dashboard can
374
+ // decide to prompt for a key before making authenticated calls).
375
+ if (path === "/healthz" || path === "/" || path === "/dashboard" || path === "/info") {
376
+ return true;
377
+ }
378
+ const header = req.headers["authorization"];
379
+ const bearer = typeof header === "string" && header.startsWith("Bearer ")
380
+ ? header.slice(7)
381
+ : undefined;
382
+ const apiKeyHeader = req.headers["x-api-key"];
383
+ const provided = bearer ?? (typeof apiKeyHeader === "string" ? apiKeyHeader : undefined);
384
+ return provided !== undefined && safeEqual(provided, config.apiKey);
385
+ }
386
+ /** A host bound to a loopback interface (the dev default). */
387
+ function isLoopbackHost(host) {
388
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
389
+ }
390
+ /**
391
+ * DNS-rebinding / localhost-CSRF guard: when the daemon is bound to loopback,
392
+ * only accept requests whose `Host` header is a loopback name, `config.host`, or
393
+ * an explicit `SBX_ALLOWED_HOSTS` entry — so a website the operator visits can't
394
+ * drive the API via a rebound DNS name. If bound to a non-loopback interface the
395
+ * operator has opted into network exposure, so the guard is skipped (they should
396
+ * set an API key). The preview/egress proxies are separate servers and unaffected.
397
+ */
398
+ function isAllowedHost(req, config) {
399
+ if (!isLoopbackHost(config.host))
400
+ return true;
401
+ const hostHeader = req.headers["host"];
402
+ if (typeof hostHeader !== "string")
403
+ return false;
404
+ const hostname = hostHeader
405
+ .replace(/:\d+$/, "") // strip port
406
+ .replace(/^\[|\]$/g, "") // strip IPv6 brackets
407
+ .toLowerCase();
408
+ if (hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1")
409
+ return true;
410
+ if (hostname === config.host.toLowerCase())
411
+ return true;
412
+ return config.allowedHosts.some((h) => h.toLowerCase() === hostname);
413
+ }
414
+ async function handle(req, res, deps) {
415
+ const { config, driver, store, backups } = deps;
416
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
417
+ const path = url.pathname;
418
+ const method = req.method ?? "GET";
419
+ if (!isAllowedHost(req, config)) {
420
+ return sendJson(res, 403, { error: "host not allowed" });
421
+ }
422
+ if (!isAuthorized(req, config, path)) {
423
+ res.setHeader("WWW-Authenticate", "Bearer");
424
+ return sendJson(res, 401, { error: "missing or invalid API key" });
425
+ }
426
+ if (method === "GET" && path === "/healthz") {
427
+ try {
428
+ await driver.ping();
429
+ return sendJson(res, 200, { ok: true, driver: driver.name });
430
+ }
431
+ catch (err) {
432
+ return sendJson(res, 503, { ok: false, error: String(err) });
433
+ }
434
+ }
435
+ // Daemon info for the dashboard (cost rates, proxy port, driver, image).
436
+ if (method === "GET" && path === "/info") {
437
+ return sendJson(res, 200, {
438
+ driver: driver.name,
439
+ drivers: DRIVER_NAMES,
440
+ defaultImage: config.defaultImage,
441
+ proxyPort: config.proxyPort,
442
+ costCpuPerHour: config.costCpuPerHour,
443
+ costMemGbPerHour: config.costMemGbPerHour,
444
+ costEgressPerGb: config.costEgressPerGb,
445
+ defaultSleepAfterMs: config.defaultSleepAfterMs,
446
+ auth: config.apiKey.length > 0,
447
+ otlp: config.otlpEndpoint.length > 0,
448
+ egressPort: config.egressPort,
449
+ egressProviders: Object.keys(buildProviders(config)),
450
+ defaultMemoryMb: config.defaultMemoryMb,
451
+ defaultCpus: config.defaultCpus,
452
+ defaultPidsLimit: config.defaultPidsLimit,
453
+ });
454
+ }
455
+ const egressTokenIdMatch = path.match(/^\/sandboxes\/([^/]+)\/egress-tokens\/([^/]+)$/);
456
+ if (method === "DELETE" && egressTokenIdMatch) {
457
+ if (!store.get(egressTokenIdMatch[1]))
458
+ return sendJson(res, 404, { error: "not found" });
459
+ const removed = store.removeEgressToken(egressTokenIdMatch[2]);
460
+ if (!removed)
461
+ return sendJson(res, 404, { error: "token not found" });
462
+ return sendJson(res, 200, { token: egressTokenIdMatch[2], revoked: true });
463
+ }
464
+ const egressTokensMatch = path.match(/^\/sandboxes\/([^/]+)\/egress-tokens$/);
465
+ if (egressTokensMatch) {
466
+ const id = egressTokensMatch[1];
467
+ if (!store.get(id))
468
+ return sendJson(res, 404, { error: "not found" });
469
+ if (method === "POST") {
470
+ const body = await readJson(req, config.maxBodyBytes);
471
+ const parsed = parseEgressPolicy(body);
472
+ if ("error" in parsed)
473
+ return sendJson(res, 400, { error: parsed.error });
474
+ const token = SandboxStore.newEgressToken();
475
+ store.addEgressToken(token, id, parsed.policy);
476
+ return sendJson(res, 201, {
477
+ token,
478
+ policy: parsed.policy,
479
+ providers: egressConfig(config).providers,
480
+ });
481
+ }
482
+ if (method === "GET") {
483
+ const tokens = store.listEgressTokens(id).map((r) => ({
484
+ token: r.token,
485
+ policy: r.policy,
486
+ spendUsd: r.spendUsd,
487
+ spendRemaining: r.policy.spendCapUsd === undefined
488
+ ? null
489
+ : Math.max(0, r.policy.spendCapUsd - r.spendUsd),
490
+ }));
491
+ return sendJson(res, 200, { tokens, providers: egressConfig(config).providers });
492
+ }
493
+ }
494
+ // Host capacity + admission status (for the dashboard meter + `sb capacity`).
495
+ if (method === "GET" && path === "/capacity") {
496
+ if (!deps.capacity)
497
+ return sendJson(res, 200, { enforced: false });
498
+ return sendJson(res, 200, deps.capacity.snapshot());
499
+ }
500
+ // Recent finished spans, for debugging and the trace view. Newest first.
501
+ if (method === "GET" && path === "/traces") {
502
+ const limit = Number(url.searchParams.get("limit") ?? "100") || 100;
503
+ return sendJson(res, 200, { spans: recentSpans().slice(0, limit) });
504
+ }
505
+ // Embedded single-page dashboard (no build step, zero dependencies).
506
+ if (method === "GET" && (path === "/" || path === "/dashboard")) {
507
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
508
+ return void res.end(DASHBOARD_HTML);
509
+ }
510
+ if (method === "POST" && path === "/sandboxes") {
511
+ const body = await readJson(req, config.maxBodyBytes);
512
+ return createSandbox(res, { config, driver, store, capacity: deps.capacity }, body);
513
+ }
514
+ if (method === "GET" && path === "/sandboxes") {
515
+ return sendJson(res, 200, { sandboxes: store.list() });
516
+ }
517
+ const execMatch = path.match(/^\/sandboxes\/([^/]+)\/exec$/);
518
+ if (method === "POST" && execMatch) {
519
+ const body = await readJson(req, config.maxBodyBytes);
520
+ return execInSandbox(res, { driver, store }, execMatch[1], body);
521
+ }
522
+ const fileActionMatch = path.match(/^\/sandboxes\/([^/]+)\/files\/(write|read|mkdir|list)$/);
523
+ if (method === "POST" && fileActionMatch) {
524
+ const body = await readJson(req, config.maxBodyBytes);
525
+ return handleFileAction(res, { driver, store }, fileActionMatch[1], fileActionMatch[2], body);
526
+ }
527
+ const watchMatch = path.match(/^\/sandboxes\/([^/]+)\/watch$/);
528
+ if (method === "GET" && watchMatch) {
529
+ const watchPath = url.searchParams.get("path") ?? "/workspace";
530
+ const intervalMs = Number(url.searchParams.get("interval") ?? "") || undefined;
531
+ return watchFiles(req, res, { driver, store }, watchMatch[1], watchPath, intervalMs);
532
+ }
533
+ const procLogsMatch = path.match(/^\/sandboxes\/([^/]+)\/processes\/([^/]+)\/logs$/);
534
+ if (method === "GET" && procLogsMatch) {
535
+ return streamLogs(req, res, { driver, store }, procLogsMatch[1], procLogsMatch[2], url.searchParams.get("follow") !== "0" &&
536
+ url.searchParams.has("follow"));
537
+ }
538
+ const procIdMatch = path.match(/^\/sandboxes\/([^/]+)\/processes\/([^/]+)$/);
539
+ if (method === "DELETE" && procIdMatch) {
540
+ const body = await readJson(req, config.maxBodyBytes);
541
+ return killProcess(res, { driver, store }, procIdMatch[1], procIdMatch[2], body);
542
+ }
543
+ const procMatch = path.match(/^\/sandboxes\/([^/]+)\/processes$/);
544
+ if (procMatch) {
545
+ if (method === "POST") {
546
+ const body = await readJson(req, config.maxBodyBytes);
547
+ return startProcess(res, { driver, store }, procMatch[1], body);
548
+ }
549
+ if (method === "GET") {
550
+ return listProcesses(res, { driver, store }, procMatch[1]);
551
+ }
552
+ }
553
+ const waitPortMatch = path.match(/^\/sandboxes\/([^/]+)\/wait-port$/);
554
+ if (method === "POST" && waitPortMatch) {
555
+ const body = await readJson(req, config.maxBodyBytes);
556
+ return waitForPort(res, { driver, store }, waitPortMatch[1], body);
557
+ }
558
+ const exposePortMatch = path.match(/^\/sandboxes\/([^/]+)\/expose\/(\d+)$/);
559
+ if (method === "DELETE" && exposePortMatch) {
560
+ return unexposePort(res, { store }, exposePortMatch[1], Number(exposePortMatch[2]));
561
+ }
562
+ const exposeMatch = path.match(/^\/sandboxes\/([^/]+)\/expose$/);
563
+ if (exposeMatch) {
564
+ if (method === "POST") {
565
+ const body = await readJson(req, config.maxBodyBytes);
566
+ return exposePort(res, { config, driver, store }, exposeMatch[1], body);
567
+ }
568
+ if (method === "GET") {
569
+ const record = store.get(exposeMatch[1]);
570
+ if (!record)
571
+ return sendJson(res, 404, { error: "not found" });
572
+ return sendJson(res, 200, { exposed: store.listExposed(exposeMatch[1]) });
573
+ }
574
+ }
575
+ const sessionEnvMatch = path.match(/^\/sandboxes\/([^/]+)\/sessions\/([^/]+)\/env$/);
576
+ if (method === "POST" && sessionEnvMatch) {
577
+ const body = await readJson(req, config.maxBodyBytes);
578
+ return setSessionEnv(res, { store }, sessionEnvMatch[1], sessionEnvMatch[2], body);
579
+ }
580
+ const sessionIdMatch = path.match(/^\/sandboxes\/([^/]+)\/sessions\/([^/]+)$/);
581
+ if (method === "DELETE" && sessionIdMatch) {
582
+ return deleteSession(res, { store }, sessionIdMatch[1], sessionIdMatch[2]);
583
+ }
584
+ const sessionsMatch = path.match(/^\/sandboxes\/([^/]+)\/sessions$/);
585
+ if (sessionsMatch) {
586
+ if (method === "POST") {
587
+ const body = await readJson(req, config.maxBodyBytes);
588
+ return createSession(res, { store }, sessionsMatch[1], body);
589
+ }
590
+ if (method === "GET") {
591
+ if (!store.get(sessionsMatch[1])) {
592
+ return sendJson(res, 404, { error: "not found" });
593
+ }
594
+ return sendJson(res, 200, {
595
+ sessions: store.listSessions(sessionsMatch[1]),
596
+ });
597
+ }
598
+ }
599
+ const envMatch = path.match(/^\/sandboxes\/([^/]+)\/env$/);
600
+ if (envMatch) {
601
+ const record = store.get(envMatch[1]);
602
+ if (!record)
603
+ return sendJson(res, 404, { error: "not found" });
604
+ if (method === "GET") {
605
+ return sendJson(res, 200, { env: record.env });
606
+ }
607
+ if (method === "POST") {
608
+ const body = await readJson(req, config.maxBodyBytes);
609
+ const env = body.env ?? {};
610
+ record.env = { ...record.env, ...env };
611
+ store.add(record); // write-through the env change
612
+ return sendJson(res, 200, { env: record.env });
613
+ }
614
+ }
615
+ if (method === "GET" && path === "/backups") {
616
+ return sendJson(res, 200, { backups: await backups.list() });
617
+ }
618
+ const backupIdMatch = path.match(/^\/backups\/([^/]+)$/);
619
+ if (method === "DELETE" && backupIdMatch) {
620
+ const removed = await backups.remove(backupIdMatch[1]);
621
+ if (!removed)
622
+ return sendJson(res, 404, { error: "backup not found" });
623
+ return sendJson(res, 200, { backupId: backupIdMatch[1], deleted: true });
624
+ }
625
+ const backupsMatch = path.match(/^\/sandboxes\/([^/]+)\/backups$/);
626
+ if (backupsMatch) {
627
+ if (method === "POST") {
628
+ return createBackup(res, deps, backupsMatch[1]);
629
+ }
630
+ if (method === "GET") {
631
+ if (!store.get(backupsMatch[1])) {
632
+ return sendJson(res, 404, { error: "not found" });
633
+ }
634
+ const all = await backups.list();
635
+ return sendJson(res, 200, {
636
+ backups: all.filter((b) => b.sandboxId === backupsMatch[1]),
637
+ });
638
+ }
639
+ }
640
+ const restoreMatch = path.match(/^\/sandboxes\/([^/]+)\/restore$/);
641
+ if (method === "POST" && restoreMatch) {
642
+ const body = await readJson(req, config.maxBodyBytes);
643
+ return restoreBackup(res, deps, restoreMatch[1], body);
644
+ }
645
+ const ctxIdMatch = path.match(/^\/sandboxes\/([^/]+)\/code-contexts\/([^/]+)$/);
646
+ if (method === "DELETE" && ctxIdMatch) {
647
+ return deleteCodeContext(res, { driver, store }, ctxIdMatch[1], ctxIdMatch[2]);
648
+ }
649
+ const ctxMatch = path.match(/^\/sandboxes\/([^/]+)\/code-contexts$/);
650
+ if (ctxMatch) {
651
+ if (method === "POST") {
652
+ const body = await readJson(req, config.maxBodyBytes);
653
+ return createCodeContext(res, { driver, store }, ctxMatch[1], body);
654
+ }
655
+ if (method === "GET") {
656
+ if (!store.get(ctxMatch[1]))
657
+ return sendJson(res, 404, { error: "not found" });
658
+ return sendJson(res, 200, {
659
+ contexts: store.listContexts(ctxMatch[1]).map(publicContext),
660
+ });
661
+ }
662
+ }
663
+ const runCodeMatch = path.match(/^\/sandboxes\/([^/]+)\/run-code$/);
664
+ if (method === "POST" && runCodeMatch) {
665
+ const body = await readJson(req, config.maxBodyBytes);
666
+ return runCode(res, { driver, store }, runCodeMatch[1], body);
667
+ }
668
+ const historyMatch = path.match(/^\/sandboxes\/([^/]+)\/metrics\/history$/);
669
+ if (method === "GET" && historyMatch) {
670
+ if (!store.get(historyMatch[1]))
671
+ return sendJson(res, 404, { error: "not found" });
672
+ return sendJson(res, 200, { samples: deps.history?.get(historyMatch[1]) ?? [] });
673
+ }
674
+ const metricsMatch = path.match(/^\/sandboxes\/([^/]+)\/metrics$/);
675
+ if (method === "GET" && metricsMatch) {
676
+ const live = url.searchParams.get("live") !== "0";
677
+ return getMetrics(res, { config, driver, store }, metricsMatch[1], live);
678
+ }
679
+ const stopMatch = path.match(/^\/sandboxes\/([^/]+)\/stop$/);
680
+ if (method === "POST" && stopMatch) {
681
+ return stopSandbox(res, { driver, store }, stopMatch[1]);
682
+ }
683
+ const pauseMatch = path.match(/^\/sandboxes\/([^/]+)\/pause$/);
684
+ if (method === "POST" && pauseMatch) {
685
+ return pauseSandboxRoute(res, { driver, store }, pauseMatch[1]);
686
+ }
687
+ const startMatch = path.match(/^\/sandboxes\/([^/]+)\/start$/);
688
+ if (method === "POST" && startMatch) {
689
+ return startSandbox(res, { driver, store }, startMatch[1]);
690
+ }
691
+ const idMatch = path.match(/^\/sandboxes\/([^/]+)$/);
692
+ if (idMatch) {
693
+ const id = idMatch[1];
694
+ if (method === "GET") {
695
+ const record = store.get(id);
696
+ if (!record)
697
+ return sendJson(res, 404, { error: "not found" });
698
+ return sendJson(res, 200, record);
699
+ }
700
+ if (method === "DELETE") {
701
+ const record = store.get(id);
702
+ if (!record)
703
+ return sendJson(res, 404, { error: "not found" });
704
+ await driver.destroy(id);
705
+ store.remove(id);
706
+ store.clearSandbox(id);
707
+ deps.history?.clear(id);
708
+ return sendJson(res, 200, { id, destroyed: true });
709
+ }
710
+ }
711
+ sendJson(res, 404, { error: `no route for ${method} ${path}` });
712
+ }
713
+ async function createSandbox(res, { config, driver, store, capacity }, body) {
714
+ const id = SandboxStore.newId();
715
+ const image = typeof body.image === "string" ? body.image : config.defaultImage;
716
+ // Per-sandbox isolation selection: pick the runtime driver (default SBX_DRIVER).
717
+ const driverName = typeof body.driver === "string" && body.driver ? body.driver : config.driver;
718
+ if (!DRIVER_NAMES.includes(driverName)) {
719
+ return sendJson(res, 400, {
720
+ error: `unknown driver "${driverName}" (expected: ${DRIVER_NAMES.join(" | ")})`,
721
+ });
722
+ }
723
+ const labels = body.labels ?? {};
724
+ const persist = body.persist !== false;
725
+ const sleepAfterMs = typeof body.sleepAfter === "number" ? body.sleepAfter : config.defaultSleepAfterMs;
726
+ const setup = Array.isArray(body.setup)
727
+ ? body.setup.filter((s) => typeof s === "string")
728
+ : undefined;
729
+ const repo = typeof body.repo === "string" && body.repo ? body.repo : undefined;
730
+ const repoRef = typeof body.repoRef === "string" && body.repoRef ? body.repoRef : undefined;
731
+ // Resolve hard resource caps: per-create value overrides the daemon default.
732
+ const resolved = resolveLimits(body, config);
733
+ if ("error" in resolved)
734
+ return sendJson(res, 400, { error: resolved.error });
735
+ const limits = resolved.limits;
736
+ // Admission control: refuse to over-subscribe the host's memory budget.
737
+ const admitted = capacity?.admit(limits.memoryMb) ?? { ok: true };
738
+ if (!admitted.ok)
739
+ return sendJson(res, 503, { error: admitted.reason });
740
+ // Egress wiring. `egress: true` (or a policy object) opts the sandbox's LLM SDKs
741
+ // into the gateway (provider base-URL + key env). Independently, when egress
742
+ // enforcement is ON every sandbox is locked to the gateway, so it also gets a
743
+ // token + `HTTP(S)_PROXY` so pip/npm/git/apt can reach the allowlist.
744
+ let egressToken;
745
+ let egressPolicy = {};
746
+ let env = body.env ?? {};
747
+ const wantEgress = body.egress === true || (!!body.egress && typeof body.egress === "object");
748
+ if (typeof body.egress === "object" && body.egress) {
749
+ const parsed = parseEgressPolicy(body.egress);
750
+ if ("error" in parsed)
751
+ return sendJson(res, 400, { error: parsed.error });
752
+ egressPolicy = parsed.policy;
753
+ }
754
+ if (wantEgress || config.egressEnforce) {
755
+ egressToken = SandboxStore.newEgressToken();
756
+ // microVM guests have no NIC: their route to the gateway is the in-guest
757
+ // loopback relay (agent listener on 127.0.0.1:<egressPort> → vsock → host),
758
+ // so their env points at localhost instead of the gateway's host address.
759
+ const advertiseHost = driverName !== "container" ? "127.0.0.1" : undefined;
760
+ if (wantEgress)
761
+ env = { ...env, ...egressEnv(config, egressToken, advertiseHost) };
762
+ if (config.egressEnforce)
763
+ env = { ...env, ...proxyEnv(config, egressToken, advertiseHost) };
764
+ }
765
+ try {
766
+ await driver.create({ id, image, driver: driverName, env, labels, persist, setup, repo, repoRef, limits });
767
+ }
768
+ catch (err) {
769
+ // Provisioning failed (e.g. repo clone) — tear down the half-built container
770
+ // so it doesn't orphan, and report the failure instead of leaking it.
771
+ await driver.destroy(id).catch(() => { });
772
+ return sendJson(res, 422, { error: `sandbox provisioning failed: ${errorMessage(err)}` });
773
+ }
774
+ // Per-sandbox LLM spend ceiling: per-create value (if a valid non-negative
775
+ // number) overrides the daemon default; 0 = unlimited.
776
+ const egressSpendCapUsd = typeof body.egressSpendCapUsd === "number" && body.egressSpendCapUsd >= 0
777
+ ? body.egressSpendCapUsd
778
+ : config.egressSpendCapUsd;
779
+ const now = new Date().toISOString();
780
+ const record = {
781
+ id,
782
+ image,
783
+ status: "running",
784
+ createdAt: now,
785
+ driver: driverName,
786
+ labels,
787
+ env,
788
+ persist,
789
+ lastActivityAt: now,
790
+ sleepAfterMs,
791
+ limits,
792
+ egressSpendCapUsd,
793
+ usage: emptyUsage(),
794
+ };
795
+ store.add(record);
796
+ if (egressToken)
797
+ store.addEgressToken(egressToken, id, egressPolicy);
798
+ sendJson(res, 201, record);
799
+ }
800
+ async function stopSandbox(res, { driver, store }, id) {
801
+ const record = store.get(id);
802
+ if (!record)
803
+ return sendJson(res, 404, { error: "not found" });
804
+ if (record.status === "stopped")
805
+ return sendJson(res, 200, record);
806
+ await driver.stop(id);
807
+ // Processes and exposed ports die with the container; sessions persist.
808
+ store.clearRuntimeState(id);
809
+ record.status = "stopped";
810
+ store.add(record); // write-through the status change
811
+ sendJson(res, 200, record);
812
+ }
813
+ async function startSandbox(res, { driver, store }, id) {
814
+ const record = store.get(id);
815
+ if (!record)
816
+ return sendJson(res, 404, { error: "not found" });
817
+ if (record.status === "running")
818
+ return sendJson(res, 200, record);
819
+ await resumeSandbox(driver, store, record);
820
+ sendJson(res, 200, record);
821
+ }
822
+ /**
823
+ * Manual fast-pause: the strongest pause the sandbox's driver offers (memory
824
+ * snapshot on microVM drivers — background processes come back alive; cold
825
+ * stop-with-volume otherwise). Unlike `stop`, any later operation transparently
826
+ * resumes the sandbox.
827
+ */
828
+ async function pauseSandboxRoute(res, { driver, store }, id) {
829
+ const record = store.get(id);
830
+ if (!record)
831
+ return sendJson(res, 404, { error: "not found" });
832
+ if (record.status === "paused")
833
+ return sendJson(res, 200, record);
834
+ if (record.status === "stopped") {
835
+ return sendJson(res, 409, { error: "sandbox is stopped; start it first" });
836
+ }
837
+ await pauseSandbox(driver, store, record);
838
+ sendJson(res, 200, record);
839
+ }
840
+ /**
841
+ * Resolve a sandbox that must be live to do work. Records the activity (so the
842
+ * idle reaper sees recent use), transparently resumes a `paused` sandbox, and
843
+ * rejects a manually `stopped` one (409 — the user must `start` it). Sends the
844
+ * error response itself and returns null on failure.
845
+ */
846
+ async function ensureLive(res, driver, store, id) {
847
+ const record = store.get(id);
848
+ if (!record) {
849
+ sendJson(res, 404, { error: "not found" });
850
+ return null;
851
+ }
852
+ if (record.status === "stopped") {
853
+ sendJson(res, 409, { error: "sandbox is stopped; start it first" });
854
+ return null;
855
+ }
856
+ if (record.status === "paused") {
857
+ await resumeSandbox(driver, store, record);
858
+ }
859
+ else {
860
+ store.touch(id);
861
+ }
862
+ return record;
863
+ }
864
+ /**
865
+ * Resource metrics + cost for a sandbox. A live snapshot is included only when
866
+ * the sandbox is running (a paused/stopped sandbox has no container); the
867
+ * accumulated usage and cost are always returned. Reading metrics is a passive
868
+ * query — it does not count as activity nor auto-resume a paused sandbox.
869
+ */
870
+ async function getMetrics(res, { config, driver, store }, id, includeLive = true) {
871
+ const record = store.get(id);
872
+ if (!record)
873
+ return sendJson(res, 404, { error: "not found" });
874
+ let live = null;
875
+ if (includeLive && record.status === "running") {
876
+ try {
877
+ live = await driver.stats(id);
878
+ }
879
+ catch {
880
+ // Container may have just gone away; report accumulated usage only.
881
+ }
882
+ }
883
+ return sendJson(res, 200, {
884
+ status: record.status,
885
+ live,
886
+ usage: record.usage,
887
+ cost: computeCost(record.usage, config),
888
+ });
889
+ }
890
+ async function createBackup(res, { driver, store, backups }, id) {
891
+ const record = await ensureLive(res, driver, store, id);
892
+ if (!record)
893
+ return;
894
+ const backupId = SandboxStore.newBackupId();
895
+ await backups.ensureDir();
896
+ const { bytes } = await driver.createBackup(id, backups.tarPath(backupId));
897
+ const info = {
898
+ backupId,
899
+ sandboxId: id,
900
+ createdAt: new Date().toISOString(),
901
+ bytes,
902
+ };
903
+ await backups.save(info);
904
+ sendJson(res, 201, info);
905
+ }
906
+ async function restoreBackup(res, { driver, store, backups }, id, body) {
907
+ if (!store.get(id))
908
+ return sendJson(res, 404, { error: "not found" });
909
+ const backupId = body.backupId;
910
+ if (typeof backupId !== "string") {
911
+ return sendJson(res, 400, { error: "backupId is required" });
912
+ }
913
+ const info = await backups.get(backupId);
914
+ if (!info)
915
+ return sendJson(res, 404, { error: "backup not found" });
916
+ if (!(await ensureLive(res, driver, store, id)))
917
+ return;
918
+ await driver.restoreBackup(id, backups.tarPath(backupId));
919
+ sendJson(res, 200, { id, restored: backupId });
920
+ }
921
+ // --- code interpreter ------------------------------------------------------
922
+ /** Run a command, accumulating its streams; never throws on non-zero exit. */
923
+ async function execCapture(driver, id, command) {
924
+ let stdout = "";
925
+ let stderr = "";
926
+ let exitCode = 0;
927
+ await driver.exec(id, command, {}, (e) => {
928
+ if (e.type === "stdout")
929
+ stdout += e.data;
930
+ else if (e.type === "stderr")
931
+ stderr += e.data;
932
+ else
933
+ exitCode = e.exitCode;
934
+ });
935
+ return { stdout, stderr, exitCode };
936
+ }
937
+ function parseLanguage(value) {
938
+ return value === "python" || value === "javascript" ? value : undefined;
939
+ }
940
+ /** Provision a kernel: make the context dir + fifos, write it, start it. */
941
+ async function startKernel(driver, store, id, language) {
942
+ const contextId = SandboxStore.newContextId();
943
+ const dir = `/workspace/.sbx/ctx-${contextId}`;
944
+ const setup = await execCapture(driver, id, `mkdir -p ${shellEscape(dir)} && mkfifo ${shellEscape(dir)}/in.fifo ${shellEscape(dir)}/out.fifo`);
945
+ if (setup.exitCode !== 0) {
946
+ throw new Error(`failed to set up code context: ${setup.stderr.trim()}`);
947
+ }
948
+ const kernel = kernelFor(language);
949
+ await driver.writeFile(id, { path: `${dir}/${kernel.filename}`, content: kernel.source });
950
+ const procId = SandboxStore.newProcId();
951
+ const { pid } = await driver.startProcess(id, procId, kernel.command(dir), {});
952
+ const ctx = {
953
+ contextId,
954
+ language,
955
+ dir,
956
+ procId,
957
+ pid,
958
+ seq: 0,
959
+ createdAt: new Date().toISOString(),
960
+ };
961
+ store.addContext(id, ctx);
962
+ return ctx;
963
+ }
964
+ /** Kill a context's kernel and remove its directory. */
965
+ async function teardownKernel(driver, id, ctx) {
966
+ try {
967
+ await driver.killProcess(id, ctx.pid, "KILL");
968
+ }
969
+ catch {
970
+ // Kernel may already be gone; the rm below still cleans up.
971
+ }
972
+ await execCapture(driver, id, `rm -rf ${shellEscape(ctx.dir)}`);
973
+ }
974
+ async function createCodeContext(res, { driver, store }, id, body) {
975
+ if (!(await ensureLive(res, driver, store, id)))
976
+ return;
977
+ const language = parseLanguage(body.language ?? "python");
978
+ if (!language) {
979
+ return sendJson(res, 400, { error: "language must be 'python' or 'javascript'" });
980
+ }
981
+ const ctx = await startKernel(driver, store, id, language);
982
+ sendJson(res, 201, publicContext(ctx));
983
+ }
984
+ async function deleteCodeContext(res, { driver, store }, id, contextId) {
985
+ const ctx = store.getContext(id, contextId);
986
+ if (!ctx)
987
+ return sendJson(res, 404, { error: "context not found" });
988
+ await teardownKernel(driver, id, ctx);
989
+ store.removeContext(id, contextId);
990
+ sendJson(res, 200, { contextId, destroyed: true });
991
+ }
992
+ async function runCode(res, { driver, store }, id, body) {
993
+ if (!(await ensureLive(res, driver, store, id)))
994
+ return;
995
+ const code = body.code;
996
+ if (typeof code !== "string") {
997
+ return sendJson(res, 400, { error: "code is required" });
998
+ }
999
+ const timeoutMs = typeof body.timeoutMs === "number" ? body.timeoutMs : 30_000;
1000
+ // Resolve the context: an explicit one (persistent), or a throwaway kernel
1001
+ // for a one-off run.
1002
+ let ctx;
1003
+ let ephemeral = false;
1004
+ if (typeof body.contextId === "string") {
1005
+ const found = store.getContext(id, body.contextId);
1006
+ if (!found)
1007
+ return sendJson(res, 404, { error: "context not found" });
1008
+ ctx = found;
1009
+ }
1010
+ else {
1011
+ const language = parseLanguage(body.language ?? "python");
1012
+ if (!language) {
1013
+ return sendJson(res, 400, { error: "language must be 'python' or 'javascript'" });
1014
+ }
1015
+ ctx = await startKernel(driver, store, id, language);
1016
+ ephemeral = true;
1017
+ }
1018
+ try {
1019
+ const result = await runCell(driver, id, ctx, code, timeoutMs);
1020
+ if (!ephemeral)
1021
+ store.addContext(id, ctx); // write-through the bumped seq
1022
+ sendJson(res, 200, result);
1023
+ }
1024
+ finally {
1025
+ if (ephemeral) {
1026
+ await teardownKernel(driver, id, ctx);
1027
+ store.removeContext(id, ctx.contextId);
1028
+ }
1029
+ }
1030
+ }
1031
+ /** Drive one cell through the kernel's fifo handshake and read its result. */
1032
+ async function runCell(driver, id, ctx, code, timeoutMs) {
1033
+ const seq = ++ctx.seq;
1034
+ await driver.writeFile(id, { path: `${ctx.dir}/cell-${seq}.code`, content: code });
1035
+ const seconds = Math.max(1, Math.ceil(timeoutMs / 1000));
1036
+ // Push the seq to the kernel and block until it signals completion. If the
1037
+ // kernel is wedged or the cell runs too long, `timeout` aborts the handshake.
1038
+ const handshake = await execCapture(driver, id, `timeout ${seconds} sh -c 'printf "%s" "${seq}" > ${ctx.dir}/in.fifo && read _ < ${ctx.dir}/out.fifo'`);
1039
+ if (handshake.exitCode === 124 || handshake.exitCode === 137) {
1040
+ return {
1041
+ stdout: "",
1042
+ stderr: "",
1043
+ results: [],
1044
+ error: `code execution timed out after ${seconds}s`,
1045
+ };
1046
+ }
1047
+ const raw = await driver.readFile(id, { path: `${ctx.dir}/cell-${seq}.result.json` });
1048
+ return JSON.parse(raw);
1049
+ }
1050
+ function publicContext(ctx) {
1051
+ return {
1052
+ contextId: ctx.contextId,
1053
+ language: ctx.language,
1054
+ createdAt: ctx.createdAt,
1055
+ };
1056
+ }
1057
+ async function execInSandbox(res, { driver, store }, id, body) {
1058
+ const record = await ensureLive(res, driver, store, id);
1059
+ if (!record)
1060
+ return;
1061
+ const command = body.command;
1062
+ if (typeof command !== "string" || command.length === 0) {
1063
+ return sendJson(res, 400, { error: "command is required" });
1064
+ }
1065
+ // Resolve an optional session: it contributes a working directory and an env
1066
+ // overlay, and its cwd follows `cd` across commands. Env precedence (lowest
1067
+ // to highest): sandbox -> session -> request.
1068
+ const requestEnv = body.env ?? {};
1069
+ const requestCwd = typeof body.cwd === "string" ? body.cwd : undefined;
1070
+ let session;
1071
+ if (typeof body.sessionId === "string") {
1072
+ session = store.getSession(id, body.sessionId);
1073
+ if (!session)
1074
+ return sendJson(res, 404, { error: "session not found" });
1075
+ }
1076
+ const env = { ...record.env, ...(session?.env ?? {}), ...requestEnv };
1077
+ const cwd = requestCwd ?? session?.cwd;
1078
+ // For a session, capture the working directory the command leaves behind so a
1079
+ // later `cd` persists. We append a `pwd` write that runs in the same shell as
1080
+ // the user command (newline-delimited so a trailing comment can't swallow it).
1081
+ let runCommand = command;
1082
+ const cwdFile = session ? `/tmp/hotcell-sess-${session.sessionId}.cwd` : null;
1083
+ if (cwdFile) {
1084
+ runCommand =
1085
+ `{\n${command}\n}\n__hc_rc=$?\n` +
1086
+ `pwd > ${cwdFile} 2>/dev/null\nexit $__hc_rc`;
1087
+ }
1088
+ // Stream output as Server-Sent Events.
1089
+ res.writeHead(200, {
1090
+ "Content-Type": "text/event-stream",
1091
+ "Cache-Control": "no-cache",
1092
+ Connection: "keep-alive",
1093
+ });
1094
+ const write = (event) => {
1095
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
1096
+ };
1097
+ try {
1098
+ await driver.exec(id, runCommand, { cwd, env }, write);
1099
+ if (session && cwdFile) {
1100
+ try {
1101
+ const next = (await driver.readFile(id, { path: cwdFile })).trim();
1102
+ if (next && next !== session.cwd) {
1103
+ session.cwd = next;
1104
+ store.addSession(id, session); // write-through the cwd change
1105
+ }
1106
+ }
1107
+ catch {
1108
+ // Best-effort cwd capture; keep the previous cwd on failure.
1109
+ }
1110
+ }
1111
+ }
1112
+ catch (err) {
1113
+ write({ type: "stderr", data: String(err) });
1114
+ write({ type: "exit", exitCode: 1 });
1115
+ }
1116
+ finally {
1117
+ res.end();
1118
+ }
1119
+ }
1120
+ async function watchFiles(req, res, { driver, store }, id, watchPath, intervalMs) {
1121
+ if (!(await ensureLive(res, driver, store, id)))
1122
+ return;
1123
+ res.writeHead(200, {
1124
+ "Content-Type": "text/event-stream",
1125
+ "Cache-Control": "no-cache",
1126
+ Connection: "keep-alive",
1127
+ });
1128
+ // Flush headers immediately with an SSE comment so clients (e.g. fetch) get
1129
+ // the response before the first change event — which may be far in the future.
1130
+ res.write(": watching\n\n");
1131
+ // Stop the watcher when the client disconnects.
1132
+ const controller = new AbortController();
1133
+ req.on("close", () => controller.abort());
1134
+ try {
1135
+ await driver.watchFiles(id, watchPath, { intervalMs, signal: controller.signal }, (event) => res.write(`data: ${JSON.stringify(event)}\n\n`));
1136
+ }
1137
+ catch (err) {
1138
+ res.write(`data: ${JSON.stringify({ type: "error", error: String(err) })}\n\n`);
1139
+ }
1140
+ finally {
1141
+ res.end();
1142
+ }
1143
+ }
1144
+ async function handleFileAction(res, { driver, store }, id, action, body) {
1145
+ if (!(await ensureLive(res, driver, store, id)))
1146
+ return;
1147
+ const path = typeof body.path === "string" ? body.path : "";
1148
+ if (!path)
1149
+ return sendJson(res, 400, { error: "path is required" });
1150
+ try {
1151
+ switch (action) {
1152
+ case "write": {
1153
+ if (typeof body.content !== "string") {
1154
+ return sendJson(res, 400, { error: "content is required" });
1155
+ }
1156
+ await driver.writeFile(id, {
1157
+ path,
1158
+ content: body.content,
1159
+ mode: typeof body.mode === "string" ? body.mode : undefined,
1160
+ });
1161
+ return sendJson(res, 200, { ok: true });
1162
+ }
1163
+ case "read": {
1164
+ const content = await driver.readFile(id, { path });
1165
+ return sendJson(res, 200, { content });
1166
+ }
1167
+ case "mkdir": {
1168
+ await driver.mkdir(id, { path, parents: body.parents === true });
1169
+ return sendJson(res, 200, { ok: true });
1170
+ }
1171
+ case "list": {
1172
+ const entries = await driver.listFiles(id, { path });
1173
+ return sendJson(res, 200, { entries });
1174
+ }
1175
+ }
1176
+ }
1177
+ catch (err) {
1178
+ const message = err instanceof Error ? err.message : String(err);
1179
+ return sendJson(res, 500, { error: message });
1180
+ }
1181
+ }
1182
+ async function startProcess(res, { driver, store }, id, body) {
1183
+ const record = await ensureLive(res, driver, store, id);
1184
+ if (!record)
1185
+ return;
1186
+ const command = body.command;
1187
+ if (typeof command !== "string" || command.length === 0) {
1188
+ return sendJson(res, 400, { error: "command is required" });
1189
+ }
1190
+ const procId = SandboxStore.newProcId();
1191
+ const opts = {
1192
+ cwd: typeof body.cwd === "string" ? body.cwd : undefined,
1193
+ env: { ...record.env, ...(body.env ?? {}) },
1194
+ };
1195
+ try {
1196
+ const { pid, logPath } = await driver.startProcess(id, procId, command, opts);
1197
+ const proc = {
1198
+ procId,
1199
+ pid,
1200
+ command,
1201
+ status: "running",
1202
+ exitCode: null,
1203
+ startedAt: new Date().toISOString(),
1204
+ logPath,
1205
+ };
1206
+ store.addProcess(id, proc);
1207
+ return sendJson(res, 201, proc);
1208
+ }
1209
+ catch (err) {
1210
+ return sendJson(res, 500, { error: errorMessage(err) });
1211
+ }
1212
+ }
1213
+ async function listProcesses(res, { driver, store }, id) {
1214
+ if (!store.get(id))
1215
+ return sendJson(res, 404, { error: "not found" });
1216
+ const procs = store.listProcesses(id);
1217
+ if (procs.length > 0) {
1218
+ try {
1219
+ const liveness = await driver.listProcesses(id, procs.map((p) => ({ procId: p.procId, pid: p.pid })));
1220
+ const running = new Map(liveness.map((l) => [l.procId, l.running]));
1221
+ for (const p of procs) {
1222
+ if (p.status === "running" && running.get(p.procId) === false) {
1223
+ p.status = "exited";
1224
+ store.addProcess(id, p); // write-through the exited status
1225
+ }
1226
+ }
1227
+ }
1228
+ catch {
1229
+ // Liveness refresh is best-effort; fall back to last-known status.
1230
+ }
1231
+ }
1232
+ return sendJson(res, 200, { processes: store.listProcesses(id) });
1233
+ }
1234
+ async function killProcess(res, { driver, store }, id, procId, body) {
1235
+ if (!store.get(id))
1236
+ return sendJson(res, 404, { error: "not found" });
1237
+ const proc = store.getProcess(id, procId);
1238
+ if (!proc)
1239
+ return sendJson(res, 404, { error: "process not found" });
1240
+ const signal = typeof body.signal === "string" ? body.signal : undefined;
1241
+ try {
1242
+ await driver.killProcess(id, proc.pid, signal);
1243
+ proc.status = "exited";
1244
+ store.addProcess(id, proc); // write-through the exited status
1245
+ return sendJson(res, 200, { ok: true });
1246
+ }
1247
+ catch (err) {
1248
+ return sendJson(res, 500, { error: errorMessage(err) });
1249
+ }
1250
+ }
1251
+ async function streamLogs(req, res, { driver, store }, id, procId, follow) {
1252
+ if (!store.get(id))
1253
+ return sendJson(res, 404, { error: "not found" });
1254
+ const proc = store.getProcess(id, procId);
1255
+ if (!proc)
1256
+ return sendJson(res, 404, { error: "process not found" });
1257
+ res.writeHead(200, {
1258
+ "Content-Type": "text/event-stream",
1259
+ "Cache-Control": "no-cache",
1260
+ Connection: "keep-alive",
1261
+ });
1262
+ const controller = new AbortController();
1263
+ req.on("close", () => controller.abort());
1264
+ try {
1265
+ await driver.streamProcessLogs(id, proc.logPath, { follow, signal: controller.signal }, (data) => res.write(`data: ${JSON.stringify({ type: "log", data })}\n\n`));
1266
+ }
1267
+ catch (err) {
1268
+ res.write(`data: ${JSON.stringify({ type: "log", data: errorMessage(err) })}\n\n`);
1269
+ }
1270
+ finally {
1271
+ res.write(`data: ${JSON.stringify({ type: "end" })}\n\n`);
1272
+ res.end();
1273
+ }
1274
+ }
1275
+ async function waitForPort(res, { driver, store }, id, body) {
1276
+ if (!(await ensureLive(res, driver, store, id)))
1277
+ return;
1278
+ const port = Number(body.port);
1279
+ if (!Number.isInteger(port) || port <= 0) {
1280
+ return sendJson(res, 400, { error: "port is required" });
1281
+ }
1282
+ try {
1283
+ const ready = await driver.waitForPort(id, port, {
1284
+ timeoutMs: typeof body.timeoutMs === "number" ? body.timeoutMs : undefined,
1285
+ intervalMs: typeof body.intervalMs === "number" ? body.intervalMs : undefined,
1286
+ host: typeof body.host === "string" ? body.host : undefined,
1287
+ });
1288
+ return sendJson(res, 200, { ready });
1289
+ }
1290
+ catch (err) {
1291
+ return sendJson(res, 500, { error: errorMessage(err) });
1292
+ }
1293
+ }
1294
+ async function exposePort(res, { config, driver, store }, id, body) {
1295
+ if (!(await ensureLive(res, driver, store, id)))
1296
+ return;
1297
+ const port = Number(body.port);
1298
+ if (!Number.isInteger(port) || port <= 0) {
1299
+ return sendJson(res, 400, { error: "port is required" });
1300
+ }
1301
+ const exposed = {
1302
+ port,
1303
+ exposeId: `${id}-${port}`,
1304
+ token: typeof body.token === "string" ? body.token : null,
1305
+ createdAt: new Date().toISOString(),
1306
+ url: previewUrl(config, id, port),
1307
+ };
1308
+ store.addExposed(id, exposed);
1309
+ return sendJson(res, 201, exposed);
1310
+ }
1311
+ function unexposePort(res, { store }, id, port) {
1312
+ if (!store.get(id))
1313
+ return sendJson(res, 404, { error: "not found" });
1314
+ const removed = store.removeExposed(id, port);
1315
+ if (!removed)
1316
+ return sendJson(res, 404, { error: "port not exposed" });
1317
+ return sendJson(res, 200, { ok: true });
1318
+ }
1319
+ function createSession(res, { store }, id, body) {
1320
+ if (!store.get(id))
1321
+ return sendJson(res, 404, { error: "not found" });
1322
+ const sessionId = typeof body.id === "string" && body.id.length > 0
1323
+ ? body.id
1324
+ : SandboxStore.newSessionId();
1325
+ if (store.getSession(id, sessionId)) {
1326
+ return sendJson(res, 409, { error: "session already exists" });
1327
+ }
1328
+ const session = {
1329
+ sessionId,
1330
+ cwd: typeof body.cwd === "string" ? body.cwd : "/workspace",
1331
+ env: body.env ?? {},
1332
+ createdAt: new Date().toISOString(),
1333
+ };
1334
+ store.addSession(id, session);
1335
+ return sendJson(res, 201, session);
1336
+ }
1337
+ function setSessionEnv(res, { store }, id, sessionId, body) {
1338
+ if (!store.get(id))
1339
+ return sendJson(res, 404, { error: "not found" });
1340
+ const session = store.getSession(id, sessionId);
1341
+ if (!session)
1342
+ return sendJson(res, 404, { error: "session not found" });
1343
+ const env = body.env ?? {};
1344
+ session.env = { ...session.env, ...env };
1345
+ store.addSession(id, session); // write-through the env change
1346
+ return sendJson(res, 200, session);
1347
+ }
1348
+ function deleteSession(res, { store }, id, sessionId) {
1349
+ if (!store.get(id))
1350
+ return sendJson(res, 404, { error: "not found" });
1351
+ const removed = store.removeSession(id, sessionId);
1352
+ if (!removed)
1353
+ return sendJson(res, 404, { error: "session not found" });
1354
+ return sendJson(res, 200, { ok: true });
1355
+ }
1356
+ // --- helpers ---------------------------------------------------------------
1357
+ async function readJson(req, maxBytes) {
1358
+ const body = await readBodyCapped(req, maxBytes);
1359
+ if (body.length === 0)
1360
+ return {};
1361
+ try {
1362
+ return JSON.parse(body.toString("utf8"));
1363
+ }
1364
+ catch {
1365
+ return {};
1366
+ }
1367
+ }
1368
+ //# sourceMappingURL=server.js.map