@hotcell/daemon 0.1.23 → 0.1.24

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 (42) hide show
  1. package/dist/api/server.d.ts +2 -0
  2. package/dist/api/server.js +117 -14
  3. package/dist/api/server.js.map +1 -1
  4. package/dist/audit-check.d.ts +1 -0
  5. package/dist/audit-check.js +113 -0
  6. package/dist/audit-check.js.map +1 -0
  7. package/dist/audit.d.ts +66 -0
  8. package/dist/audit.js +260 -0
  9. package/dist/audit.js.map +1 -0
  10. package/dist/boot-scheduler.d.ts +25 -0
  11. package/dist/boot-scheduler.js +81 -0
  12. package/dist/boot-scheduler.js.map +1 -0
  13. package/dist/config.d.ts +15 -3
  14. package/dist/config.js +22 -0
  15. package/dist/config.js.map +1 -1
  16. package/dist/driver/container.d.ts +6 -0
  17. package/dist/driver/container.js +45 -2
  18. package/dist/driver/container.js.map +1 -1
  19. package/dist/driver/router.d.ts +5 -0
  20. package/dist/driver/router.js +6 -0
  21. package/dist/driver/router.js.map +1 -1
  22. package/dist/driver/types.d.ts +2 -0
  23. package/dist/e2e-harness.js +39 -26
  24. package/dist/e2e-harness.js.map +1 -1
  25. package/dist/egress-check.js +7 -0
  26. package/dist/egress-check.js.map +1 -1
  27. package/dist/index.js +16 -4
  28. package/dist/index.js.map +1 -1
  29. package/dist/lifecycle.d.ts +1 -1
  30. package/dist/lifecycle.js +6 -2
  31. package/dist/lifecycle.js.map +1 -1
  32. package/dist/net/firewall.d.ts +26 -1
  33. package/dist/net/firewall.js +175 -35
  34. package/dist/net/firewall.js.map +1 -1
  35. package/dist/proxy/egress.js +25 -0
  36. package/dist/proxy/egress.js.map +1 -1
  37. package/dist/smoke.js +8 -0
  38. package/dist/smoke.js.map +1 -1
  39. package/dist/tracing.d.ts +1 -0
  40. package/dist/tracing.js +17 -3
  41. package/dist/tracing.js.map +1 -1
  42. package/package.json +2 -2
@@ -3,6 +3,7 @@ import { BackupRegistry } from "../backups.js";
3
3
  import { type Config } from "../config.js";
4
4
  import type { Driver } from "../driver/types.js";
5
5
  import type { Capacity } from "../capacity.js";
6
+ import type { BootScheduler } from "../boot-scheduler.js";
6
7
  import type { MetricsHistory } from "../metrics.js";
7
8
  import { SandboxStore } from "../store.js";
8
9
  interface Deps {
@@ -12,6 +13,7 @@ interface Deps {
12
13
  backups: BackupRegistry;
13
14
  history?: MetricsHistory;
14
15
  capacity?: Capacity;
16
+ bootScheduler?: BootScheduler;
15
17
  /** Re-read provider keys (file/keychain/env) and hot-swap the egress gateway. */
16
18
  reloadKeys?: () => {
17
19
  providers: number;
@@ -1,4 +1,5 @@
1
1
  import { createServer } from "node:http";
2
+ import { audit, redactCommandForAudit } from "../audit.js";
2
3
  import { previewUrl } from "../config.js";
3
4
  import { kernelFor } from "../kernels.js";
4
5
  import { pauseSandbox, resumeSandbox } from "../lifecycle.js";
@@ -133,6 +134,7 @@ async function handleTerminalUpgrade(req, socket, head, deps) {
133
134
  return;
134
135
  }
135
136
  if (!isAllowedHost(req, config)) {
137
+ auditAccessDenied("auth.host_rejected", "GET", url.pathname, "host_not_allowed", driver, store);
136
138
  socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
137
139
  socket.destroy();
138
140
  return;
@@ -140,6 +142,7 @@ async function handleTerminalUpgrade(req, socket, head, deps) {
140
142
  if (config.apiKey) {
141
143
  const provided = url.searchParams.get("key") ?? bearerFromHeader(req);
142
144
  if (!provided || !safeEqual(provided, config.apiKey)) {
145
+ auditAccessDenied("auth.rejected", "GET", url.pathname, "invalid_api_key", driver, store);
143
146
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
144
147
  socket.destroy();
145
148
  return;
@@ -184,11 +187,13 @@ async function handleTerminalUpgrade(req, socket, head, deps) {
184
187
  term = await driver.openTerminal(id, { cols, rows, env: record.env });
185
188
  }
186
189
  catch (err) {
190
+ audit({ event: "sandbox.terminal.open.failed", sandboxId: id, driver: record.driver ?? driver.name });
187
191
  ws.send(`\r\n[sbx] failed to open terminal: ${errorMessage(err)}\r\n`);
188
192
  ws.close();
189
193
  span.setStatus("error").end();
190
194
  return;
191
195
  }
196
+ audit({ event: "sandbox.terminal.open", sandboxId: id, driver: record.driver ?? driver.name });
192
197
  log.info("terminal opened", { sandbox: id, traceId: span.traceId });
193
198
  term.stream.on("data", (chunk) => ws.send(chunk));
194
199
  term.stream.on("end", () => ws.close());
@@ -211,6 +216,7 @@ async function handleTerminalUpgrade(req, socket, head, deps) {
211
216
  });
212
217
  ws.onClose(() => {
213
218
  term.close();
219
+ audit({ event: "sandbox.terminal.close", sandboxId: id, driver: record.driver ?? driver.name });
214
220
  span.end();
215
221
  log.info("terminal closed", { sandbox: id });
216
222
  });
@@ -401,7 +407,18 @@ function resolveLimits(body, config) {
401
407
  function normalizeRoute(path) {
402
408
  return path
403
409
  .replace(/^\/sandboxes\/[^/]+/, "/sandboxes/:id")
404
- .replace(/\/(processes|sessions|code-contexts|expose|backups)\/[^/]+/, "/$1/:sub");
410
+ .replace(/\/(processes|sessions|code-contexts|expose|backups|egress-tokens)\/[^/]+/g, "/$1/:sub");
411
+ }
412
+ /** Audit an access rejection without recording credentials or raw path identifiers. */
413
+ function auditAccessDenied(event, method, path, reason, driver, store) {
414
+ const sandboxId = path.match(/^\/sandboxes\/([^/]+)/)?.[1];
415
+ const record = sandboxId ? store.get(sandboxId) : undefined;
416
+ audit({
417
+ event,
418
+ sandboxId,
419
+ driver: record?.driver ?? driver.name,
420
+ attributes: { method, route: normalizeRoute(path), reason },
421
+ });
405
422
  }
406
423
  /**
407
424
  * Enforce the API key when one is configured. `/healthz` and the dashboard HTML
@@ -459,9 +476,11 @@ async function handle(req, res, deps) {
459
476
  const path = url.pathname;
460
477
  const method = req.method ?? "GET";
461
478
  if (!isAllowedHost(req, config)) {
479
+ auditAccessDenied("auth.host_rejected", method, path, "host_not_allowed", driver, store);
462
480
  return sendJson(res, 403, { error: "host not allowed" });
463
481
  }
464
482
  if (!isAuthorized(req, config, path)) {
483
+ auditAccessDenied("auth.rejected", method, path, "invalid_api_key", driver, store);
465
484
  res.setHeader("WWW-Authenticate", "Bearer");
466
485
  return sendJson(res, 401, { error: "missing or invalid API key" });
467
486
  }
@@ -500,6 +519,8 @@ async function handle(req, res, deps) {
500
519
  if (!store.get(egressTokenIdMatch[1]))
501
520
  return sendJson(res, 404, { error: "not found" });
502
521
  const removed = store.removeEgressToken(egressTokenIdMatch[2]);
522
+ if (removed)
523
+ audit({ event: "gateway.token.revoked", sandboxId: egressTokenIdMatch[1], driver: store.get(egressTokenIdMatch[1])?.driver ?? driver.name, attributes: { "token.raw": egressTokenIdMatch[2] } });
503
524
  if (!removed)
504
525
  return sendJson(res, 404, { error: "token not found" });
505
526
  return sendJson(res, 200, { token: egressTokenIdMatch[2], revoked: true });
@@ -516,6 +537,7 @@ async function handle(req, res, deps) {
516
537
  return sendJson(res, 400, { error: parsed.error });
517
538
  const token = SandboxStore.newEgressToken();
518
539
  store.addEgressToken(token, id, parsed.policy);
540
+ audit({ event: "gateway.token.minted", sandboxId: id, driver: store.get(id)?.driver ?? driver.name, attributes: { "token.raw": token, providers: (parsed.policy.providers ?? []).join(",") } });
519
541
  return sendJson(res, 201, {
520
542
  token,
521
543
  policy: parsed.policy,
@@ -538,13 +560,38 @@ async function handle(req, res, deps) {
538
560
  if (method === "GET" && path === "/capacity") {
539
561
  if (!deps.capacity)
540
562
  return sendJson(res, 200, { enforced: false });
541
- return sendJson(res, 200, deps.capacity.snapshot());
563
+ return sendJson(res, 200, {
564
+ ...deps.capacity.snapshot(),
565
+ boot: deps.bootScheduler?.snapshot(),
566
+ });
542
567
  }
543
568
  // Hot-reload provider keys after `hotcell keys add/rm` — no daemon restart.
544
569
  if (method === "POST" && path === "/reload-keys") {
545
570
  const r = deps.reloadKeys?.() ?? { providers: 0 };
546
571
  return sendJson(res, 200, { ok: true, ...r });
547
572
  }
573
+ if (method === "POST" && path === "/audit-events") {
574
+ const body = await readJson(req, config.maxBodyBytes);
575
+ if (body.event !== "keys.import" || !Array.isArray(body.choices) || body.choices.length > 500) {
576
+ return sendJson(res, 400, { error: "invalid audit event payload" });
577
+ }
578
+ const choices = body.choices;
579
+ if (!choices.every((choice) => {
580
+ if (!choice || typeof choice !== "object")
581
+ return false;
582
+ const item = choice;
583
+ return typeof item.name === "string" && /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(item.name)
584
+ && (item.disposition === "gateway" || item.disposition === "inject" || item.disposition === "skip");
585
+ }))
586
+ return sendJson(res, 400, { error: "invalid key import choice" });
587
+ const safeChoices = choices;
588
+ audit({ event: "keys.import", driver: driver.name, attributes: {
589
+ "key.count": safeChoices.length,
590
+ "key.names": safeChoices.map((item) => item.name).join(","),
591
+ "key.dispositions": safeChoices.map((item) => `${item.name}=${item.disposition}`).join(","),
592
+ } });
593
+ return sendJson(res, 202, { accepted: true });
594
+ }
548
595
  // Recent finished spans, for debugging and the trace view. Newest first.
549
596
  if (method === "GET" && path === "/traces") {
550
597
  const limit = Number(url.searchParams.get("limit") ?? "100") || 100;
@@ -557,7 +604,13 @@ async function handle(req, res, deps) {
557
604
  }
558
605
  if (method === "POST" && path === "/sandboxes") {
559
606
  const body = await readJson(req, config.maxBodyBytes);
560
- return createSandbox(res, { config, driver, store, capacity: deps.capacity }, body);
607
+ return createSandbox(res, {
608
+ config,
609
+ driver,
610
+ store,
611
+ capacity: deps.capacity,
612
+ bootScheduler: deps.bootScheduler,
613
+ }, body);
561
614
  }
562
615
  if (method === "GET" && path === "/sandboxes") {
563
616
  return sendJson(res, 200, { sandboxes: store.list() });
@@ -665,9 +718,16 @@ async function handle(req, res, deps) {
665
718
  }
666
719
  const backupIdMatch = path.match(/^\/backups\/([^/]+)$/);
667
720
  if (method === "DELETE" && backupIdMatch) {
721
+ const backupInfo = await backups.get(backupIdMatch[1]);
668
722
  const removed = await backups.remove(backupIdMatch[1]);
669
723
  if (!removed)
670
724
  return sendJson(res, 404, { error: "backup not found" });
725
+ audit({
726
+ event: "sandbox.backup.delete",
727
+ sandboxId: backupInfo?.sandboxId,
728
+ driver: backupInfo ? store.get(backupInfo.sandboxId)?.driver ?? driver.name : driver.name,
729
+ attributes: { backupId: backupIdMatch[1] },
730
+ });
671
731
  return sendJson(res, 200, { backupId: backupIdMatch[1], deleted: true });
672
732
  }
673
733
  const backupsMatch = path.match(/^\/sandboxes\/([^/]+)\/backups$/);
@@ -750,6 +810,7 @@ async function handle(req, res, deps) {
750
810
  if (!record)
751
811
  return sendJson(res, 404, { error: "not found" });
752
812
  await driver.destroy(id);
813
+ audit({ event: "sandbox.destroy", sandboxId: id, driver: record.driver ?? driver.name, attributes: { image: record.image } });
753
814
  store.remove(id);
754
815
  store.clearSandbox(id);
755
816
  deps.history?.clear(id);
@@ -758,7 +819,10 @@ async function handle(req, res, deps) {
758
819
  }
759
820
  sendJson(res, 404, { error: `no route for ${method} ${path}` });
760
821
  }
761
- async function createSandbox(res, { config, driver, store, capacity }, body) {
822
+ function safeRepoForAudit(repo) {
823
+ return repo.replace(/:\/\/[^/@]+@/g, "://").split(/[?#]/, 1)[0]?.slice(0, 512) ?? "";
824
+ }
825
+ async function createSandbox(res, { config, driver, store, capacity, bootScheduler }, body) {
762
826
  const id = SandboxStore.newId();
763
827
  const image = typeof body.image === "string" ? body.image : config.defaultImage;
764
828
  // Per-sandbox isolation selection: pick the runtime driver (default SBX_DRIVER).
@@ -801,8 +865,10 @@ async function createSandbox(res, { config, driver, store, capacity }, body) {
801
865
  ok: true,
802
866
  release: () => { },
803
867
  };
804
- if (!admitted.ok)
868
+ if (!admitted.ok) {
869
+ audit({ event: "sandbox.admission_denied", sandboxId: id, driver: driverName, attributes: { image, memoryMb: limits.memoryMb ?? 0, cpus: limits.cpus ?? 0, status: 503, reason: admitted.reason.slice(0, 256) } });
805
870
  return sendCapacityRefusal(res, admitted.reason);
871
+ }
806
872
  // Egress wiring. `egress: true` (or a policy object) opts the sandbox's LLM SDKs
807
873
  // into the gateway (provider base-URL + key env). Independently, when egress
808
874
  // enforcement is ON every sandbox is locked to the gateway, so it also gets a
@@ -878,19 +944,38 @@ async function createSandbox(res, { config, driver, store, capacity }, body) {
878
944
  usage: emptyUsage(),
879
945
  };
880
946
  store.add(record);
947
+ audit({ event: "sandbox.create", sandboxId: id, driver: driverName, attributes: { image, repo: repo ? safeRepoForAudit(repo) : "", branch: (repoRef ?? branchRaw ?? "").slice(0, 256), memoryMb: limits.memoryMb ?? 0, cpus: limits.cpus ?? 0 } });
881
948
  if (egressToken)
882
949
  store.addEgressToken(egressToken, id, egressPolicy);
883
- const provision = () => driver.create({
884
- id, image, driver: driverName, env, labels, persist, setup: setupSteps,
885
- repo, repoRef, limits, networked, writableRootfs, cpuset,
886
- onProgress: (phase) => {
950
+ const provision = () => (driverName === "firecracker" || driverName === "applevz") && bootScheduler
951
+ ? bootScheduler.run(`create:${id}`, () => driver.create({
952
+ id, image, driver: driverName, env, labels, persist, setup: setupSteps,
953
+ repo, repoRef, limits, networked, writableRootfs, cpuset,
954
+ onProgress: (phase) => {
955
+ const r = store.get(id);
956
+ if (r?.status === "creating") {
957
+ r.statusReason = phase;
958
+ store.add(r);
959
+ }
960
+ },
961
+ }), () => {
887
962
  const r = store.get(id);
888
963
  if (r?.status === "creating") {
889
- r.statusReason = phase;
964
+ r.statusReason = "queued for microVM boot";
890
965
  store.add(r);
891
966
  }
892
- },
893
- });
967
+ })
968
+ : driver.create({
969
+ id, image, driver: driverName, env, labels, persist, setup: setupSteps,
970
+ repo, repoRef, limits, networked, writableRootfs, cpuset,
971
+ onProgress: (phase) => {
972
+ const r = store.get(id);
973
+ if (r?.status === "creating") {
974
+ r.statusReason = phase;
975
+ store.add(r);
976
+ }
977
+ },
978
+ });
894
979
  // Terminal transitions. Both re-check the store: a DELETE mid-create is the
895
980
  // cancel path, and must win — never resurrect a destroyed record.
896
981
  const finishOk = () => {
@@ -904,6 +989,7 @@ async function createSandbox(res, { config, driver, store, capacity }, body) {
904
989
  r.statusReason = undefined;
905
990
  r.lastActivityAt = new Date().toISOString();
906
991
  store.add(r);
992
+ audit({ event: "sandbox.start", sandboxId: id, driver: r.driver ?? driver.name });
907
993
  return r;
908
994
  };
909
995
  const finishFail = async (err, keepRecord) => {
@@ -958,7 +1044,8 @@ async function stopSandbox(res, { driver, store }, id) {
958
1044
  // Processes and exposed ports die with the container; sessions persist.
959
1045
  store.clearRuntimeState(id);
960
1046
  record.status = "stopped";
961
- store.add(record); // write-through the status change
1047
+ store.add(record);
1048
+ audit({ event: "sandbox.stop", sandboxId: id, driver: record.driver ?? driver.name }); // write-through the status change
962
1049
  sendJson(res, 200, record);
963
1050
  }
964
1051
  async function startSandbox(res, { driver, store, capacity }, id) {
@@ -1097,6 +1184,7 @@ async function createBackup(res, { driver, store, backups, capacity }, id) {
1097
1184
  bytes,
1098
1185
  };
1099
1186
  await backups.save(info);
1187
+ audit({ event: "sandbox.backup.create", sandboxId: id, driver: record.driver ?? driver.name, attributes: { backupId, bytes } });
1100
1188
  sendJson(res, 201, info);
1101
1189
  }
1102
1190
  async function restoreBackup(res, { driver, store, backups, capacity }, id, body) {
@@ -1112,6 +1200,7 @@ async function restoreBackup(res, { driver, store, backups, capacity }, id, body
1112
1200
  if (!(await ensureLive(res, driver, store, id, capacity)))
1113
1201
  return;
1114
1202
  await driver.restoreBackup(id, backups.tarPath(backupId));
1203
+ audit({ event: "sandbox.backup.restore", sandboxId: id, driver: store.get(id)?.driver ?? driver.name, attributes: { backupId } });
1115
1204
  sendJson(res, 200, { id, restored: backupId });
1116
1205
  }
1117
1206
  // --- code interpreter ------------------------------------------------------
@@ -1293,9 +1382,16 @@ async function execInSandbox(res, { driver, store, capacity }, id, body) {
1293
1382
  // The stream is live work: the reaper must see this sandbox as active for
1294
1383
  // the whole run, not just the instant the request arrived.
1295
1384
  store.beginExec(id);
1385
+ const execStartedAt = Date.now();
1386
+ audit({ event: "sandbox.exec.start", sandboxId: id, driver: record.driver ?? driver.name, attributes: { command: redactCommandForAudit(command), cwd: cwd ?? "" } });
1387
+ let auditExitCode = 1;
1296
1388
  const stopKeepalive = startSseKeepalive(res);
1297
1389
  try {
1298
- await driver.exec(id, runCommand, { cwd, env }, write);
1390
+ await driver.exec(id, runCommand, { cwd, env }, (event) => {
1391
+ if (event.type === "exit")
1392
+ auditExitCode = event.exitCode;
1393
+ write(event);
1394
+ });
1299
1395
  if (session && cwdFile) {
1300
1396
  try {
1301
1397
  const next = (await driver.readFile(id, { path: cwdFile })).trim();
@@ -1315,6 +1411,7 @@ async function execInSandbox(res, { driver, store, capacity }, id, body) {
1315
1411
  }
1316
1412
  finally {
1317
1413
  stopKeepalive();
1414
+ audit({ event: "sandbox.exec", sandboxId: id, driver: record.driver ?? driver.name, attributes: { command: redactCommandForAudit(command), cwd: cwd ?? "", exitCode: auditExitCode, durationMs: Date.now() - execStartedAt } });
1318
1415
  store.endExec(id);
1319
1416
  res.end();
1320
1417
  }
@@ -1380,14 +1477,17 @@ async function handleFileAction(res, { driver, store, capacity }, id, action, bo
1380
1477
  content: body.content,
1381
1478
  mode: typeof body.mode === "string" ? body.mode : undefined,
1382
1479
  });
1480
+ audit({ event: "sandbox.file.write", sandboxId: id, driver: store.get(id)?.driver ?? driver.name, attributes: { path: path.slice(0, 512), bytes: Buffer.byteLength(body.content, "utf8") } });
1383
1481
  return sendJson(res, 200, { ok: true });
1384
1482
  }
1385
1483
  case "read": {
1386
1484
  const content = await driver.readFile(id, { path });
1485
+ audit({ event: "sandbox.file.read", sandboxId: id, driver: store.get(id)?.driver ?? driver.name, attributes: { path: path.slice(0, 512), bytes: Buffer.byteLength(content, "utf8") } });
1387
1486
  return sendJson(res, 200, { content });
1388
1487
  }
1389
1488
  case "mkdir": {
1390
1489
  await driver.mkdir(id, { path, parents: body.parents === true });
1490
+ audit({ event: "sandbox.file.mkdir", sandboxId: id, driver: store.get(id)?.driver ?? driver.name, attributes: { path: path.slice(0, 512), parents: body.parents === true } });
1391
1491
  return sendJson(res, 200, { ok: true });
1392
1492
  }
1393
1493
  case "list": {
@@ -1426,6 +1526,7 @@ async function startProcess(res, { driver, store, capacity }, id, body) {
1426
1526
  logPath,
1427
1527
  };
1428
1528
  store.addProcess(id, proc);
1529
+ audit({ event: "sandbox.process.start", sandboxId: id, driver: record.driver ?? driver.name, attributes: { command: redactCommandForAudit(command), cwd: opts.cwd ?? "", procId } });
1429
1530
  return sendJson(res, 201, proc);
1430
1531
  }
1431
1532
  catch (err) {
@@ -1530,6 +1631,7 @@ async function exposePort(res, { config, driver, store, capacity }, id, body) {
1530
1631
  url: previewUrl(config, id, port),
1531
1632
  };
1532
1633
  store.addExposed(id, exposed);
1634
+ audit({ event: "sandbox.preview.expose", sandboxId: id, driver: store.get(id)?.driver ?? driver.name, attributes: { port, tokenConfigured: exposed.token !== null } });
1533
1635
  return sendJson(res, 201, exposed);
1534
1636
  }
1535
1637
  function unexposePort(res, { store }, id, port) {
@@ -1538,6 +1640,7 @@ function unexposePort(res, { store }, id, port) {
1538
1640
  const removed = store.removeExposed(id, port);
1539
1641
  if (!removed)
1540
1642
  return sendJson(res, 404, { error: "port not exposed" });
1643
+ audit({ event: "sandbox.preview.unexpose", sandboxId: id, driver: store.get(id)?.driver, attributes: { port } });
1541
1644
  return sendJson(res, 200, { ok: true });
1542
1645
  }
1543
1646
  function createSession(res, { store }, id, body) {