@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.
- package/dist/agent-check.d.ts +1 -0
- package/dist/agent-check.js +105 -0
- package/dist/agent-check.js.map +1 -0
- package/dist/api/server.d.ts +64 -0
- package/dist/api/server.js +1368 -0
- package/dist/api/server.js.map +1 -0
- package/dist/api/ws.d.ts +14 -0
- package/dist/api/ws.js +214 -0
- package/dist/api/ws.js.map +1 -0
- package/dist/applevz-check.d.ts +1 -0
- package/dist/applevz-check.js +280 -0
- package/dist/applevz-check.js.map +1 -0
- package/dist/backups.d.ts +29 -0
- package/dist/backups.js +76 -0
- package/dist/backups.js.map +1 -0
- package/dist/capacity.d.ts +63 -0
- package/dist/capacity.js +95 -0
- package/dist/capacity.js.map +1 -0
- package/dist/config.d.ts +241 -0
- package/dist/config.js +152 -0
- package/dist/config.js.map +1 -0
- package/dist/cost.d.ts +9 -0
- package/dist/cost.js +19 -0
- package/dist/cost.js.map +1 -0
- package/dist/density-bench.d.ts +1 -0
- package/dist/density-bench.js +97 -0
- package/dist/density-bench.js.map +1 -0
- package/dist/driver/agent-driver.d.ts +67 -0
- package/dist/driver/agent-driver.js +197 -0
- package/dist/driver/agent-driver.js.map +1 -0
- package/dist/driver/agent.d.ts +94 -0
- package/dist/driver/agent.js +319 -0
- package/dist/driver/agent.js.map +1 -0
- package/dist/driver/applevz.d.ts +95 -0
- package/dist/driver/applevz.js +527 -0
- package/dist/driver/applevz.js.map +1 -0
- package/dist/driver/container.d.ts +99 -0
- package/dist/driver/container.js +733 -0
- package/dist/driver/container.js.map +1 -0
- package/dist/driver/fc-api.d.ts +85 -0
- package/dist/driver/fc-api.js +173 -0
- package/dist/driver/fc-api.js.map +1 -0
- package/dist/driver/firecracker.d.ts +126 -0
- package/dist/driver/firecracker.js +560 -0
- package/dist/driver/firecracker.js.map +1 -0
- package/dist/driver/index.d.ts +17 -0
- package/dist/driver/index.js +61 -0
- package/dist/driver/index.js.map +1 -0
- package/dist/driver/router.d.ts +82 -0
- package/dist/driver/router.js +159 -0
- package/dist/driver/router.js.map +1 -0
- package/dist/driver/types.d.ts +234 -0
- package/dist/driver/types.js +2 -0
- package/dist/driver/types.js.map +1 -0
- package/dist/driver/unsupported.d.ts +50 -0
- package/dist/driver/unsupported.js +81 -0
- package/dist/driver/unsupported.js.map +1 -0
- package/dist/driver/vz-image.d.ts +35 -0
- package/dist/driver/vz-image.js +75 -0
- package/dist/driver/vz-image.js.map +1 -0
- package/dist/e2e-harness.d.ts +1 -0
- package/dist/e2e-harness.js +99 -0
- package/dist/e2e-harness.js.map +1 -0
- package/dist/egress-check.d.ts +8 -0
- package/dist/egress-check.js +317 -0
- package/dist/egress-check.js.map +1 -0
- package/dist/fc-check.d.ts +1 -0
- package/dist/fc-check.js +113 -0
- package/dist/fc-check.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +114 -0
- package/dist/index.js.map +1 -0
- package/dist/kernels.d.ts +22 -0
- package/dist/kernels.js +134 -0
- package/dist/kernels.js.map +1 -0
- package/dist/lifecycle.d.ts +49 -0
- package/dist/lifecycle.js +111 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/logger.d.ts +16 -0
- package/dist/logger.js +67 -0
- package/dist/logger.js.map +1 -0
- package/dist/metrics.d.ts +46 -0
- package/dist/metrics.js +92 -0
- package/dist/metrics.js.map +1 -0
- package/dist/net/firewall.d.ts +4 -0
- package/dist/net/firewall.js +80 -0
- package/dist/net/firewall.js.map +1 -0
- package/dist/pricing.d.ts +36 -0
- package/dist/pricing.js +81 -0
- package/dist/pricing.js.map +1 -0
- package/dist/proxy/allowlist-default.d.ts +23 -0
- package/dist/proxy/allowlist-default.js +53 -0
- package/dist/proxy/allowlist-default.js.map +1 -0
- package/dist/proxy/allowlist.d.ts +43 -0
- package/dist/proxy/allowlist.js +87 -0
- package/dist/proxy/allowlist.js.map +1 -0
- package/dist/proxy/egress.d.ts +49 -0
- package/dist/proxy/egress.js +599 -0
- package/dist/proxy/egress.js.map +1 -0
- package/dist/proxy/ratelimit.d.ts +49 -0
- package/dist/proxy/ratelimit.js +74 -0
- package/dist/proxy/ratelimit.js.map +1 -0
- package/dist/proxy/server.d.ts +26 -0
- package/dist/proxy/server.js +202 -0
- package/dist/proxy/server.js.map +1 -0
- package/dist/router-check.d.ts +1 -0
- package/dist/router-check.js +97 -0
- package/dist/router-check.js.map +1 -0
- package/dist/smoke-fc.d.ts +1 -0
- package/dist/smoke-fc.js +183 -0
- package/dist/smoke-fc.js.map +1 -0
- package/dist/smoke-microvm.d.ts +1 -0
- package/dist/smoke-microvm.js +187 -0
- package/dist/smoke-microvm.js.map +1 -0
- package/dist/smoke-remote.d.ts +20 -0
- package/dist/smoke-remote.js +155 -0
- package/dist/smoke-remote.js.map +1 -0
- package/dist/smoke-vz.d.ts +1 -0
- package/dist/smoke-vz.js +161 -0
- package/dist/smoke-vz.js.map +1 -0
- package/dist/smoke.d.ts +7 -0
- package/dist/smoke.js +962 -0
- package/dist/smoke.js.map +1 -0
- package/dist/store.d.ts +124 -0
- package/dist/store.js +578 -0
- package/dist/store.js.map +1 -0
- package/dist/tracing.d.ts +45 -0
- package/dist/tracing.js +144 -0
- package/dist/tracing.js.map +1 -0
- package/dist/types.d.ts +274 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/util.d.ts +27 -0
- package/dist/util.js +63 -0
- package/dist/util.js.map +1 -0
- package/dist/warmpool-check.d.ts +1 -0
- package/dist/warmpool-check.js +83 -0
- package/dist/warmpool-check.js.map +1 -0
- package/dist/web/dashboard.d.ts +1 -0
- package/dist/web/dashboard.js +456 -0
- package/dist/web/dashboard.js.map +1 -0
- 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
|