@hotcell/daemon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/agent-check.d.ts +1 -0
  2. package/dist/agent-check.js +105 -0
  3. package/dist/agent-check.js.map +1 -0
  4. package/dist/api/server.d.ts +64 -0
  5. package/dist/api/server.js +1368 -0
  6. package/dist/api/server.js.map +1 -0
  7. package/dist/api/ws.d.ts +14 -0
  8. package/dist/api/ws.js +214 -0
  9. package/dist/api/ws.js.map +1 -0
  10. package/dist/applevz-check.d.ts +1 -0
  11. package/dist/applevz-check.js +280 -0
  12. package/dist/applevz-check.js.map +1 -0
  13. package/dist/backups.d.ts +29 -0
  14. package/dist/backups.js +76 -0
  15. package/dist/backups.js.map +1 -0
  16. package/dist/capacity.d.ts +63 -0
  17. package/dist/capacity.js +95 -0
  18. package/dist/capacity.js.map +1 -0
  19. package/dist/config.d.ts +241 -0
  20. package/dist/config.js +152 -0
  21. package/dist/config.js.map +1 -0
  22. package/dist/cost.d.ts +9 -0
  23. package/dist/cost.js +19 -0
  24. package/dist/cost.js.map +1 -0
  25. package/dist/density-bench.d.ts +1 -0
  26. package/dist/density-bench.js +97 -0
  27. package/dist/density-bench.js.map +1 -0
  28. package/dist/driver/agent-driver.d.ts +67 -0
  29. package/dist/driver/agent-driver.js +197 -0
  30. package/dist/driver/agent-driver.js.map +1 -0
  31. package/dist/driver/agent.d.ts +94 -0
  32. package/dist/driver/agent.js +319 -0
  33. package/dist/driver/agent.js.map +1 -0
  34. package/dist/driver/applevz.d.ts +95 -0
  35. package/dist/driver/applevz.js +527 -0
  36. package/dist/driver/applevz.js.map +1 -0
  37. package/dist/driver/container.d.ts +99 -0
  38. package/dist/driver/container.js +733 -0
  39. package/dist/driver/container.js.map +1 -0
  40. package/dist/driver/fc-api.d.ts +85 -0
  41. package/dist/driver/fc-api.js +173 -0
  42. package/dist/driver/fc-api.js.map +1 -0
  43. package/dist/driver/firecracker.d.ts +126 -0
  44. package/dist/driver/firecracker.js +560 -0
  45. package/dist/driver/firecracker.js.map +1 -0
  46. package/dist/driver/index.d.ts +17 -0
  47. package/dist/driver/index.js +61 -0
  48. package/dist/driver/index.js.map +1 -0
  49. package/dist/driver/router.d.ts +82 -0
  50. package/dist/driver/router.js +159 -0
  51. package/dist/driver/router.js.map +1 -0
  52. package/dist/driver/types.d.ts +234 -0
  53. package/dist/driver/types.js +2 -0
  54. package/dist/driver/types.js.map +1 -0
  55. package/dist/driver/unsupported.d.ts +50 -0
  56. package/dist/driver/unsupported.js +81 -0
  57. package/dist/driver/unsupported.js.map +1 -0
  58. package/dist/driver/vz-image.d.ts +35 -0
  59. package/dist/driver/vz-image.js +75 -0
  60. package/dist/driver/vz-image.js.map +1 -0
  61. package/dist/e2e-harness.d.ts +1 -0
  62. package/dist/e2e-harness.js +99 -0
  63. package/dist/e2e-harness.js.map +1 -0
  64. package/dist/egress-check.d.ts +8 -0
  65. package/dist/egress-check.js +317 -0
  66. package/dist/egress-check.js.map +1 -0
  67. package/dist/fc-check.d.ts +1 -0
  68. package/dist/fc-check.js +113 -0
  69. package/dist/fc-check.js.map +1 -0
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.js +114 -0
  72. package/dist/index.js.map +1 -0
  73. package/dist/kernels.d.ts +22 -0
  74. package/dist/kernels.js +134 -0
  75. package/dist/kernels.js.map +1 -0
  76. package/dist/lifecycle.d.ts +49 -0
  77. package/dist/lifecycle.js +111 -0
  78. package/dist/lifecycle.js.map +1 -0
  79. package/dist/logger.d.ts +16 -0
  80. package/dist/logger.js +67 -0
  81. package/dist/logger.js.map +1 -0
  82. package/dist/metrics.d.ts +46 -0
  83. package/dist/metrics.js +92 -0
  84. package/dist/metrics.js.map +1 -0
  85. package/dist/net/firewall.d.ts +4 -0
  86. package/dist/net/firewall.js +80 -0
  87. package/dist/net/firewall.js.map +1 -0
  88. package/dist/pricing.d.ts +36 -0
  89. package/dist/pricing.js +81 -0
  90. package/dist/pricing.js.map +1 -0
  91. package/dist/proxy/allowlist-default.d.ts +23 -0
  92. package/dist/proxy/allowlist-default.js +53 -0
  93. package/dist/proxy/allowlist-default.js.map +1 -0
  94. package/dist/proxy/allowlist.d.ts +43 -0
  95. package/dist/proxy/allowlist.js +87 -0
  96. package/dist/proxy/allowlist.js.map +1 -0
  97. package/dist/proxy/egress.d.ts +49 -0
  98. package/dist/proxy/egress.js +599 -0
  99. package/dist/proxy/egress.js.map +1 -0
  100. package/dist/proxy/ratelimit.d.ts +49 -0
  101. package/dist/proxy/ratelimit.js +74 -0
  102. package/dist/proxy/ratelimit.js.map +1 -0
  103. package/dist/proxy/server.d.ts +26 -0
  104. package/dist/proxy/server.js +202 -0
  105. package/dist/proxy/server.js.map +1 -0
  106. package/dist/router-check.d.ts +1 -0
  107. package/dist/router-check.js +97 -0
  108. package/dist/router-check.js.map +1 -0
  109. package/dist/smoke-fc.d.ts +1 -0
  110. package/dist/smoke-fc.js +183 -0
  111. package/dist/smoke-fc.js.map +1 -0
  112. package/dist/smoke-microvm.d.ts +1 -0
  113. package/dist/smoke-microvm.js +187 -0
  114. package/dist/smoke-microvm.js.map +1 -0
  115. package/dist/smoke-remote.d.ts +20 -0
  116. package/dist/smoke-remote.js +155 -0
  117. package/dist/smoke-remote.js.map +1 -0
  118. package/dist/smoke-vz.d.ts +1 -0
  119. package/dist/smoke-vz.js +161 -0
  120. package/dist/smoke-vz.js.map +1 -0
  121. package/dist/smoke.d.ts +7 -0
  122. package/dist/smoke.js +962 -0
  123. package/dist/smoke.js.map +1 -0
  124. package/dist/store.d.ts +124 -0
  125. package/dist/store.js +578 -0
  126. package/dist/store.js.map +1 -0
  127. package/dist/tracing.d.ts +45 -0
  128. package/dist/tracing.js +144 -0
  129. package/dist/tracing.js.map +1 -0
  130. package/dist/types.d.ts +274 -0
  131. package/dist/types.js +3 -0
  132. package/dist/types.js.map +1 -0
  133. package/dist/util.d.ts +27 -0
  134. package/dist/util.js +63 -0
  135. package/dist/util.js.map +1 -0
  136. package/dist/warmpool-check.d.ts +1 -0
  137. package/dist/warmpool-check.js +83 -0
  138. package/dist/warmpool-check.js.map +1 -0
  139. package/dist/web/dashboard.d.ts +1 -0
  140. package/dist/web/dashboard.js +456 -0
  141. package/dist/web/dashboard.js.map +1 -0
  142. package/package.json +53 -0
@@ -0,0 +1,733 @@
1
+ import Docker from "dockerode";
2
+ import { gatewayOf } from "../net/firewall.js";
3
+ import { createReadStream, createWriteStream } from "node:fs";
4
+ import { mkdir } from "node:fs/promises";
5
+ import { dirname } from "node:path";
6
+ import { Duplex, PassThrough } from "node:stream";
7
+ import { pipeline } from "node:stream/promises";
8
+ import { shellEscape } from "../util.js";
9
+ import { log } from "../logger.js";
10
+ export class ContainerDriver {
11
+ name = "container";
12
+ docker;
13
+ ensured = new Set();
14
+ egress;
15
+ egressNetReady = false;
16
+ constructor(docker, egress) {
17
+ this.docker = docker ?? new Docker();
18
+ this.egress = egress;
19
+ }
20
+ containerName(id) {
21
+ return `sbx-${id}`;
22
+ }
23
+ /** Deterministic name for the sandbox's persistent workspace volume. */
24
+ volumeName(id) {
25
+ return `sbx-${id}-workspace`;
26
+ }
27
+ async ping() {
28
+ await this.docker.ping();
29
+ }
30
+ async hostInfo() {
31
+ const info = (await this.docker.info());
32
+ return {
33
+ memoryMb: Math.floor((info.MemTotal ?? 0) / (1024 * 1024)),
34
+ cpus: info.NCPU ?? 0,
35
+ };
36
+ }
37
+ /**
38
+ * Create the egress-enforcement bridge network if missing (idempotent). Uses the
39
+ * configured subnet so the host firewall's `-s <subnet>` rules match, with the
40
+ * gateway pinned to `.1`. Safe to call repeatedly; a 409 (already exists) is fine.
41
+ */
42
+ async ensureEgressNetwork() {
43
+ if (!this.egress || this.egressNetReady)
44
+ return;
45
+ const { network, subnet } = this.egress;
46
+ const gateway = gatewayOf(subnet);
47
+ try {
48
+ await this.docker.createNetwork({
49
+ Name: network,
50
+ Driver: "bridge",
51
+ CheckDuplicate: true,
52
+ IPAM: { Driver: "default", Config: [{ Subnet: subnet, Gateway: gateway }] },
53
+ Labels: { "sbx.managed": "true" },
54
+ });
55
+ }
56
+ catch (err) {
57
+ if (!isConflict(err))
58
+ throw err; // 409 = already exists
59
+ }
60
+ this.egressNetReady = true;
61
+ }
62
+ /** Create the workspace volume if it does not already exist (idempotent). */
63
+ async ensureVolume(id) {
64
+ const name = this.volumeName(id);
65
+ await this.docker.createVolume({
66
+ Name: name,
67
+ Labels: { "sbx.managed": "true", "sbx.id": id },
68
+ });
69
+ }
70
+ /** Pull the image if it is not already present locally. */
71
+ async ensureImage(image) {
72
+ if (this.ensured.has(image))
73
+ return;
74
+ const images = await this.docker.listImages({
75
+ filters: { reference: [image] },
76
+ });
77
+ if (images.length === 0) {
78
+ await new Promise((resolve, reject) => {
79
+ this.docker.pull(image, (err, stream) => {
80
+ if (err)
81
+ return reject(err);
82
+ this.docker.modem.followProgress(stream, (doneErr) => doneErr ? reject(doneErr) : resolve());
83
+ });
84
+ });
85
+ }
86
+ this.ensured.add(image);
87
+ }
88
+ async create(opts) {
89
+ await this.launchContainer(opts);
90
+ if (opts.repo)
91
+ await this.cloneRepo(opts.id, opts.repo, opts.repoRef);
92
+ await this.runSetup(opts.id, opts.setup ?? []);
93
+ }
94
+ /**
95
+ * Clone a git repo into `/workspace` at create time so an agent comes up with
96
+ * the code in place. Installs git first if the image lacks it (the default
97
+ * slim image), then shallow-clones into a subdir named after the repo. A
98
+ * failure throws — unlike best-effort `setup`, the agent needs the code.
99
+ */
100
+ async cloneRepo(id, repo, ref) {
101
+ const refFlag = ref ? `--branch ${shellEscape(ref)} ` : "";
102
+ const script = `if ! command -v git >/dev/null 2>&1; then apt-get update && apt-get install -y git >/dev/null 2>&1; fi\n` +
103
+ `cd /workspace && git clone --depth 1 ${refFlag}${shellEscape(repo)}`;
104
+ const { stdout, stderr, exitCode } = await this.execCapture(id, script);
105
+ if (exitCode !== 0) {
106
+ throw new Error(`git clone failed: ${(stderr || stdout).trim() || `exit ${exitCode}`}`);
107
+ }
108
+ log.info("repo cloned", { sandbox: id, repo });
109
+ }
110
+ /**
111
+ * Run declarative setup commands in order, once, after the container starts.
112
+ * Best-effort: a non-zero exit is logged but does not fail create. Kept out of
113
+ * `launchContainer` on purpose so it does not re-fire on `start()`/resume.
114
+ */
115
+ async runSetup(id, commands) {
116
+ for (const command of commands) {
117
+ const { stderr, exitCode } = await this.execCapture(id, command);
118
+ if (exitCode === 0) {
119
+ log.info("setup command ok", { sandbox: id, command });
120
+ }
121
+ else {
122
+ log.warn("setup command failed (continuing)", {
123
+ sandbox: id,
124
+ command,
125
+ exitCode,
126
+ stderr: stderr.trim(),
127
+ });
128
+ }
129
+ }
130
+ }
131
+ async start(opts) {
132
+ try {
133
+ await this.launchContainer(opts);
134
+ }
135
+ catch (err) {
136
+ // A 409 means the container already exists (already started) — treat as ok.
137
+ if (!isConflict(err))
138
+ throw err;
139
+ }
140
+ }
141
+ async stop(id) {
142
+ // Remove the container but keep the workspace volume. With persistence the
143
+ // data survives; without it, stop is effectively a destroy of the rootfs.
144
+ const container = this.docker.getContainer(this.containerName(id));
145
+ try {
146
+ await container.remove({ force: true });
147
+ }
148
+ catch (err) {
149
+ if (!isNotFound(err))
150
+ throw err;
151
+ }
152
+ }
153
+ /** Shared create/start path: ensure image (+volume), then run the container. */
154
+ async launchContainer(opts) {
155
+ await this.ensureImage(opts.image);
156
+ const persist = opts.persist ?? true;
157
+ if (persist)
158
+ await this.ensureVolume(opts.id);
159
+ // Under egress enforcement, put the sandbox on the dedicated bridge network
160
+ // (whose egress is locked down by the host firewall) and pin its DNS resolver.
161
+ const enforced = this.egress?.enforce === true;
162
+ if (enforced)
163
+ await this.ensureEgressNetwork();
164
+ const env = Object.entries(opts.env ?? {}).map(([k, v]) => `${k}=${v}`);
165
+ const container = await this.docker.createContainer({
166
+ name: this.containerName(opts.id),
167
+ Image: opts.image,
168
+ // Keep the sandbox alive regardless of the image's default CMD.
169
+ Entrypoint: ["/bin/sh", "-c"],
170
+ Cmd: ["sleep infinity"],
171
+ Env: env,
172
+ Labels: { "sbx.managed": "true", "sbx.id": opts.id, ...opts.labels },
173
+ WorkingDir: "/workspace",
174
+ HostConfig: {
175
+ AutoRemove: false,
176
+ // Back /workspace with the named volume so it outlives the container.
177
+ ...(persist ? { Binds: [`${this.volumeName(opts.id)}:/workspace`] } : {}),
178
+ // Join the locked-down egress network when enforcement is on; otherwise the
179
+ // default bridge.
180
+ ...(enforced ? { NetworkMode: this.egress.network } : {}),
181
+ // Pin DNS to a controlled resolver under enforcement (blocks DNS exfil; DoH
182
+ // is separately denied by the allowlist). Empty = Docker's embedded resolver.
183
+ ...(enforced && this.egress.dns ? { Dns: [this.egress.dns] } : {}),
184
+ // Make `host.docker.internal` resolve to the daemon host from inside the
185
+ // sandbox so the egress LLM gateway is reachable. Docker Desktop (macOS)
186
+ // injects this name automatically; native Linux dockerd does not, so we
187
+ // map it to the bridge gateway explicitly (Docker ≥20.10). Harmless on
188
+ // Desktop, which already defines it — keeps one codepath across hosts.
189
+ ExtraHosts: ["host.docker.internal:host-gateway"],
190
+ // Hard per-sandbox resource caps (cgroups). Each is omitted when 0/unset.
191
+ ...resourceHostConfig(opts.limits),
192
+ },
193
+ });
194
+ await container.start();
195
+ }
196
+ async exec(id, command, opts, onEvent) {
197
+ const container = this.docker.getContainer(this.containerName(id));
198
+ const exec = await container.exec({
199
+ Cmd: ["/bin/bash", "-lc", command],
200
+ AttachStdout: true,
201
+ AttachStderr: true,
202
+ WorkingDir: opts.cwd ?? "/workspace",
203
+ Env: Object.entries(opts.env ?? {}).map(([k, v]) => `${k}=${v}`),
204
+ });
205
+ const stream = await exec.start({ hijack: true, stdin: false });
206
+ const stdout = new PassThrough();
207
+ const stderr = new PassThrough();
208
+ stdout.on("data", (chunk) => onEvent({ type: "stdout", data: chunk.toString("utf8") }));
209
+ stderr.on("data", (chunk) => onEvent({ type: "stderr", data: chunk.toString("utf8") }));
210
+ // Demultiplex Docker's combined stdout/stderr framing.
211
+ this.docker.modem.demuxStream(stream, stdout, stderr);
212
+ await new Promise((resolve, reject) => {
213
+ stream.on("end", resolve);
214
+ stream.on("error", reject);
215
+ });
216
+ stdout.end();
217
+ stderr.end();
218
+ const info = await exec.inspect();
219
+ const exitCode = info.ExitCode ?? 0;
220
+ onEvent({ type: "exit", exitCode });
221
+ return exitCode;
222
+ }
223
+ /**
224
+ * Run a non-interactive command and capture its streams + exit code. Never
225
+ * throws on a non-zero exit — callers decide what a failure means.
226
+ */
227
+ async execCapture(id, command, opts = {}) {
228
+ const container = this.docker.getContainer(this.containerName(id));
229
+ const exec = await container.exec({
230
+ Cmd: ["/bin/bash", "-c", command],
231
+ AttachStdout: true,
232
+ AttachStderr: true,
233
+ WorkingDir: opts.cwd ?? "/workspace",
234
+ Env: Object.entries(opts.env ?? {}).map(([k, v]) => `${k}=${v}`),
235
+ });
236
+ const stream = await exec.start({ hijack: true, stdin: false });
237
+ const stdout = new PassThrough();
238
+ const stderr = new PassThrough();
239
+ let out = "";
240
+ let err = "";
241
+ stdout.on("data", (chunk) => {
242
+ out += chunk.toString("utf8");
243
+ });
244
+ stderr.on("data", (chunk) => {
245
+ err += chunk.toString("utf8");
246
+ });
247
+ this.docker.modem.demuxStream(stream, stdout, stderr);
248
+ await new Promise((resolve, reject) => {
249
+ stream.on("end", resolve);
250
+ stream.on("error", reject);
251
+ });
252
+ stdout.end();
253
+ stderr.end();
254
+ const info = await exec.inspect();
255
+ return { stdout: out, stderr: err, exitCode: info.ExitCode ?? 0 };
256
+ }
257
+ /**
258
+ * Run a non-interactive command and return its stdout as a UTF-8 string.
259
+ * Throws if the command exits non-zero.
260
+ */
261
+ async runAndCapture(id, command) {
262
+ const { stdout, stderr, exitCode } = await this.execCapture(id, command);
263
+ if (exitCode !== 0) {
264
+ throw new Error(stderr.trim() || `command failed with exit code ${exitCode}`);
265
+ }
266
+ return stdout;
267
+ }
268
+ async writeFile(id, opts) {
269
+ if (opts.mode !== undefined && !/^[0-7]{3,4}$/.test(opts.mode)) {
270
+ throw new Error(`invalid file mode: ${opts.mode}`);
271
+ }
272
+ const dir = opts.path.includes("/")
273
+ ? opts.path.slice(0, opts.path.lastIndexOf("/"))
274
+ : "/workspace";
275
+ const encoded = Buffer.from(opts.content, "utf8").toString("base64");
276
+ // `mode` is validated above to be octal digits only, so it's safe unquoted.
277
+ const mode = opts.mode ? `chmod ${opts.mode} ${shellEscape(opts.path)} && ` : "";
278
+ const command = `mkdir -p ${shellEscape(dir)} && printf '%s' '${encoded}' | base64 -d > ${shellEscape(opts.path)} && ${mode}echo ok`;
279
+ const output = await this.runAndCapture(id, command);
280
+ if (output.trim() !== "ok") {
281
+ throw new Error(`writeFile failed: ${output}`);
282
+ }
283
+ }
284
+ async readFile(id, opts) {
285
+ return this.runAndCapture(id, `cat ${shellEscape(opts.path)}`);
286
+ }
287
+ async mkdir(id, opts) {
288
+ const flag = opts.parents ? "-p" : "";
289
+ await this.runAndCapture(id, `mkdir ${flag} ${shellEscape(opts.path)}`);
290
+ }
291
+ async listFiles(id, opts) {
292
+ const container = this.docker.getContainer(this.containerName(id));
293
+ const exec = await container.exec({
294
+ Cmd: [
295
+ "/bin/sh",
296
+ "-c",
297
+ `find ${shellEscape(opts.path)} -maxdepth 1 -printf '%y|%p|%s|%TY-%Tm-%TdT%TH:%TM:%TS\\n'`,
298
+ ],
299
+ AttachStdout: true,
300
+ AttachStderr: true,
301
+ WorkingDir: "/workspace",
302
+ });
303
+ const stream = await exec.start({ hijack: true, stdin: false });
304
+ const stdout = new PassThrough();
305
+ const stderr = new PassThrough();
306
+ let out = "";
307
+ let err = "";
308
+ stdout.on("data", (chunk) => {
309
+ out += chunk.toString("utf8");
310
+ });
311
+ stderr.on("data", (chunk) => {
312
+ err += chunk.toString("utf8");
313
+ });
314
+ this.docker.modem.demuxStream(stream, stdout, stderr);
315
+ await new Promise((resolve, reject) => {
316
+ stream.on("end", resolve);
317
+ stream.on("error", reject);
318
+ });
319
+ stdout.end();
320
+ stderr.end();
321
+ const info = await exec.inspect();
322
+ if ((info.ExitCode ?? 0) !== 0) {
323
+ throw new Error(`listFiles failed: ${err || "unknown error"}`);
324
+ }
325
+ const lines = out
326
+ .split("\n")
327
+ .map((line) => line.trim())
328
+ .filter(Boolean);
329
+ return lines.map((line) => {
330
+ const [type, rawPath, size, mtime] = line.split("|");
331
+ const path = rawPath;
332
+ const name = path.slice(path.lastIndexOf("/") + 1);
333
+ return {
334
+ path,
335
+ name,
336
+ isDirectory: type === "d",
337
+ size: Number(size ?? 0),
338
+ modifiedAt: mtime ?? new Date().toISOString(),
339
+ };
340
+ });
341
+ }
342
+ async watchFiles(id, path, opts, onEvent) {
343
+ const intervalSec = ((opts.intervalMs ?? 1000) / 1000).toFixed(3);
344
+ const container = this.docker.getContainer(this.containerName(id));
345
+ // A portable poll-based watcher in python3 (guaranteed in the base image);
346
+ // avoids depending on inotify-tools. Emits `<type>\t<path>` lines.
347
+ const cmd = `python3 -u -c ${shellEscape(WATCHER_PY)} ${shellEscape(path)} ${intervalSec}`;
348
+ const exec = await container.exec({
349
+ Cmd: ["/bin/sh", "-c", cmd],
350
+ AttachStdout: true,
351
+ AttachStderr: true,
352
+ Tty: false,
353
+ });
354
+ const stream = await exec.start({ hijack: true, stdin: false });
355
+ const stdout = new PassThrough();
356
+ const stderr = new PassThrough();
357
+ stderr.resume();
358
+ let buffer = "";
359
+ stdout.on("data", (chunk) => {
360
+ buffer += chunk.toString("utf8");
361
+ let idx;
362
+ while ((idx = buffer.indexOf("\n")) !== -1) {
363
+ const line = buffer.slice(0, idx);
364
+ buffer = buffer.slice(idx + 1);
365
+ const tab = line.indexOf("\t");
366
+ if (tab === -1)
367
+ continue;
368
+ const type = line.slice(0, tab);
369
+ if (type === "created" || type === "modified" || type === "deleted") {
370
+ onEvent({ type, path: line.slice(tab + 1) });
371
+ }
372
+ }
373
+ });
374
+ this.docker.modem.demuxStream(stream, stdout, stderr);
375
+ const onAbort = () => {
376
+ stream.destroy?.();
377
+ };
378
+ if (opts.signal.aborted)
379
+ onAbort();
380
+ else
381
+ opts.signal.addEventListener("abort", onAbort, { once: true });
382
+ try {
383
+ await new Promise((resolve) => {
384
+ stream.on("end", resolve);
385
+ stream.on("close", resolve);
386
+ stream.on("error", () => resolve());
387
+ });
388
+ }
389
+ finally {
390
+ opts.signal.removeEventListener("abort", onAbort);
391
+ stdout.end();
392
+ stderr.end();
393
+ }
394
+ }
395
+ async startProcess(id, procId, command, opts) {
396
+ const logPath = `/tmp/hotcell-proc-${procId}.log`;
397
+ // `setsid` gives the process its own session + group (pgid == its pid), so we
398
+ // can later signal the whole tree, and it survives the exec that launched it.
399
+ // The inner `bash -lc` runs the user command with a login PATH like `exec`.
400
+ const launch = `setsid bash -lc ${shellEscape(command)} > ${shellEscape(logPath)} 2>&1 & echo $!`;
401
+ const { stdout, stderr, exitCode } = await this.execCapture(id, launch, {
402
+ cwd: opts.cwd,
403
+ env: opts.env,
404
+ });
405
+ const pid = Number.parseInt(stdout.trim(), 10);
406
+ if (exitCode !== 0 || !Number.isFinite(pid)) {
407
+ throw new Error(stderr.trim() || `startProcess failed (exit ${exitCode})`);
408
+ }
409
+ return { procId, pid, logPath };
410
+ }
411
+ async listProcesses(id, procs) {
412
+ if (procs.length === 0)
413
+ return [];
414
+ // Emit `<pid> 1` if alive, `<pid> 0` otherwise.
415
+ const pids = procs.map((p) => p.pid).join(" ");
416
+ const cmd = `for p in ${pids}; do if kill -0 "$p" 2>/dev/null; then echo "$p 1"; else echo "$p 0"; fi; done`;
417
+ const { stdout } = await this.execCapture(id, cmd);
418
+ const alive = new Map();
419
+ for (const line of stdout.split("\n")) {
420
+ const [p, state] = line.trim().split(" ");
421
+ if (p)
422
+ alive.set(Number(p), state === "1");
423
+ }
424
+ return procs.map((p) => ({
425
+ procId: p.procId,
426
+ pid: p.pid,
427
+ running: alive.get(p.pid) ?? false,
428
+ }));
429
+ }
430
+ async killProcess(id, pid, signal = "TERM") {
431
+ const stripped = signal.replace(/^SIG/, "");
432
+ // Whitelist signal names/numbers; anything else (incl. shell metacharacters)
433
+ // falls back to TERM so a crafted `signal` can't inject into the kill command.
434
+ const sig = /^[A-Z0-9]+$/.test(stripped) ? stripped : "TERM";
435
+ // Signal the process group first (pid == pgid via setsid), then the pid.
436
+ const cmd = `kill -${sig} -${pid} 2>/dev/null; kill -${sig} ${pid} 2>/dev/null; true`;
437
+ await this.execCapture(id, cmd);
438
+ }
439
+ async streamProcessLogs(id, logPath, opts, onData) {
440
+ const container = this.docker.getContainer(this.containerName(id));
441
+ // `-F` tolerates the logfile not existing yet (it waits for it).
442
+ const cmd = opts.follow
443
+ ? `tail -n +1 -F ${shellEscape(logPath)}`
444
+ : `cat ${shellEscape(logPath)}`;
445
+ const exec = await container.exec({
446
+ Cmd: ["/bin/sh", "-c", cmd],
447
+ AttachStdout: true,
448
+ AttachStderr: true,
449
+ });
450
+ const stream = await exec.start({ hijack: true, stdin: false });
451
+ const stdout = new PassThrough();
452
+ const stderr = new PassThrough();
453
+ stdout.on("data", (chunk) => onData(chunk.toString("utf8")));
454
+ stderr.on("data", (chunk) => onData(chunk.toString("utf8")));
455
+ this.docker.modem.demuxStream(stream, stdout, stderr);
456
+ const onAbort = () => {
457
+ stream.destroy?.();
458
+ };
459
+ if (opts.signal.aborted)
460
+ onAbort();
461
+ else
462
+ opts.signal.addEventListener("abort", onAbort, { once: true });
463
+ try {
464
+ await new Promise((resolve) => {
465
+ stream.on("end", resolve);
466
+ stream.on("close", resolve);
467
+ stream.on("error", () => resolve());
468
+ });
469
+ }
470
+ finally {
471
+ opts.signal.removeEventListener("abort", onAbort);
472
+ stdout.end();
473
+ stderr.end();
474
+ }
475
+ }
476
+ async waitForPort(id, port, opts) {
477
+ const host = opts.host ?? "127.0.0.1";
478
+ assertHost(host);
479
+ const timeoutMs = opts.timeoutMs ?? 30_000;
480
+ const intervalSec = ((opts.intervalMs ?? 250) / 1000).toFixed(3);
481
+ // bash `/dev/tcp` probe loop with a millisecond deadline.
482
+ const cmd = `deadline=$(( $(date +%s%3N) + ${timeoutMs} )); ` +
483
+ `while true; do ` +
484
+ `if (exec 3<>/dev/tcp/${host}/${port}) 2>/dev/null; then exit 0; fi; ` +
485
+ `if [ "$(date +%s%3N)" -ge "$deadline" ]; then exit 1; fi; ` +
486
+ `sleep ${intervalSec}; ` +
487
+ `done`;
488
+ const { exitCode } = await this.execCapture(id, cmd);
489
+ return exitCode === 0;
490
+ }
491
+ async openTcpBridge(id, port, host) {
492
+ assertHost(host);
493
+ const container = this.docker.getContainer(this.containerName(id));
494
+ // Relay raw bytes to the in-container port. socat is binary-safe and handles
495
+ // half-close cleanly; fall back to a bash /dev/tcp bridge when socat is
496
+ // absent (default slim image). We run WITHOUT a TTY (Tty:false) so no pty
497
+ // line discipline can cook the bytes — the response is demultiplexed below
498
+ // to strip Docker's 8-byte stream frames, leaving a clean byte stream.
499
+ const relay = `if command -v socat >/dev/null 2>&1; then ` +
500
+ `exec socat - TCP:${host}:${port}; ` +
501
+ `else exec bash -c 'exec 3<>/dev/tcp/${host}/${port}; cat <&3 & cat >&3; wait'; fi`;
502
+ const exec = await container.exec({
503
+ Cmd: ["/bin/sh", "-c", relay],
504
+ AttachStdin: true,
505
+ AttachStdout: true,
506
+ AttachStderr: true,
507
+ Tty: false,
508
+ });
509
+ const hijacked = await exec.start({ hijack: true, stdin: true });
510
+ // Demux the multiplexed output into a clean readable; drain stderr.
511
+ const outbound = new PassThrough();
512
+ const errSink = new PassThrough();
513
+ errSink.resume();
514
+ this.docker.modem.demuxStream(hijacked, outbound, errSink);
515
+ // Present a single duplex: writes -> exec stdin, reads <- demuxed stdout.
516
+ const writable = hijacked;
517
+ const stream = Duplex.from({ readable: outbound, writable });
518
+ return {
519
+ stream,
520
+ close() {
521
+ hijacked.destroy?.();
522
+ outbound.destroy();
523
+ },
524
+ };
525
+ }
526
+ async openTerminal(id, opts) {
527
+ const container = this.docker.getContainer(this.containerName(id));
528
+ // A real PTY: Tty:true makes Docker allocate a pseudo-terminal, so the output
529
+ // is raw (no 8-byte stream framing) and line discipline / control chars work.
530
+ // Prefer an interactive login bash, falling back to sh on minimal images.
531
+ const exec = await container.exec({
532
+ Cmd: [
533
+ "/bin/sh",
534
+ "-c",
535
+ "if command -v bash >/dev/null 2>&1; then exec bash -l; else exec sh -l; fi",
536
+ ],
537
+ AttachStdin: true,
538
+ AttachStdout: true,
539
+ AttachStderr: true,
540
+ Tty: true,
541
+ WorkingDir: opts.cwd ?? "/workspace",
542
+ Env: Object.entries(opts.env ?? {}).map(([k, v]) => `${k}=${v}`),
543
+ });
544
+ const stream = (await exec.start({
545
+ hijack: true,
546
+ stdin: true,
547
+ // Tell dockerode this is a TTY stream so it doesn't try to demux frames.
548
+ Tty: true,
549
+ }));
550
+ if (opts.cols && opts.rows) {
551
+ await exec.resize({ w: opts.cols, h: opts.rows }).catch(() => { });
552
+ }
553
+ return {
554
+ stream,
555
+ resize(cols, rows) {
556
+ exec.resize({ w: cols, h: rows }).catch(() => { });
557
+ },
558
+ close() {
559
+ stream.destroy?.();
560
+ },
561
+ };
562
+ }
563
+ async createBackup(id, tarPath) {
564
+ const container = this.docker.getContainer(this.containerName(id));
565
+ // getArchive streams a tar of the path's contents (entries rooted at
566
+ // `workspace/...`) straight from the container fs over the Docker socket.
567
+ const stream = (await container.getArchive({
568
+ path: "/workspace",
569
+ }));
570
+ await mkdir(dirname(tarPath), { recursive: true });
571
+ let bytes = 0;
572
+ stream.on("data", (chunk) => {
573
+ bytes += chunk.length;
574
+ });
575
+ await pipeline(stream, createWriteStream(tarPath));
576
+ return { bytes };
577
+ }
578
+ async restoreBackup(id, tarPath) {
579
+ // Clear the existing workspace so the restore is a replacement, not a merge.
580
+ await this.execCapture(id, "find /workspace -mindepth 1 -maxdepth 1 -exec rm -rf {} +");
581
+ const container = this.docker.getContainer(this.containerName(id));
582
+ // The tar entries are rooted at `workspace/`, so extract at `/`.
583
+ await container.putArchive(createReadStream(tarPath), { path: "/" });
584
+ }
585
+ async stats(id) {
586
+ const container = this.docker.getContainer(this.containerName(id));
587
+ // `stream:false` makes the daemon take two samples ~1s apart and populate
588
+ // `precpu_stats`, so the CPU% delta is correct from a single call (a
589
+ // `one-shot` read would leave precpu zero and inflate CPU%). Depending on the
590
+ // dockerode version this resolves either to a parsed object or a one-shot
591
+ // stream, so handle both.
592
+ const result = await container.stats({ stream: false });
593
+ const raw = isReadable(result)
594
+ ? await collectJson(result)
595
+ : result;
596
+ return normalizeStats(raw);
597
+ }
598
+ async destroy(id) {
599
+ const container = this.docker.getContainer(this.containerName(id));
600
+ try {
601
+ await container.remove({ force: true });
602
+ }
603
+ catch (err) {
604
+ // Already gone is fine; rethrow anything else.
605
+ if (!isNotFound(err))
606
+ throw err;
607
+ }
608
+ // Drop the persistent volume too — destroy is irreversible.
609
+ try {
610
+ await this.docker.getVolume(this.volumeName(id)).remove({ force: true });
611
+ }
612
+ catch (err) {
613
+ if (!isNotFound(err))
614
+ throw err;
615
+ }
616
+ }
617
+ }
618
+ /** Duck-type check for a Node readable stream. */
619
+ function isReadable(value) {
620
+ return (typeof value === "object" &&
621
+ value !== null &&
622
+ typeof value.pipe === "function");
623
+ }
624
+ /** Read a readable stream to completion and JSON.parse its contents. */
625
+ async function collectJson(stream) {
626
+ const chunks = [];
627
+ for await (const chunk of stream) {
628
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
629
+ }
630
+ return JSON.parse(Buffer.concat(chunks).toString("utf8").trim() || "{}");
631
+ }
632
+ /** Normalize Docker's raw stats JSON into our cross-driver SandboxStats shape. */
633
+ function normalizeStats(s) {
634
+ const cpu = s.cpu_stats ?? {};
635
+ const precpu = s.precpu_stats ?? {};
636
+ const cpuDelta = (cpu.cpu_usage?.total_usage ?? 0) - (precpu.cpu_usage?.total_usage ?? 0);
637
+ const systemDelta = (cpu.system_cpu_usage ?? 0) - (precpu.system_cpu_usage ?? 0);
638
+ const onlineCpus = cpu.online_cpus || cpu.cpu_usage?.percpu_usage?.length || 1;
639
+ let cpuPercent = 0;
640
+ if (systemDelta > 0 && cpuDelta > 0) {
641
+ cpuPercent = (cpuDelta / systemDelta) * onlineCpus * 100;
642
+ }
643
+ const mem = s.memory_stats ?? {};
644
+ // Exclude reclaimable page cache where the runtime breaks it out (cgroup v2
645
+ // reports `inactive_file`; v1 reports `cache`).
646
+ const cache = mem.stats?.inactive_file ?? mem.stats?.cache ?? 0;
647
+ const memBytes = Math.max(0, (mem.usage ?? 0) - cache);
648
+ let netRxBytes = 0;
649
+ let netTxBytes = 0;
650
+ for (const net of Object.values(s.networks ?? {})) {
651
+ netRxBytes += net.rx_bytes ?? 0;
652
+ netTxBytes += net.tx_bytes ?? 0;
653
+ }
654
+ return {
655
+ cpuPercent,
656
+ cpuTotalUsageNs: cpu.cpu_usage?.total_usage ?? 0,
657
+ onlineCpus,
658
+ memBytes,
659
+ memLimitBytes: mem.limit ?? 0,
660
+ netRxBytes,
661
+ netTxBytes,
662
+ pids: s.pids_stats?.current ?? 0,
663
+ sampledAt: new Date().toISOString(),
664
+ };
665
+ }
666
+ function isNotFound(err) {
667
+ return statusCode(err) === 404;
668
+ }
669
+ function isConflict(err) {
670
+ return statusCode(err) === 409;
671
+ }
672
+ function statusCode(err) {
673
+ return typeof err === "object" && err !== null && "statusCode" in err
674
+ ? err.statusCode
675
+ : undefined;
676
+ }
677
+ /**
678
+ * Translate sandbox resource limits into Docker `HostConfig` cgroup fields.
679
+ * Omits any limit that's 0/undefined so it stays unlimited. `cpus` → `NanoCpus`
680
+ * (1 core = 1e9), `memoryMb` → `Memory` bytes, `pidsLimit` → `PidsLimit`.
681
+ */
682
+ function resourceHostConfig(limits) {
683
+ const cfg = {};
684
+ if (limits?.memoryMb && limits.memoryMb > 0) {
685
+ cfg.Memory = Math.round(limits.memoryMb * 1024 * 1024);
686
+ }
687
+ if (limits?.cpus && limits.cpus > 0) {
688
+ cfg.NanoCpus = Math.round(limits.cpus * 1e9);
689
+ }
690
+ if (limits?.pidsLimit && limits.pidsLimit > 0) {
691
+ cfg.PidsLimit = Math.round(limits.pidsLimit);
692
+ }
693
+ return cfg;
694
+ }
695
+ /**
696
+ * Reject a host that isn't a plain IP/hostname before it's interpolated into a
697
+ * `/dev/tcp/<host>/...` redirect or `socat TCP:<host>` arg, where shell quoting
698
+ * is awkward. Allows letters, digits, `.`, `-`, `_`, and `:` (IPv6) only.
699
+ */
700
+ function assertHost(host) {
701
+ if (!/^[A-Za-z0-9._:-]+$/.test(host)) {
702
+ throw new Error(`invalid host: ${host}`);
703
+ }
704
+ }
705
+ /**
706
+ * Poll-based recursive file watcher (python3, no inotify dependency). Snapshots
707
+ * mtimes under argv[1] every argv[2] seconds and prints `<type>\t<path>` lines
708
+ * for created/modified/deleted files. Contains no single quotes so it survives
709
+ * single-quote shell escaping.
710
+ */
711
+ const WATCHER_PY = [
712
+ "import os,sys,time",
713
+ "path=sys.argv[1]; interval=float(sys.argv[2])",
714
+ "def snap():",
715
+ " s={}",
716
+ " for root,_,files in os.walk(path):",
717
+ " for n in files:",
718
+ " p=os.path.join(root,n)",
719
+ " try: s[p]=os.path.getmtime(p)",
720
+ " except OSError: pass",
721
+ " return s",
722
+ "prev=snap()",
723
+ "while True:",
724
+ " time.sleep(interval)",
725
+ " cur=snap()",
726
+ ' for p in cur:',
727
+ ' if p not in prev: print("created\\t"+p,flush=True)',
728
+ ' elif cur[p]!=prev[p]: print("modified\\t"+p,flush=True)',
729
+ ' for p in prev:',
730
+ ' if p not in cur: print("deleted\\t"+p,flush=True)',
731
+ " prev=cur",
732
+ ].join("\n");
733
+ //# sourceMappingURL=container.js.map