@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.
- package/dist/api/server.d.ts +2 -0
- package/dist/api/server.js +145 -14
- package/dist/api/server.js.map +1 -1
- package/dist/audit-check.d.ts +1 -0
- package/dist/audit-check.js +113 -0
- package/dist/audit-check.js.map +1 -0
- package/dist/audit.d.ts +66 -0
- package/dist/audit.js +260 -0
- package/dist/audit.js.map +1 -0
- package/dist/boot-scheduler.d.ts +25 -0
- package/dist/boot-scheduler.js +81 -0
- package/dist/boot-scheduler.js.map +1 -0
- package/dist/config.d.ts +15 -3
- package/dist/config.js +22 -0
- package/dist/config.js.map +1 -1
- package/dist/driver/container.d.ts +15 -0
- package/dist/driver/container.js +70 -13
- package/dist/driver/container.js.map +1 -1
- package/dist/driver/router.d.ts +5 -0
- package/dist/driver/router.js +6 -0
- package/dist/driver/router.js.map +1 -1
- package/dist/driver/types.d.ts +2 -0
- package/dist/e2e-harness.js +39 -26
- package/dist/e2e-harness.js.map +1 -1
- package/dist/egress-check.js +7 -0
- package/dist/egress-check.js.map +1 -1
- package/dist/index.js +16 -4
- package/dist/index.js.map +1 -1
- package/dist/lifecycle.d.ts +1 -1
- package/dist/lifecycle.js +24 -2
- package/dist/lifecycle.js.map +1 -1
- package/dist/net/firewall.d.ts +26 -1
- package/dist/net/firewall.js +175 -35
- package/dist/net/firewall.js.map +1 -1
- package/dist/proxy/egress.js +25 -0
- package/dist/proxy/egress.js.map +1 -1
- package/dist/smoke.js +8 -0
- package/dist/smoke.js.map +1 -1
- package/dist/store.d.ts +8 -0
- package/dist/store.js +25 -0
- package/dist/store.js.map +1 -1
- package/dist/tracing.d.ts +1 -0
- package/dist/tracing.js +17 -3
- package/dist/tracing.js.map +1 -1
- package/package.json +2 -2
package/dist/api/server.d.ts
CHANGED
|
@@ -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;
|
package/dist/api/server.js
CHANGED
|
@@ -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)\/[^/]
|
|
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,
|
|
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, {
|
|
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
|
-
|
|
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 = () =>
|
|
884
|
-
id
|
|
885
|
-
|
|
886
|
-
|
|
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 =
|
|
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);
|
|
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 },
|
|
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) {
|