@hotcell/daemon 0.1.22 → 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 (45) hide show
  1. package/dist/api/server.d.ts +2 -0
  2. package/dist/api/server.js +145 -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 +15 -0
  17. package/dist/driver/container.js +70 -13
  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 +24 -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/store.d.ts +8 -0
  40. package/dist/store.js +25 -0
  41. package/dist/store.js.map +1 -1
  42. package/dist/tracing.d.ts +1 -0
  43. package/dist/tracing.js +17 -3
  44. package/dist/tracing.js.map +1 -1
  45. 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 ------------------------------------------------------
@@ -1290,8 +1379,19 @@ async function execInSandbox(res, { driver, store, capacity }, id, body) {
1290
1379
  const write = (event) => {
1291
1380
  res.write(`data: ${JSON.stringify(event)}\n\n`);
1292
1381
  };
1382
+ // The stream is live work: the reaper must see this sandbox as active for
1383
+ // the whole run, not just the instant the request arrived.
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;
1388
+ const stopKeepalive = startSseKeepalive(res);
1293
1389
  try {
1294
- 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
+ });
1295
1395
  if (session && cwdFile) {
1296
1396
  try {
1297
1397
  const next = (await driver.readFile(id, { path: cwdFile })).trim();
@@ -1310,9 +1410,30 @@ async function execInSandbox(res, { driver, store, capacity }, id, body) {
1310
1410
  write({ type: "exit", exitCode: 1 });
1311
1411
  }
1312
1412
  finally {
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 } });
1415
+ store.endExec(id);
1313
1416
  res.end();
1314
1417
  }
1315
1418
  }
1419
+ /**
1420
+ * Emit an SSE comment frame on a quiet interval for the life of a stream.
1421
+ *
1422
+ * An agent inside a sandbox can legitimately produce NOTHING on an exec stream
1423
+ * for many minutes (a model thinking between tool calls, a subagent doing the
1424
+ * work while the parent waits). HTTP clients infer death from that silence —
1425
+ * undici's default fetch kills a body after 5 quiet minutes ("Body Timeout
1426
+ * Error"), and it cost a consumer a complete 75-minute run at its final verify
1427
+ * step. Comment frames are ignored by every SSE parser but keep bytes flowing,
1428
+ * so no client-side timeout policy ever sees silence on a healthy stream.
1429
+ */
1430
+ function startSseKeepalive(res, intervalMs = 15_000) {
1431
+ const timer = setInterval(() => {
1432
+ res.write(": hb\n\n");
1433
+ }, intervalMs);
1434
+ timer.unref?.();
1435
+ return () => clearInterval(timer);
1436
+ }
1316
1437
  async function watchFiles(req, res, { driver, store, capacity }, id, watchPath, intervalMs) {
1317
1438
  if (!(await ensureLive(res, driver, store, id, capacity)))
1318
1439
  return;
@@ -1327,6 +1448,7 @@ async function watchFiles(req, res, { driver, store, capacity }, id, watchPath,
1327
1448
  // Stop the watcher when the client disconnects.
1328
1449
  const controller = new AbortController();
1329
1450
  req.on("close", () => controller.abort());
1451
+ const stopKeepalive = startSseKeepalive(res);
1330
1452
  try {
1331
1453
  await driver.watchFiles(id, watchPath, { intervalMs, signal: controller.signal }, (event) => res.write(`data: ${JSON.stringify(event)}\n\n`));
1332
1454
  }
@@ -1334,6 +1456,7 @@ async function watchFiles(req, res, { driver, store, capacity }, id, watchPath,
1334
1456
  res.write(`data: ${JSON.stringify({ type: "error", error: String(err) })}\n\n`);
1335
1457
  }
1336
1458
  finally {
1459
+ stopKeepalive();
1337
1460
  res.end();
1338
1461
  }
1339
1462
  }
@@ -1354,14 +1477,17 @@ async function handleFileAction(res, { driver, store, capacity }, id, action, bo
1354
1477
  content: body.content,
1355
1478
  mode: typeof body.mode === "string" ? body.mode : undefined,
1356
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") } });
1357
1481
  return sendJson(res, 200, { ok: true });
1358
1482
  }
1359
1483
  case "read": {
1360
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") } });
1361
1486
  return sendJson(res, 200, { content });
1362
1487
  }
1363
1488
  case "mkdir": {
1364
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 } });
1365
1491
  return sendJson(res, 200, { ok: true });
1366
1492
  }
1367
1493
  case "list": {
@@ -1400,6 +1526,7 @@ async function startProcess(res, { driver, store, capacity }, id, body) {
1400
1526
  logPath,
1401
1527
  };
1402
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 } });
1403
1530
  return sendJson(res, 201, proc);
1404
1531
  }
1405
1532
  catch (err) {
@@ -1457,6 +1584,7 @@ async function streamLogs(req, res, { driver, store }, id, procId, follow) {
1457
1584
  });
1458
1585
  const controller = new AbortController();
1459
1586
  req.on("close", () => controller.abort());
1587
+ const stopKeepalive = startSseKeepalive(res);
1460
1588
  try {
1461
1589
  await driver.streamProcessLogs(id, proc.logPath, { follow, signal: controller.signal }, (data) => res.write(`data: ${JSON.stringify({ type: "log", data })}\n\n`));
1462
1590
  }
@@ -1464,6 +1592,7 @@ async function streamLogs(req, res, { driver, store }, id, procId, follow) {
1464
1592
  res.write(`data: ${JSON.stringify({ type: "log", data: errorMessage(err) })}\n\n`);
1465
1593
  }
1466
1594
  finally {
1595
+ stopKeepalive();
1467
1596
  res.write(`data: ${JSON.stringify({ type: "end" })}\n\n`);
1468
1597
  res.end();
1469
1598
  }
@@ -1502,6 +1631,7 @@ async function exposePort(res, { config, driver, store, capacity }, id, body) {
1502
1631
  url: previewUrl(config, id, port),
1503
1632
  };
1504
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 } });
1505
1635
  return sendJson(res, 201, exposed);
1506
1636
  }
1507
1637
  function unexposePort(res, { store }, id, port) {
@@ -1510,6 +1640,7 @@ function unexposePort(res, { store }, id, port) {
1510
1640
  const removed = store.removeExposed(id, port);
1511
1641
  if (!removed)
1512
1642
  return sendJson(res, 404, { error: "port not exposed" });
1643
+ audit({ event: "sandbox.preview.unexpose", sandboxId: id, driver: store.get(id)?.driver, attributes: { port } });
1513
1644
  return sendJson(res, 200, { ok: true });
1514
1645
  }
1515
1646
  function createSession(res, { store }, id, body) {