@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
package/dist/smoke.js
ADDED
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 0 smoke test.
|
|
3
|
+
*
|
|
4
|
+
* Starts the daemon, creates a sandbox, runs a command, checks the output,
|
|
5
|
+
* destroys the sandbox, and exits with the command's status.
|
|
6
|
+
*/
|
|
7
|
+
import { createServer as httpCreateServer, request as httpRequest } from "node:http";
|
|
8
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { setTimeout } from "node:timers/promises";
|
|
12
|
+
import { BackupRegistry } from "./backups.js";
|
|
13
|
+
import { Capacity } from "./capacity.js";
|
|
14
|
+
import { createEgressProxy } from "./proxy/egress.js";
|
|
15
|
+
import { loadConfig } from "./config.js";
|
|
16
|
+
import { createDriver } from "./driver/index.js";
|
|
17
|
+
import { createApiServer } from "./api/server.js";
|
|
18
|
+
import { reapIdle } from "./lifecycle.js";
|
|
19
|
+
import { MetricsHistory, sampleUsage } from "./metrics.js";
|
|
20
|
+
import { createProxyServer } from "./proxy/server.js";
|
|
21
|
+
import { emptyUsage, SandboxStore } from "./store.js";
|
|
22
|
+
async function main() {
|
|
23
|
+
const config = loadConfig();
|
|
24
|
+
// Use a throwaway backup dir + SQLite file so the smoke run leaves nothing behind.
|
|
25
|
+
config.backupDir = await mkdtemp(join(tmpdir(), "hotcell-smoke-backups-"));
|
|
26
|
+
const dbDir = await mkdtemp(join(tmpdir(), "hotcell-smoke-db-"));
|
|
27
|
+
config.dbPath = join(dbDir, "state.db");
|
|
28
|
+
const driver = createDriver(config);
|
|
29
|
+
const store = new SandboxStore(config.dbPath);
|
|
30
|
+
const backups = new BackupRegistry(config.backupDir);
|
|
31
|
+
const history = new MetricsHistory(config.metricsHistory);
|
|
32
|
+
const server = createApiServer({ config, driver, store, backups, history });
|
|
33
|
+
await new Promise((resolve) => server.listen(config.port, config.host, resolve));
|
|
34
|
+
const endpoint = `http://${config.host}:${config.port}`;
|
|
35
|
+
const proxy = createProxyServer({ config, driver, store });
|
|
36
|
+
await new Promise((resolve) => proxy.listen(config.proxyPort, config.proxyHost, resolve));
|
|
37
|
+
const proxyEndpoint = `http://${config.proxyHost}:${config.proxyPort}`;
|
|
38
|
+
console.error(`[smoke] daemon up at ${endpoint}`);
|
|
39
|
+
try {
|
|
40
|
+
// Create sandbox with a declarative setup command that provisions a marker
|
|
41
|
+
// file (asserted below) — exercises the create-time `setup` path end-to-end.
|
|
42
|
+
const createRes = await fetch(`${endpoint}/sandboxes`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "content-type": "application/json" },
|
|
45
|
+
body: JSON.stringify({ setup: ["echo provisioned > /workspace/setup-marker.txt"] }),
|
|
46
|
+
});
|
|
47
|
+
if (!createRes.ok)
|
|
48
|
+
throw new Error(`create failed: ${createRes.status}`);
|
|
49
|
+
const { id } = (await createRes.json());
|
|
50
|
+
console.error(`[smoke] created sandbox ${id}`);
|
|
51
|
+
// Verify the setup command ran (best-effort, but should have succeeded here).
|
|
52
|
+
const markerRes = await fetch(`${endpoint}/sandboxes/${id}/files/read`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { "content-type": "application/json" },
|
|
55
|
+
body: JSON.stringify({ path: "/workspace/setup-marker.txt" }),
|
|
56
|
+
});
|
|
57
|
+
if (!markerRes.ok)
|
|
58
|
+
throw new Error(`setup marker read failed: ${markerRes.status}`);
|
|
59
|
+
const { content: marker } = (await markerRes.json());
|
|
60
|
+
if (marker.trim() !== "provisioned") {
|
|
61
|
+
throw new Error(`setup command did not run: marker="${marker}"`);
|
|
62
|
+
}
|
|
63
|
+
console.error("[smoke] setup command ran");
|
|
64
|
+
// Run command.
|
|
65
|
+
const execRes = await fetch(`${endpoint}/sandboxes/${id}/exec`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "content-type": "application/json" },
|
|
68
|
+
body: JSON.stringify({ command: "echo hello from smoke" }),
|
|
69
|
+
});
|
|
70
|
+
if (!execRes.ok || !execRes.body)
|
|
71
|
+
throw new Error(`exec failed: ${execRes.status}`);
|
|
72
|
+
let stdout = "";
|
|
73
|
+
let exitCode = -1;
|
|
74
|
+
for await (const event of parseSSE(execRes.body)) {
|
|
75
|
+
if (event.type === "stdout")
|
|
76
|
+
stdout += event.data;
|
|
77
|
+
else if (event.type === "exit")
|
|
78
|
+
exitCode = event.exitCode ?? 0;
|
|
79
|
+
}
|
|
80
|
+
console.log(stdout.trim());
|
|
81
|
+
if (stdout.trim() !== "hello from smoke") {
|
|
82
|
+
throw new Error(`unexpected output: ${stdout}`);
|
|
83
|
+
}
|
|
84
|
+
if (exitCode !== 0) {
|
|
85
|
+
throw new Error(`non-zero exit: ${exitCode}`);
|
|
86
|
+
}
|
|
87
|
+
// File operations.
|
|
88
|
+
const writeRes = await fetch(`${endpoint}/sandboxes/${id}/files/write`, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "content-type": "application/json" },
|
|
91
|
+
body: JSON.stringify({ path: "/workspace/smoke.txt", content: "file ops work" }),
|
|
92
|
+
});
|
|
93
|
+
if (!writeRes.ok)
|
|
94
|
+
throw new Error(`write failed: ${writeRes.status}`);
|
|
95
|
+
console.error("[smoke] wrote file");
|
|
96
|
+
const readRes = await fetch(`${endpoint}/sandboxes/${id}/files/read`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "content-type": "application/json" },
|
|
99
|
+
body: JSON.stringify({ path: "/workspace/smoke.txt" }),
|
|
100
|
+
});
|
|
101
|
+
if (!readRes.ok)
|
|
102
|
+
throw new Error(`read failed: ${readRes.status}`);
|
|
103
|
+
const { content } = (await readRes.json());
|
|
104
|
+
if (content !== "file ops work") {
|
|
105
|
+
throw new Error(`unexpected file content: ${content}`);
|
|
106
|
+
}
|
|
107
|
+
console.error("[smoke] read file");
|
|
108
|
+
const mkdirRes = await fetch(`${endpoint}/sandboxes/${id}/files/mkdir`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "content-type": "application/json" },
|
|
111
|
+
body: JSON.stringify({ path: "/workspace/nested/dir", parents: true }),
|
|
112
|
+
});
|
|
113
|
+
if (!mkdirRes.ok)
|
|
114
|
+
throw new Error(`mkdir failed: ${mkdirRes.status}`);
|
|
115
|
+
console.error("[smoke] created directory");
|
|
116
|
+
const listRes = await fetch(`${endpoint}/sandboxes/${id}/files/list`, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: { "content-type": "application/json" },
|
|
119
|
+
body: JSON.stringify({ path: "/workspace" }),
|
|
120
|
+
});
|
|
121
|
+
if (!listRes.ok)
|
|
122
|
+
throw new Error(`list failed: ${listRes.status}`);
|
|
123
|
+
const { entries } = (await listRes.json());
|
|
124
|
+
const names = entries.map((e) => e.name).sort();
|
|
125
|
+
if (!names.includes("smoke.txt") || !names.includes("nested")) {
|
|
126
|
+
throw new Error(`unexpected directory listing: ${names.join(", ")}`);
|
|
127
|
+
}
|
|
128
|
+
console.error("[smoke] listed files");
|
|
129
|
+
// Background process + port readiness + preview proxy.
|
|
130
|
+
const startRes = await fetch(`${endpoint}/sandboxes/${id}/processes`, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { "content-type": "application/json" },
|
|
133
|
+
body: JSON.stringify({ command: "python3 -m http.server 8000" }),
|
|
134
|
+
});
|
|
135
|
+
if (!startRes.ok)
|
|
136
|
+
throw new Error(`startProcess failed: ${startRes.status}`);
|
|
137
|
+
const proc = (await startRes.json());
|
|
138
|
+
console.error(`[smoke] started process ${proc.procId}`);
|
|
139
|
+
const waitRes = await fetch(`${endpoint}/sandboxes/${id}/wait-port`, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: { "content-type": "application/json" },
|
|
142
|
+
body: JSON.stringify({ port: 8000, timeoutMs: 15000 }),
|
|
143
|
+
});
|
|
144
|
+
const { ready } = (await waitRes.json());
|
|
145
|
+
if (!ready)
|
|
146
|
+
throw new Error("port 8000 never became ready");
|
|
147
|
+
console.error("[smoke] port 8000 ready");
|
|
148
|
+
const exposeRes = await fetch(`${endpoint}/sandboxes/${id}/expose`, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: { "content-type": "application/json" },
|
|
151
|
+
body: JSON.stringify({ port: 8000 }),
|
|
152
|
+
});
|
|
153
|
+
if (!exposeRes.ok)
|
|
154
|
+
throw new Error(`expose failed: ${exposeRes.status}`);
|
|
155
|
+
console.error("[smoke] exposed port 8000");
|
|
156
|
+
// Reach the in-sandbox server through the preview proxy (path-based route).
|
|
157
|
+
const previewRes = await fetch(`${proxyEndpoint}/_hotcell/${id}/8000/`);
|
|
158
|
+
const previewBody = await previewRes.text();
|
|
159
|
+
if (!previewBody.includes("Directory listing")) {
|
|
160
|
+
throw new Error(`preview proxy did not serve the sandbox: ${previewBody.slice(0, 80)}`);
|
|
161
|
+
}
|
|
162
|
+
console.error("[smoke] preview proxy served the sandbox");
|
|
163
|
+
// Preview token is exact + case-sensitive: re-expose port 8000 with a token
|
|
164
|
+
// (server still alive here), then a wrong-case token is 403 and the exact
|
|
165
|
+
// token serves the sandbox. Uses node:http to avoid fetch socket pooling.
|
|
166
|
+
await fetch(`${endpoint}/sandboxes/${id}/expose`, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: { "content-type": "application/json" },
|
|
169
|
+
body: JSON.stringify({ port: 8000, token: "AbC123" }),
|
|
170
|
+
});
|
|
171
|
+
const wrongTok = await httpGet(config.proxyHost, config.proxyPort, `/_hotcell/${id}/8000/?token=abc123`);
|
|
172
|
+
if (wrongTok.status !== 403)
|
|
173
|
+
throw new Error(`wrong-case token should 403, got ${wrongTok.status}`);
|
|
174
|
+
const rightTok = await httpGet(config.proxyHost, config.proxyPort, `/_hotcell/${id}/8000/?token=AbC123`);
|
|
175
|
+
if (!rightTok.body.includes("Directory listing")) {
|
|
176
|
+
throw new Error(`exact token should pass, got ${rightTok.status}`);
|
|
177
|
+
}
|
|
178
|
+
// Drop the token again so the later (post-restart) proxy paths stay simple.
|
|
179
|
+
await fetch(`${endpoint}/sandboxes/${id}/expose`, {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: { "content-type": "application/json" },
|
|
182
|
+
body: JSON.stringify({ port: 8000 }),
|
|
183
|
+
});
|
|
184
|
+
console.error("[smoke] preview token is exact + case-sensitive (wrong case 403)");
|
|
185
|
+
// Sandbox env vars apply to subsequent commands.
|
|
186
|
+
const setEnvRes = await fetch(`${endpoint}/sandboxes/${id}/env`, {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: { "content-type": "application/json" },
|
|
189
|
+
body: JSON.stringify({ env: { SMOKE_VAR: "from-env" } }),
|
|
190
|
+
});
|
|
191
|
+
if (!setEnvRes.ok)
|
|
192
|
+
throw new Error(`setEnv failed: ${setEnvRes.status}`);
|
|
193
|
+
const envEcho = await execAndCapture(endpoint, id, "echo $SMOKE_VAR");
|
|
194
|
+
if (envEcho !== "from-env") {
|
|
195
|
+
throw new Error(`env var not applied: "${envEcho}"`);
|
|
196
|
+
}
|
|
197
|
+
console.error("[smoke] sandbox env var applied");
|
|
198
|
+
// Sessions: cwd follows `cd` and session env overlays the sandbox env.
|
|
199
|
+
const sessionRes = await fetch(`${endpoint}/sandboxes/${id}/sessions`, {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: { "content-type": "application/json" },
|
|
202
|
+
body: JSON.stringify({ env: { SESS_VAR: "from-session" } }),
|
|
203
|
+
});
|
|
204
|
+
if (!sessionRes.ok)
|
|
205
|
+
throw new Error(`createSession failed: ${sessionRes.status}`);
|
|
206
|
+
const { sessionId } = (await sessionRes.json());
|
|
207
|
+
await execAndCapture(endpoint, id, "cd /tmp", sessionId);
|
|
208
|
+
const sessPwd = await execAndCapture(endpoint, id, "pwd", sessionId);
|
|
209
|
+
if (sessPwd !== "/tmp") {
|
|
210
|
+
throw new Error(`session cwd did not persist: "${sessPwd}"`);
|
|
211
|
+
}
|
|
212
|
+
const sessEcho = await execAndCapture(endpoint, id, "echo $SESS_VAR", sessionId);
|
|
213
|
+
if (sessEcho !== "from-session") {
|
|
214
|
+
throw new Error(`session env var not applied: "${sessEcho}"`);
|
|
215
|
+
}
|
|
216
|
+
console.error("[smoke] session cwd + env persisted");
|
|
217
|
+
// Code interpreter: a stateful Python context (variables persist) plus a
|
|
218
|
+
// one-off run.
|
|
219
|
+
const ctxRes = await fetch(`${endpoint}/sandboxes/${id}/code-contexts`, {
|
|
220
|
+
method: "POST",
|
|
221
|
+
headers: { "content-type": "application/json" },
|
|
222
|
+
body: JSON.stringify({ language: "python" }),
|
|
223
|
+
});
|
|
224
|
+
if (!ctxRes.ok)
|
|
225
|
+
throw new Error(`createCodeContext failed: ${ctxRes.status}`);
|
|
226
|
+
const { contextId } = (await ctxRes.json());
|
|
227
|
+
console.error(`[smoke] created code context ${contextId}`);
|
|
228
|
+
await runCode(endpoint, id, "x = 41", contextId);
|
|
229
|
+
const exprResult = await runCode(endpoint, id, "x + 1", contextId);
|
|
230
|
+
if (exprResult.results[0]?.text !== "42") {
|
|
231
|
+
throw new Error(`stateful runCode wrong result: ${JSON.stringify(exprResult.results)}`);
|
|
232
|
+
}
|
|
233
|
+
const printResult = await runCode(endpoint, id, "print('hi from kernel')", contextId);
|
|
234
|
+
if (printResult.stdout.trim() !== "hi from kernel") {
|
|
235
|
+
throw new Error(`runCode stdout wrong: "${printResult.stdout}"`);
|
|
236
|
+
}
|
|
237
|
+
const errResult = await runCode(endpoint, id, "1/0", contextId);
|
|
238
|
+
if (!errResult.error || !errResult.error.includes("ZeroDivisionError")) {
|
|
239
|
+
throw new Error(`runCode did not capture error: ${JSON.stringify(errResult)}`);
|
|
240
|
+
}
|
|
241
|
+
console.error("[smoke] stateful python context works (vars persist, stdout, errors)");
|
|
242
|
+
// One-off run with no explicit context.
|
|
243
|
+
const oneOff = await runCode(endpoint, id, "print(6 * 7)");
|
|
244
|
+
if (oneOff.stdout.trim() !== "42") {
|
|
245
|
+
throw new Error(`one-off runCode wrong: "${oneOff.stdout}"`);
|
|
246
|
+
}
|
|
247
|
+
console.error("[smoke] one-off runCode works");
|
|
248
|
+
const delCtxRes = await fetch(`${endpoint}/sandboxes/${id}/code-contexts/${contextId}`, { method: "DELETE" });
|
|
249
|
+
if (!delCtxRes.ok)
|
|
250
|
+
throw new Error(`delete context failed: ${delCtxRes.status}`);
|
|
251
|
+
console.error("[smoke] destroyed code context");
|
|
252
|
+
// File watching: open the stream, create a file, expect a change event.
|
|
253
|
+
const watchAbort = new AbortController();
|
|
254
|
+
const watchRes = await fetch(`${endpoint}/sandboxes/${id}/watch?path=/workspace&interval=300`, { signal: watchAbort.signal });
|
|
255
|
+
if (!watchRes.ok || !watchRes.body)
|
|
256
|
+
throw new Error(`watch failed: ${watchRes.status}`);
|
|
257
|
+
const sawFile = (async () => {
|
|
258
|
+
for await (const ev of parseSSE(watchRes.body)) {
|
|
259
|
+
if (ev.path?.endsWith("watched.txt")) {
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return false;
|
|
264
|
+
})();
|
|
265
|
+
sawFile.catch(() => false); // avoid an unhandled rejection on abort
|
|
266
|
+
// Let the watcher take its first snapshot, then create a file.
|
|
267
|
+
await setTimeout(600);
|
|
268
|
+
await fetch(`${endpoint}/sandboxes/${id}/files/write`, {
|
|
269
|
+
method: "POST",
|
|
270
|
+
headers: { "content-type": "application/json" },
|
|
271
|
+
body: JSON.stringify({ path: "/workspace/watched.txt", content: "watch me" }),
|
|
272
|
+
});
|
|
273
|
+
const detected = await Promise.race([
|
|
274
|
+
sawFile,
|
|
275
|
+
setTimeout(5000).then(() => false),
|
|
276
|
+
]);
|
|
277
|
+
watchAbort.abort();
|
|
278
|
+
if (!detected)
|
|
279
|
+
throw new Error("watch did not report the new file");
|
|
280
|
+
console.error("[smoke] watch reported the new file");
|
|
281
|
+
// Persistence: a file in /workspace survives a stop/start (container is
|
|
282
|
+
// recreated, the named volume is reattached).
|
|
283
|
+
await fetch(`${endpoint}/sandboxes/${id}/files/write`, {
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: { "content-type": "application/json" },
|
|
286
|
+
body: JSON.stringify({ path: "/workspace/persist.txt", content: "survives restart" }),
|
|
287
|
+
});
|
|
288
|
+
const stopRes = await fetch(`${endpoint}/sandboxes/${id}/stop`, { method: "POST" });
|
|
289
|
+
if (!stopRes.ok)
|
|
290
|
+
throw new Error(`stop failed: ${stopRes.status}`);
|
|
291
|
+
const stopped = (await stopRes.json());
|
|
292
|
+
if (stopped.status !== "stopped")
|
|
293
|
+
throw new Error(`expected stopped, got ${stopped.status}`);
|
|
294
|
+
console.error("[smoke] stopped sandbox");
|
|
295
|
+
const startSbRes = await fetch(`${endpoint}/sandboxes/${id}/start`, { method: "POST" });
|
|
296
|
+
if (!startSbRes.ok)
|
|
297
|
+
throw new Error(`start failed: ${startSbRes.status}`);
|
|
298
|
+
console.error("[smoke] started sandbox");
|
|
299
|
+
const persistRead = await fetch(`${endpoint}/sandboxes/${id}/files/read`, {
|
|
300
|
+
method: "POST",
|
|
301
|
+
headers: { "content-type": "application/json" },
|
|
302
|
+
body: JSON.stringify({ path: "/workspace/persist.txt" }),
|
|
303
|
+
});
|
|
304
|
+
if (!persistRead.ok)
|
|
305
|
+
throw new Error(`persist read failed: ${persistRead.status}`);
|
|
306
|
+
const { content: persisted } = (await persistRead.json());
|
|
307
|
+
if (persisted !== "survives restart") {
|
|
308
|
+
throw new Error(`workspace did not persist across restart: "${persisted}"`);
|
|
309
|
+
}
|
|
310
|
+
console.error("[smoke] workspace persisted across stop/start");
|
|
311
|
+
// Backup + restore: snapshot /workspace, mutate it, restore, confirm rollback.
|
|
312
|
+
await fetch(`${endpoint}/sandboxes/${id}/files/write`, {
|
|
313
|
+
method: "POST",
|
|
314
|
+
headers: { "content-type": "application/json" },
|
|
315
|
+
body: JSON.stringify({ path: "/workspace/backup.txt", content: "v1" }),
|
|
316
|
+
});
|
|
317
|
+
const backupRes = await fetch(`${endpoint}/sandboxes/${id}/backups`, { method: "POST" });
|
|
318
|
+
if (!backupRes.ok)
|
|
319
|
+
throw new Error(`backup failed: ${backupRes.status}`);
|
|
320
|
+
const { backupId } = (await backupRes.json());
|
|
321
|
+
console.error(`[smoke] created backup ${backupId}`);
|
|
322
|
+
// Mutate after the backup: change a file and add a new one.
|
|
323
|
+
await fetch(`${endpoint}/sandboxes/${id}/files/write`, {
|
|
324
|
+
method: "POST",
|
|
325
|
+
headers: { "content-type": "application/json" },
|
|
326
|
+
body: JSON.stringify({ path: "/workspace/backup.txt", content: "v2" }),
|
|
327
|
+
});
|
|
328
|
+
await fetch(`${endpoint}/sandboxes/${id}/files/write`, {
|
|
329
|
+
method: "POST",
|
|
330
|
+
headers: { "content-type": "application/json" },
|
|
331
|
+
body: JSON.stringify({ path: "/workspace/after-backup.txt", content: "transient" }),
|
|
332
|
+
});
|
|
333
|
+
const restoreRes = await fetch(`${endpoint}/sandboxes/${id}/restore`, {
|
|
334
|
+
method: "POST",
|
|
335
|
+
headers: { "content-type": "application/json" },
|
|
336
|
+
body: JSON.stringify({ backupId }),
|
|
337
|
+
});
|
|
338
|
+
if (!restoreRes.ok)
|
|
339
|
+
throw new Error(`restore failed: ${restoreRes.status}`);
|
|
340
|
+
console.error("[smoke] restored backup");
|
|
341
|
+
const rolledBack = await execAndCapture(endpoint, id, "cat /workspace/backup.txt");
|
|
342
|
+
if (rolledBack !== "v1") {
|
|
343
|
+
throw new Error(`restore did not roll back the file: "${rolledBack}"`);
|
|
344
|
+
}
|
|
345
|
+
// The file added after the backup must be gone (restore is a replacement).
|
|
346
|
+
const gone = await execAndCapture(endpoint, id, "test -e /workspace/after-backup.txt && echo present || echo gone");
|
|
347
|
+
if (gone !== "gone") {
|
|
348
|
+
throw new Error(`restore did not clear post-backup files: "${gone}"`);
|
|
349
|
+
}
|
|
350
|
+
console.error("[smoke] backup/restore rolled the workspace back");
|
|
351
|
+
// Durable state: a fresh store opened on the same SQLite file (as a daemon
|
|
352
|
+
// restart would) must rehydrate the sandbox record and its sessions.
|
|
353
|
+
const reopened = new SandboxStore(config.dbPath);
|
|
354
|
+
try {
|
|
355
|
+
const restored = reopened.get(id);
|
|
356
|
+
if (!restored)
|
|
357
|
+
throw new Error("sandbox record did not survive restart");
|
|
358
|
+
if (restored.status !== "running") {
|
|
359
|
+
throw new Error(`restored status was "${restored.status}", expected "running"`);
|
|
360
|
+
}
|
|
361
|
+
if (reopened.listSessions(id).length === 0) {
|
|
362
|
+
throw new Error("sessions did not survive restart");
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
reopened.close();
|
|
367
|
+
}
|
|
368
|
+
console.error("[smoke] control-plane state survived a simulated restart");
|
|
369
|
+
// Metrics + cost: run the sampler twice (advancing its clock) to integrate
|
|
370
|
+
// CPU-seconds and mem-byte-seconds, then read the metrics endpoint.
|
|
371
|
+
const t0 = Date.now();
|
|
372
|
+
await sampleUsage(driver, store, history, t0);
|
|
373
|
+
await sampleUsage(driver, store, history, t0 + 10_000);
|
|
374
|
+
const metrics = (await (await fetch(`${endpoint}/sandboxes/${id}/metrics`)).json());
|
|
375
|
+
if (!metrics.live)
|
|
376
|
+
throw new Error("metrics live snapshot missing for running sandbox");
|
|
377
|
+
if (!(metrics.usage.cpuSeconds > 0)) {
|
|
378
|
+
throw new Error(`expected cpuSeconds > 0, got ${metrics.usage.cpuSeconds}`);
|
|
379
|
+
}
|
|
380
|
+
if (!(metrics.usage.memByteSeconds > 0)) {
|
|
381
|
+
throw new Error(`expected memByteSeconds > 0, got ${metrics.usage.memByteSeconds}`);
|
|
382
|
+
}
|
|
383
|
+
// The earlier preview-proxy fetch should have metered egress bytes.
|
|
384
|
+
if (!(metrics.usage.egressBytes > 0)) {
|
|
385
|
+
throw new Error(`expected egressBytes > 0, got ${metrics.usage.egressBytes}`);
|
|
386
|
+
}
|
|
387
|
+
if (!(metrics.cost.total > 0)) {
|
|
388
|
+
throw new Error(`expected cost.total > 0, got ${metrics.cost.total}`);
|
|
389
|
+
}
|
|
390
|
+
console.error("[smoke] metrics + cost meter integrate CPU/mem usage");
|
|
391
|
+
// Metrics history: the two sampler ticks above recorded live samples that
|
|
392
|
+
// back the dashboard sparklines.
|
|
393
|
+
const histRes = await fetch(`${endpoint}/sandboxes/${id}/metrics/history`);
|
|
394
|
+
const hist = (await histRes.json());
|
|
395
|
+
if (!Array.isArray(hist.samples) || hist.samples.length < 2) {
|
|
396
|
+
throw new Error(`expected >=2 history samples, got ${hist.samples?.length}`);
|
|
397
|
+
}
|
|
398
|
+
console.error(`[smoke] metrics history recorded ${hist.samples.length} samples`);
|
|
399
|
+
// Tracing: every request opens a server span; recent ones are queryable.
|
|
400
|
+
const tracesRes = await fetch(`${endpoint}/traces`);
|
|
401
|
+
const traces = (await tracesRes.json());
|
|
402
|
+
if (!Array.isArray(traces.spans) || traces.spans.length === 0) {
|
|
403
|
+
throw new Error("expected recent spans in /traces, got none");
|
|
404
|
+
}
|
|
405
|
+
if (!traces.spans.some((s) => s.name.includes("/sandboxes/:id"))) {
|
|
406
|
+
throw new Error("expected a normalized /sandboxes/:id span in /traces");
|
|
407
|
+
}
|
|
408
|
+
console.error(`[smoke] tracing recorded ${traces.spans.length} spans`);
|
|
409
|
+
// API-key auth: a second server with a key set rejects keyless calls (401),
|
|
410
|
+
// accepts the right key, and leaves /info open so the dashboard can prompt.
|
|
411
|
+
const authedServer = createApiServer({
|
|
412
|
+
config: { ...config, apiKey: "smoke-secret" },
|
|
413
|
+
driver,
|
|
414
|
+
store,
|
|
415
|
+
backups,
|
|
416
|
+
history,
|
|
417
|
+
});
|
|
418
|
+
const authPort = config.port + 2;
|
|
419
|
+
await new Promise((resolve) => authedServer.listen(authPort, config.host, resolve));
|
|
420
|
+
const authBase = `http://${config.host}:${authPort}`;
|
|
421
|
+
try {
|
|
422
|
+
const noKey = await fetch(`${authBase}/sandboxes`);
|
|
423
|
+
if (noKey.status !== 401)
|
|
424
|
+
throw new Error(`keyless call should 401, got ${noKey.status}`);
|
|
425
|
+
const wrongKey = await fetch(`${authBase}/sandboxes`, {
|
|
426
|
+
headers: { authorization: "Bearer nope" },
|
|
427
|
+
});
|
|
428
|
+
if (wrongKey.status !== 401)
|
|
429
|
+
throw new Error(`wrong key should 401, got ${wrongKey.status}`);
|
|
430
|
+
const goodKey = await fetch(`${authBase}/sandboxes`, {
|
|
431
|
+
headers: { authorization: "Bearer smoke-secret" },
|
|
432
|
+
});
|
|
433
|
+
if (!goodKey.ok)
|
|
434
|
+
throw new Error(`valid key should pass, got ${goodKey.status}`);
|
|
435
|
+
const openInfo = await fetch(`${authBase}/info`);
|
|
436
|
+
if (!openInfo.ok)
|
|
437
|
+
throw new Error(`/info must stay open under auth, got ${openInfo.status}`);
|
|
438
|
+
const infoBody = (await openInfo.json());
|
|
439
|
+
if (infoBody.auth !== true)
|
|
440
|
+
throw new Error("/info.auth should be true when keyed");
|
|
441
|
+
}
|
|
442
|
+
finally {
|
|
443
|
+
await new Promise((resolve) => authedServer.close(() => resolve()));
|
|
444
|
+
}
|
|
445
|
+
console.error("[smoke] API-key auth gate enforces key (401) and keeps /info open");
|
|
446
|
+
// Egress credential proxy (LLM gateway): a mock provider upstream proves the
|
|
447
|
+
// daemon injects the real key (held host-side) and meters calls + tokens.
|
|
448
|
+
{
|
|
449
|
+
const REAL_KEY = "REAL-KEY-123";
|
|
450
|
+
const upstream = httpCreateServer((ureq, ures) => {
|
|
451
|
+
const auth = ureq.headers["authorization"] ?? "";
|
|
452
|
+
// Echo the auth header we received + report token usage (OpenAI shape).
|
|
453
|
+
const body = JSON.stringify({
|
|
454
|
+
ok: true,
|
|
455
|
+
receivedAuth: auth,
|
|
456
|
+
usage: { prompt_tokens: 11, completion_tokens: 7, cost: 0.0123 },
|
|
457
|
+
});
|
|
458
|
+
ures.writeHead(200, { "content-type": "application/json" });
|
|
459
|
+
ures.end(body);
|
|
460
|
+
});
|
|
461
|
+
await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
|
|
462
|
+
const upstreamPort = upstream.address().port;
|
|
463
|
+
const egressProxy = createEgressProxy({
|
|
464
|
+
config,
|
|
465
|
+
store,
|
|
466
|
+
providers: {
|
|
467
|
+
mock: {
|
|
468
|
+
baseUrl: `http://127.0.0.1:${upstreamPort}`,
|
|
469
|
+
authHeader: "authorization",
|
|
470
|
+
format: (k) => `Bearer ${k}`,
|
|
471
|
+
apiKey: REAL_KEY,
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
const egressPort = config.port + 5;
|
|
476
|
+
await new Promise((r) => egressProxy.listen(egressPort, "127.0.0.1", r));
|
|
477
|
+
try {
|
|
478
|
+
// Mint a per-sandbox egress token via REST.
|
|
479
|
+
const mint = (await (await fetch(`${endpoint}/sandboxes/${id}/egress-tokens`, { method: "POST" })).json());
|
|
480
|
+
if (!mint.token)
|
|
481
|
+
throw new Error("egress mint returned no token");
|
|
482
|
+
// Call the gateway with the token in place of the real provider key.
|
|
483
|
+
const call = (await (await fetch(`http://127.0.0.1:${egressPort}/mock/v1/chat`, {
|
|
484
|
+
method: "POST",
|
|
485
|
+
headers: { "content-type": "application/json", "x-hotcell-egress": mint.token },
|
|
486
|
+
body: JSON.stringify({ hello: "world" }),
|
|
487
|
+
})).json());
|
|
488
|
+
if (call.receivedAuth !== `Bearer ${REAL_KEY}`) {
|
|
489
|
+
throw new Error(`expected injected key, upstream saw: ${call.receivedAuth}`);
|
|
490
|
+
}
|
|
491
|
+
// An invalid token is rejected before reaching the provider.
|
|
492
|
+
const bad = await fetch(`http://127.0.0.1:${egressPort}/mock/v1/chat`, {
|
|
493
|
+
method: "POST",
|
|
494
|
+
headers: { "x-hotcell-egress": "nope" },
|
|
495
|
+
body: "{}",
|
|
496
|
+
});
|
|
497
|
+
if (bad.status !== 403)
|
|
498
|
+
throw new Error(`invalid token should 403, got ${bad.status}`);
|
|
499
|
+
// Metering reflects the call + parsed prompt/completion tokens.
|
|
500
|
+
const mu = (await (await fetch(`${endpoint}/sandboxes/${id}/metrics?live=0`)).json());
|
|
501
|
+
if (mu.usage.providerCalls !== 1) {
|
|
502
|
+
throw new Error(`expected 1 provider call, got ${mu.usage.providerCalls}`);
|
|
503
|
+
}
|
|
504
|
+
if (mu.usage.providerTokensIn !== 11 || mu.usage.providerTokensOut !== 7) {
|
|
505
|
+
throw new Error(`expected 11/7 tokens, got ${mu.usage.providerTokensIn}/${mu.usage.providerTokensOut}`);
|
|
506
|
+
}
|
|
507
|
+
// The provider-reported USD cost flows through to usage + the breakdown.
|
|
508
|
+
if (Math.abs(mu.usage.providerCost - 0.0123) > 1e-9) {
|
|
509
|
+
throw new Error(`expected providerCost 0.0123, got ${mu.usage.providerCost}`);
|
|
510
|
+
}
|
|
511
|
+
if (Math.abs(mu.cost.provider - 0.0123) > 1e-9) {
|
|
512
|
+
throw new Error(`expected cost.provider 0.0123, got ${mu.cost.provider}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
finally {
|
|
516
|
+
await new Promise((r) => egressProxy.close(() => r()));
|
|
517
|
+
await new Promise((r) => upstream.close(() => r()));
|
|
518
|
+
}
|
|
519
|
+
console.error("[smoke] egress proxy injects provider key + meters calls/tokens/cost");
|
|
520
|
+
}
|
|
521
|
+
// Auto-egress wiring: a sandbox created with {egress:true} gets the provider
|
|
522
|
+
// base-URL + key env vars injected (token in place of the real key). Configure
|
|
523
|
+
// a provider on the shared config so a route exists, then assert the env.
|
|
524
|
+
config.providerKeys = { ...config.providerKeys, openai: "sk-smoke" };
|
|
525
|
+
{
|
|
526
|
+
const egCreate = (await (await fetch(`${endpoint}/sandboxes`, {
|
|
527
|
+
method: "POST",
|
|
528
|
+
headers: { "content-type": "application/json" },
|
|
529
|
+
body: JSON.stringify({ egress: true }),
|
|
530
|
+
})).json());
|
|
531
|
+
try {
|
|
532
|
+
const baseUrl = await execAndCapture(endpoint, egCreate.id, "printenv OPENAI_BASE_URL");
|
|
533
|
+
const keyVal = await execAndCapture(endpoint, egCreate.id, "printenv OPENAI_API_KEY");
|
|
534
|
+
if (!baseUrl.endsWith("/openai")) {
|
|
535
|
+
throw new Error(`expected OPENAI_BASE_URL .../openai, got "${baseUrl}"`);
|
|
536
|
+
}
|
|
537
|
+
if (!keyVal.startsWith("hc-")) {
|
|
538
|
+
throw new Error(`expected OPENAI_API_KEY to be an egress token, got "${keyVal}"`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
finally {
|
|
542
|
+
await fetch(`${endpoint}/sandboxes/${egCreate.id}`, { method: "DELETE" });
|
|
543
|
+
}
|
|
544
|
+
console.error("[smoke] auto-egress wiring injects provider env into the sandbox");
|
|
545
|
+
}
|
|
546
|
+
// Per-sandbox resource limits: hard caps are applied + persisted, the cgroup
|
|
547
|
+
// reflects them, and they survive resume; daemon defaults apply when omitted.
|
|
548
|
+
{
|
|
549
|
+
const MIB = 1024 * 1024;
|
|
550
|
+
const created = (await (await fetch(`${endpoint}/sandboxes`, {
|
|
551
|
+
method: "POST",
|
|
552
|
+
headers: { "content-type": "application/json" },
|
|
553
|
+
body: JSON.stringify({ memoryMb: 256, cpus: 0.5, pidsLimit: 128 }),
|
|
554
|
+
})).json());
|
|
555
|
+
const limId = created.id;
|
|
556
|
+
try {
|
|
557
|
+
if (created.limits.memoryMb !== 256 ||
|
|
558
|
+
created.limits.cpus !== 0.5 ||
|
|
559
|
+
created.limits.pidsLimit !== 128) {
|
|
560
|
+
throw new Error(`limits not persisted on record: ${JSON.stringify(created.limits)}`);
|
|
561
|
+
}
|
|
562
|
+
const mem = await memLimitBytes(endpoint, limId);
|
|
563
|
+
if (mem !== 256 * MIB)
|
|
564
|
+
throw new Error(`expected 256 MiB cap, got ${mem}`);
|
|
565
|
+
// cgroup v2 reflects the caps (best-effort — tolerate v1 / missing files).
|
|
566
|
+
const pidsMax = await execAndCapture(endpoint, limId, "cat /sys/fs/cgroup/pids.max 2>/dev/null || echo NA");
|
|
567
|
+
if (pidsMax !== "NA" && pidsMax !== "128")
|
|
568
|
+
throw new Error(`pids.max expected 128, got ${pidsMax}`);
|
|
569
|
+
const cpuMax = await execAndCapture(endpoint, limId, "cat /sys/fs/cgroup/cpu.max 2>/dev/null || echo NA");
|
|
570
|
+
if (cpuMax !== "NA" && cpuMax !== "50000 100000") {
|
|
571
|
+
throw new Error(`cpu.max expected '50000 100000', got '${cpuMax}'`);
|
|
572
|
+
}
|
|
573
|
+
// Caps survive a stop/start (container recreate).
|
|
574
|
+
await fetch(`${endpoint}/sandboxes/${limId}/stop`, { method: "POST" });
|
|
575
|
+
await fetch(`${endpoint}/sandboxes/${limId}/start`, { method: "POST" });
|
|
576
|
+
const memAfter = await memLimitBytes(endpoint, limId);
|
|
577
|
+
if (memAfter !== 256 * MIB)
|
|
578
|
+
throw new Error(`cap lost after resume, got ${memAfter}`);
|
|
579
|
+
}
|
|
580
|
+
finally {
|
|
581
|
+
await fetch(`${endpoint}/sandboxes/${limId}`, { method: "DELETE" });
|
|
582
|
+
}
|
|
583
|
+
// Daemon default applies when no per-create value is given.
|
|
584
|
+
const savedDefault = config.defaultMemoryMb;
|
|
585
|
+
config.defaultMemoryMb = 128;
|
|
586
|
+
try {
|
|
587
|
+
const def = (await (await fetch(`${endpoint}/sandboxes`, {
|
|
588
|
+
method: "POST",
|
|
589
|
+
headers: { "content-type": "application/json" },
|
|
590
|
+
body: "{}",
|
|
591
|
+
})).json());
|
|
592
|
+
try {
|
|
593
|
+
const mem = await memLimitBytes(endpoint, def.id);
|
|
594
|
+
if (mem !== 128 * MIB)
|
|
595
|
+
throw new Error(`daemon-default cap not applied, got ${mem}`);
|
|
596
|
+
}
|
|
597
|
+
finally {
|
|
598
|
+
await fetch(`${endpoint}/sandboxes/${def.id}`, { method: "DELETE" });
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
finally {
|
|
602
|
+
config.defaultMemoryMb = savedDefault;
|
|
603
|
+
}
|
|
604
|
+
console.error("[smoke] resource limits enforced (256 MiB cap, survives resume) + daemon default applies");
|
|
605
|
+
}
|
|
606
|
+
// --repo: clone a git repo into /workspace at create. Best-effort (needs
|
|
607
|
+
// network + installs git on the slim image) — logs a skip when offline.
|
|
608
|
+
{
|
|
609
|
+
const repoRes = await fetch(`${endpoint}/sandboxes`, {
|
|
610
|
+
method: "POST",
|
|
611
|
+
headers: { "content-type": "application/json" },
|
|
612
|
+
body: JSON.stringify({ repo: "https://github.com/octocat/Hello-World.git" }),
|
|
613
|
+
});
|
|
614
|
+
if (repoRes.ok) {
|
|
615
|
+
const { id: repoId } = (await repoRes.json());
|
|
616
|
+
try {
|
|
617
|
+
const found = await execAndCapture(endpoint, repoId, "test -e /workspace/Hello-World/README && echo YES || echo NO");
|
|
618
|
+
if (found !== "YES")
|
|
619
|
+
throw new Error("repo did not clone into /workspace");
|
|
620
|
+
console.error("[smoke] --repo cloned a git repo into /workspace");
|
|
621
|
+
}
|
|
622
|
+
finally {
|
|
623
|
+
await fetch(`${endpoint}/sandboxes/${repoId}`, { method: "DELETE" });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
// 422 = clone failed (likely offline); the wiring + cleanup still ran.
|
|
628
|
+
console.error(`[smoke] --repo clone skipped (status ${repoRes.status}, likely offline)`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// Capacity / admission control: with a 700 MiB budget and a 512 MiB default
|
|
632
|
+
// reservation, exactly one uncapped sandbox fits and the second is rejected
|
|
633
|
+
// (503) before any container is created. Uses an isolated store so it doesn't
|
|
634
|
+
// see the main smoke's sandboxes.
|
|
635
|
+
{
|
|
636
|
+
const capConfig = {
|
|
637
|
+
...config,
|
|
638
|
+
admission: "enforce",
|
|
639
|
+
hostMemoryMb: 700,
|
|
640
|
+
defaultReservationMb: 512,
|
|
641
|
+
overcommit: 1,
|
|
642
|
+
};
|
|
643
|
+
const capStore = new SandboxStore(":memory:");
|
|
644
|
+
const capacity = new Capacity(capStore, capConfig, null);
|
|
645
|
+
const capServer = createApiServer({
|
|
646
|
+
config: capConfig,
|
|
647
|
+
driver,
|
|
648
|
+
store: capStore,
|
|
649
|
+
backups,
|
|
650
|
+
history,
|
|
651
|
+
capacity,
|
|
652
|
+
});
|
|
653
|
+
const capPort = config.port + 6;
|
|
654
|
+
await new Promise((r) => capServer.listen(capPort, config.host, r));
|
|
655
|
+
const capBase = `http://${config.host}:${capPort}`;
|
|
656
|
+
let firstId;
|
|
657
|
+
try {
|
|
658
|
+
const r1 = await fetch(`${capBase}/sandboxes`, { method: "POST" });
|
|
659
|
+
if (!r1.ok)
|
|
660
|
+
throw new Error(`first create should fit, got ${r1.status}`);
|
|
661
|
+
firstId = (await r1.json()).id;
|
|
662
|
+
const r2 = await fetch(`${capBase}/sandboxes`, { method: "POST" });
|
|
663
|
+
if (r2.status !== 503)
|
|
664
|
+
throw new Error(`over-budget create should 503, got ${r2.status}`);
|
|
665
|
+
const snap = (await (await fetch(`${capBase}/capacity`)).json());
|
|
666
|
+
if (!snap.enforced || snap.memory.budgetMb !== 700 || snap.memory.committedMb !== 512 || snap.fits !== 0) {
|
|
667
|
+
throw new Error(`unexpected capacity snapshot: ${JSON.stringify(snap)}`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
finally {
|
|
671
|
+
if (firstId)
|
|
672
|
+
await fetch(`${capBase}/sandboxes/${firstId}`, { method: "DELETE" });
|
|
673
|
+
await new Promise((r) => capServer.close(() => r()));
|
|
674
|
+
capStore.close();
|
|
675
|
+
}
|
|
676
|
+
console.error("[smoke] capacity admission: 1 fits a 700 MiB budget, 2nd rejected (503)");
|
|
677
|
+
}
|
|
678
|
+
// Usage-based admission (deterministic, no containers): two uncapped sandboxes
|
|
679
|
+
// are charged the 256 MiB floor while idle, but when one's measured RSS grows
|
|
680
|
+
// it's charged its actual usage — so a busy box commits more than an idle one.
|
|
681
|
+
{
|
|
682
|
+
const us = new SandboxStore(":memory:");
|
|
683
|
+
const uh = new MetricsHistory(10);
|
|
684
|
+
const rec = (id) => us.add({
|
|
685
|
+
id, image: "x", status: "running", createdAt: new Date().toISOString(),
|
|
686
|
+
labels: {}, env: {}, persist: true, lastActivityAt: "", sleepAfterMs: 0,
|
|
687
|
+
limits: {}, usage: emptyUsage(),
|
|
688
|
+
});
|
|
689
|
+
const sample = (id, mb) => uh.record(id, { at: "", cpuPercent: 0, memBytes: mb * 1024 * 1024, netRxBytes: 0, netTxBytes: 0, pids: 1 });
|
|
690
|
+
rec("aa");
|
|
691
|
+
rec("bb");
|
|
692
|
+
sample("aa", 40);
|
|
693
|
+
sample("bb", 40);
|
|
694
|
+
const uCfg = { ...config, admission: "enforce", hostMemoryMb: 4000, defaultReservationMb: 256, overcommit: 1 };
|
|
695
|
+
const ucap = new Capacity(us, uCfg, null, uh);
|
|
696
|
+
try {
|
|
697
|
+
const idle = ucap.snapshot().memory.committedMb;
|
|
698
|
+
if (idle !== 512)
|
|
699
|
+
throw new Error(`idle committed should be 2×floor=512, got ${idle}`);
|
|
700
|
+
sample("bb", 900); // bb gets busy
|
|
701
|
+
const busy = ucap.snapshot().memory.committedMb;
|
|
702
|
+
if (busy !== 256 + 900)
|
|
703
|
+
throw new Error(`busy committed should be 1156, got ${busy}`);
|
|
704
|
+
}
|
|
705
|
+
finally {
|
|
706
|
+
us.close();
|
|
707
|
+
}
|
|
708
|
+
console.error("[smoke] usage-based admission: idle charges the floor, busy charges measured RSS");
|
|
709
|
+
}
|
|
710
|
+
// Interactive terminal over WebSocket: open a PTY shell, type a command, and
|
|
711
|
+
// read its output back. 6*7 is evaluated by the shell, so seeing the result
|
|
712
|
+
// (not the literal "$((6*7))" that input-echo prints) proves real execution.
|
|
713
|
+
const wsUrl = `${endpoint.replace(/^http/, "ws")}/sandboxes/${id}/terminal?cols=80&rows=24`;
|
|
714
|
+
const termOutput = await new Promise((resolve, reject) => {
|
|
715
|
+
const WS = globalThis.WebSocket;
|
|
716
|
+
if (!WS)
|
|
717
|
+
return reject(new Error("global WebSocket unavailable (need Node >=21)"));
|
|
718
|
+
const ws = new WS(wsUrl);
|
|
719
|
+
ws.binaryType = "arraybuffer";
|
|
720
|
+
let out = "";
|
|
721
|
+
// NB: this module imports the promise-based setTimeout, so use the globals.
|
|
722
|
+
const timer = globalThis.setTimeout(() => {
|
|
723
|
+
try {
|
|
724
|
+
ws.close();
|
|
725
|
+
}
|
|
726
|
+
catch { }
|
|
727
|
+
resolve(out);
|
|
728
|
+
}, 6000);
|
|
729
|
+
ws.onopen = () => ws.send(new TextEncoder().encode("echo TERMINAL_OK_$((6*7))\n"));
|
|
730
|
+
ws.onmessage = (e) => {
|
|
731
|
+
out += typeof e.data === "string" ? e.data : Buffer.from(e.data).toString("utf8");
|
|
732
|
+
if (out.includes("TERMINAL_OK_42")) {
|
|
733
|
+
globalThis.clearTimeout(timer);
|
|
734
|
+
try {
|
|
735
|
+
ws.close();
|
|
736
|
+
}
|
|
737
|
+
catch { }
|
|
738
|
+
resolve(out);
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
ws.onerror = (e) => {
|
|
742
|
+
globalThis.clearTimeout(timer);
|
|
743
|
+
reject(new Error(`terminal ws error: ${e?.message ?? "unknown"}`));
|
|
744
|
+
};
|
|
745
|
+
});
|
|
746
|
+
if (!termOutput.includes("TERMINAL_OK_42")) {
|
|
747
|
+
throw new Error(`terminal did not run command; output: ${JSON.stringify(termOutput.slice(0, 200))}`);
|
|
748
|
+
}
|
|
749
|
+
console.error("[smoke] interactive terminal (WebSocket PTY) round-trips a command");
|
|
750
|
+
// --- security hardening (audit remediation) ---------------------------
|
|
751
|
+
// Host-header DNS-rebinding guard: a foreign Host is rejected, loopback is fine.
|
|
752
|
+
const foreignHost = await statusWithHost(config.host, config.port, "/info", "evil.example");
|
|
753
|
+
if (foreignHost !== 403)
|
|
754
|
+
throw new Error(`foreign Host should 403, got ${foreignHost}`);
|
|
755
|
+
const loopbackHost = await statusWithHost(config.host, config.port, "/info", "127.0.0.1");
|
|
756
|
+
if (loopbackHost !== 200)
|
|
757
|
+
throw new Error(`loopback Host should 200, got ${loopbackHost}`);
|
|
758
|
+
console.error("[smoke] Host-header guard rejects foreign Host (403), allows loopback");
|
|
759
|
+
// Request-body size cap → 413. Shrink the limit on the live config (read per
|
|
760
|
+
// request), send an over-cap body, then restore it.
|
|
761
|
+
const savedMax = config.maxBodyBytes;
|
|
762
|
+
config.maxBodyBytes = 1024;
|
|
763
|
+
try {
|
|
764
|
+
const big = await fetch(`${endpoint}/sandboxes`, {
|
|
765
|
+
method: "POST",
|
|
766
|
+
headers: { "content-type": "application/json" },
|
|
767
|
+
body: JSON.stringify({ labels: { blob: "x".repeat(4096) } }),
|
|
768
|
+
});
|
|
769
|
+
if (big.status !== 413)
|
|
770
|
+
throw new Error(`oversized body should 413, got ${big.status}`);
|
|
771
|
+
}
|
|
772
|
+
finally {
|
|
773
|
+
config.maxBodyBytes = savedMax;
|
|
774
|
+
}
|
|
775
|
+
console.error("[smoke] oversized request body rejected with 413");
|
|
776
|
+
// In-container injection is neutralized: a malicious chmod mode is rejected,
|
|
777
|
+
// and a malicious wait-port host is rejected — neither runs a command.
|
|
778
|
+
const badMode = await fetch(`${endpoint}/sandboxes/${id}/files/write`, {
|
|
779
|
+
method: "POST",
|
|
780
|
+
headers: { "content-type": "application/json" },
|
|
781
|
+
body: JSON.stringify({
|
|
782
|
+
path: "/workspace/m.txt",
|
|
783
|
+
content: "x",
|
|
784
|
+
mode: "777; touch /workspace/PWNED #",
|
|
785
|
+
}),
|
|
786
|
+
});
|
|
787
|
+
if (badMode.status === 200)
|
|
788
|
+
throw new Error("malicious file mode was accepted");
|
|
789
|
+
const badHost = await fetch(`${endpoint}/sandboxes/${id}/wait-port`, {
|
|
790
|
+
method: "POST",
|
|
791
|
+
headers: { "content-type": "application/json" },
|
|
792
|
+
body: JSON.stringify({ port: 1, host: "127.0.0.1; touch /workspace/PWNED #", timeoutMs: 500 }),
|
|
793
|
+
});
|
|
794
|
+
if (badHost.status === 200)
|
|
795
|
+
throw new Error("malicious wait-port host was accepted");
|
|
796
|
+
const pwned = await execAndCapture(endpoint, id, "test -e /workspace/PWNED && echo YES || echo NO");
|
|
797
|
+
if (pwned !== "NO")
|
|
798
|
+
throw new Error("injection created /workspace/PWNED — escaping failed");
|
|
799
|
+
console.error("[smoke] shell-injection via mode/host neutralized (no side effects)");
|
|
800
|
+
// WebSocket message cap: an over-cap inbound frame closes the socket without
|
|
801
|
+
// crashing the daemon (a follow-up request still succeeds).
|
|
802
|
+
await new Promise((resolve) => {
|
|
803
|
+
const WS = globalThis.WebSocket;
|
|
804
|
+
const ws = new WS(`${endpoint.replace(/^http/, "ws")}/sandboxes/${id}/terminal`);
|
|
805
|
+
ws.binaryType = "arraybuffer";
|
|
806
|
+
const done = () => resolve();
|
|
807
|
+
ws.onopen = () => ws.send(new Uint8Array(2 * 1024 * 1024)); // 2 MiB > 1 MiB cap
|
|
808
|
+
ws.onclose = done;
|
|
809
|
+
globalThis.setTimeout(done, 4000);
|
|
810
|
+
});
|
|
811
|
+
const alive = await fetch(`${endpoint}/healthz`);
|
|
812
|
+
if (!alive.ok)
|
|
813
|
+
throw new Error("daemon did not survive an over-cap WebSocket frame");
|
|
814
|
+
console.error("[smoke] over-cap WebSocket frame closes connection, daemon survives");
|
|
815
|
+
// Dashboard + info endpoints.
|
|
816
|
+
const infoRes = await fetch(`${endpoint}/info`);
|
|
817
|
+
const info = (await infoRes.json());
|
|
818
|
+
if (typeof info.costCpuPerHour !== "number" || !info.driver) {
|
|
819
|
+
throw new Error(`/info missing fields: ${JSON.stringify(info)}`);
|
|
820
|
+
}
|
|
821
|
+
const dashRes = await fetch(`${endpoint}/`);
|
|
822
|
+
const dashHtml = await dashRes.text();
|
|
823
|
+
if (!dashRes.ok || !dashHtml.includes("<title>hotcell dashboard</title>")) {
|
|
824
|
+
throw new Error(`dashboard not served (status ${dashRes.status})`);
|
|
825
|
+
}
|
|
826
|
+
console.error("[smoke] dashboard + /info served");
|
|
827
|
+
// Lifecycle FSM: an idle sandbox auto-pauses, and the next operation
|
|
828
|
+
// transparently auto-resumes it (workspace intact). Use a dedicated sandbox
|
|
829
|
+
// with no exposed ports/processes so the reaper is allowed to pause it.
|
|
830
|
+
const fsmCreate = await fetch(`${endpoint}/sandboxes`, {
|
|
831
|
+
method: "POST",
|
|
832
|
+
headers: { "content-type": "application/json" },
|
|
833
|
+
body: JSON.stringify({ sleepAfter: 60_000 }),
|
|
834
|
+
});
|
|
835
|
+
if (!fsmCreate.ok)
|
|
836
|
+
throw new Error(`fsm create failed: ${fsmCreate.status}`);
|
|
837
|
+
const { id: fsmId } = (await fsmCreate.json());
|
|
838
|
+
await fetch(`${endpoint}/sandboxes/${fsmId}/files/write`, {
|
|
839
|
+
method: "POST",
|
|
840
|
+
headers: { "content-type": "application/json" },
|
|
841
|
+
body: JSON.stringify({ path: "/workspace/fsm.txt", content: "survives pause" }),
|
|
842
|
+
});
|
|
843
|
+
// Advance the reaper's clock past sleepAfter to force the idle transition.
|
|
844
|
+
const reaped = await reapIdle(driver, store, Date.now() + 600_000);
|
|
845
|
+
if (!reaped.includes(fsmId)) {
|
|
846
|
+
throw new Error(`reaper did not auto-pause idle sandbox (paused: ${reaped})`);
|
|
847
|
+
}
|
|
848
|
+
const pausedInfo = (await (await fetch(`${endpoint}/sandboxes/${fsmId}`)).json());
|
|
849
|
+
if (pausedInfo.status !== "paused") {
|
|
850
|
+
throw new Error(`expected paused, got "${pausedInfo.status}"`);
|
|
851
|
+
}
|
|
852
|
+
// Next op auto-resumes and the workspace volume is intact.
|
|
853
|
+
const resumed = await execAndCapture(endpoint, fsmId, "cat /workspace/fsm.txt");
|
|
854
|
+
if (resumed !== "survives pause") {
|
|
855
|
+
throw new Error(`auto-resume lost the workspace: "${resumed}"`);
|
|
856
|
+
}
|
|
857
|
+
const runningInfo = (await (await fetch(`${endpoint}/sandboxes/${fsmId}`)).json());
|
|
858
|
+
if (runningInfo.status !== "running") {
|
|
859
|
+
throw new Error(`expected running after resume, got "${runningInfo.status}"`);
|
|
860
|
+
}
|
|
861
|
+
await fetch(`${endpoint}/sandboxes/${fsmId}`, { method: "DELETE" });
|
|
862
|
+
console.error("[smoke] idle sandbox auto-paused and auto-resumed");
|
|
863
|
+
// Destroy sandbox.
|
|
864
|
+
const deleteRes = await fetch(`${endpoint}/sandboxes/${id}`, { method: "DELETE" });
|
|
865
|
+
if (!deleteRes.ok)
|
|
866
|
+
throw new Error(`destroy failed: ${deleteRes.status}`);
|
|
867
|
+
console.error(`[smoke] destroyed sandbox ${id}`);
|
|
868
|
+
console.error("[smoke] passed");
|
|
869
|
+
return 0;
|
|
870
|
+
}
|
|
871
|
+
catch (err) {
|
|
872
|
+
console.error(`[smoke] failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
873
|
+
return 1;
|
|
874
|
+
}
|
|
875
|
+
finally {
|
|
876
|
+
proxy.close();
|
|
877
|
+
server.close();
|
|
878
|
+
store.close();
|
|
879
|
+
await rm(config.backupDir, { recursive: true, force: true });
|
|
880
|
+
await rm(dbDir, { recursive: true, force: true });
|
|
881
|
+
await setTimeout(100);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
/** Run a command (optionally in a session) and return trimmed stdout. */
|
|
885
|
+
/**
|
|
886
|
+
* Plain node:http GET (no connection pooling, unlike global fetch — important
|
|
887
|
+
* for the splice-based preview proxy). Optionally overrides the Host header.
|
|
888
|
+
*/
|
|
889
|
+
function httpGet(host, port, path, hostHeader) {
|
|
890
|
+
return new Promise((resolve, reject) => {
|
|
891
|
+
const headers = { connection: "close" };
|
|
892
|
+
if (hostHeader)
|
|
893
|
+
headers.host = hostHeader;
|
|
894
|
+
const req = httpRequest({ host, port, path, method: "GET", headers }, (res) => {
|
|
895
|
+
let body = "";
|
|
896
|
+
res.setEncoding("utf8");
|
|
897
|
+
res.on("data", (c) => (body += c));
|
|
898
|
+
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
|
|
899
|
+
});
|
|
900
|
+
req.on("error", reject);
|
|
901
|
+
req.end();
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
/** GET with a custom Host header; resolve with just the status code. */
|
|
905
|
+
async function statusWithHost(host, port, path, hostHeader) {
|
|
906
|
+
return (await httpGet(host, port, path, hostHeader)).status;
|
|
907
|
+
}
|
|
908
|
+
/** Read a running sandbox's current memory cap (Docker stats `memory_stats.limit`). */
|
|
909
|
+
async function memLimitBytes(endpoint, id) {
|
|
910
|
+
const m = (await (await fetch(`${endpoint}/sandboxes/${id}/metrics`)).json());
|
|
911
|
+
return m.live?.memLimitBytes ?? 0;
|
|
912
|
+
}
|
|
913
|
+
async function execAndCapture(endpoint, id, command, sessionId) {
|
|
914
|
+
const res = await fetch(`${endpoint}/sandboxes/${id}/exec`, {
|
|
915
|
+
method: "POST",
|
|
916
|
+
headers: { "content-type": "application/json" },
|
|
917
|
+
body: JSON.stringify({ command, sessionId }),
|
|
918
|
+
});
|
|
919
|
+
if (!res.ok || !res.body)
|
|
920
|
+
throw new Error(`exec failed: ${res.status}`);
|
|
921
|
+
let out = "";
|
|
922
|
+
for await (const event of parseSSE(res.body)) {
|
|
923
|
+
if (event.type === "stdout")
|
|
924
|
+
out += event.data;
|
|
925
|
+
}
|
|
926
|
+
return out.trim();
|
|
927
|
+
}
|
|
928
|
+
/** Run a code cell via the run-code endpoint, optionally in a context. */
|
|
929
|
+
async function runCode(endpoint, id, code, contextId) {
|
|
930
|
+
const res = await fetch(`${endpoint}/sandboxes/${id}/run-code`, {
|
|
931
|
+
method: "POST",
|
|
932
|
+
headers: { "content-type": "application/json" },
|
|
933
|
+
body: JSON.stringify({ code, contextId }),
|
|
934
|
+
});
|
|
935
|
+
if (!res.ok)
|
|
936
|
+
throw new Error(`run-code failed: ${res.status}`);
|
|
937
|
+
return (await res.json());
|
|
938
|
+
}
|
|
939
|
+
async function* parseSSE(body) {
|
|
940
|
+
const reader = body.getReader();
|
|
941
|
+
const decoder = new TextDecoder();
|
|
942
|
+
let buffer = "";
|
|
943
|
+
for (;;) {
|
|
944
|
+
const { done, value } = await reader.read();
|
|
945
|
+
if (done)
|
|
946
|
+
break;
|
|
947
|
+
buffer += decoder.decode(value, { stream: true });
|
|
948
|
+
let idx;
|
|
949
|
+
while ((idx = buffer.indexOf("\n\n")) !== -1) {
|
|
950
|
+
const frame = buffer.slice(0, idx);
|
|
951
|
+
buffer = buffer.slice(idx + 2);
|
|
952
|
+
const line = frame.split("\n").find((l) => l.startsWith("data:"));
|
|
953
|
+
if (!line)
|
|
954
|
+
continue;
|
|
955
|
+
const json = line.slice(5).trim();
|
|
956
|
+
if (json)
|
|
957
|
+
yield JSON.parse(json);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
main().then((code) => process.exit(code));
|
|
962
|
+
//# sourceMappingURL=smoke.js.map
|