@rallycry/conveyor-k3 0.1.3

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.
@@ -0,0 +1,1182 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/vm-qemu.ts
4
+ import { readFileSync as readFileSync4 } from "fs";
5
+ import { spawn as spawn3 } from "child_process";
6
+ import { dirname, join as join5 } from "path";
7
+ import { fileURLToPath } from "url";
8
+ import { createHash } from "crypto";
9
+
10
+ // src/vm/network.ts
11
+ import { networkInterfaces } from "os";
12
+ import { resolve4 as systemResolve4, resolveSrv as systemResolveSrv } from "dns/promises";
13
+ var WORKER_SUBNET = "192.168.5.0/24";
14
+ var WORKER_IP = "192.168.5.15";
15
+ var MAX_NETWORK_FORWARDS = 100;
16
+ function parseIPv4(address) {
17
+ const parts = address.split(".");
18
+ if (parts.length !== 4) return null;
19
+ let value = 0;
20
+ for (const part of parts) {
21
+ if (!/^\d{1,3}$/.test(part)) return null;
22
+ const octet = Number(part);
23
+ if (octet > 255) return null;
24
+ value = value << 8 | octet;
25
+ }
26
+ return value >>> 0;
27
+ }
28
+ function inCidr(value, base, mask) {
29
+ return (value & mask) === (base & mask);
30
+ }
31
+ function isGloballyRoutableIPv4(address) {
32
+ const value = parseIPv4(address);
33
+ if (value === null) return false;
34
+ return ![
35
+ ["0.0.0.0", 4278190080],
36
+ ["10.0.0.0", 4278190080],
37
+ ["100.64.0.0", 4290772992],
38
+ ["127.0.0.0", 4278190080],
39
+ ["169.254.0.0", 4294901760],
40
+ ["172.16.0.0", 4293918720],
41
+ ["192.0.0.0", 4294967040],
42
+ ["192.0.2.0", 4294967040],
43
+ ["192.88.99.0", 4294967040],
44
+ ["192.168.0.0", 4294901760],
45
+ ["198.18.0.0", 4294836224],
46
+ ["198.51.100.0", 4294967040],
47
+ ["203.0.113.0", 4294967040],
48
+ ["224.0.0.0", 4026531840],
49
+ ["240.0.0.0", 4026531840]
50
+ ].some(([base, mask]) => inCidr(value, parseIPv4(base), mask));
51
+ }
52
+
53
+ // src/vm/qemu-policy.ts
54
+ var ALLOWED_PROBES = new Set(
55
+ [
56
+ ["-version"],
57
+ ["--version"],
58
+ ["-accel", "help"],
59
+ ["-M", "none", "-accel", "help"],
60
+ ["-M", "none", "-netdev", "help"],
61
+ ["-machine", "help"],
62
+ ["-cpu", "help", "-machine", "virt"],
63
+ ["-cpu", "help", "-machine", "q35"]
64
+ ].map((args) => args.join("\0"))
65
+ );
66
+ var TRUSTED_NC_PATH = /^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/;
67
+ var CONTROL_SOCKETS = /* @__PURE__ */ new Map([
68
+ ["char-serial", "serial.sock"],
69
+ ["char-serial-pci", "serialp.sock"],
70
+ ["char-serial-virtio", "serialv.sock"],
71
+ ["char-qmp", "qmp.sock"]
72
+ ]);
73
+ var CONTROL_LOGS = /* @__PURE__ */ new Map([
74
+ ["char-serial", "serial.log"],
75
+ ["char-serial-pci", "serialp.log"],
76
+ ["char-serial-virtio", "serialv.log"]
77
+ ]);
78
+ var REQUIRED_DEVICES = [
79
+ "qemu-xhci",
80
+ "virtio-blk-pci",
81
+ "virtio-scsi",
82
+ "scsi-cd",
83
+ "virtio-net-pci",
84
+ "virtio-rng-pci",
85
+ "virtio-serial-pci",
86
+ "virtconsole"
87
+ ];
88
+ var REQUIRED_CONTROLS = [
89
+ "memory",
90
+ "cpu",
91
+ "smp",
92
+ "boot",
93
+ "audio",
94
+ "-display",
95
+ "-vga",
96
+ "-parallel",
97
+ "serial",
98
+ "qmp",
99
+ "name",
100
+ "pidfile",
101
+ "drive:firmware",
102
+ "drive:boot",
103
+ "drive:cidata"
104
+ ];
105
+ var DISALLOWED_CONTROLS = /* @__PURE__ */ new Set(["-sandbox", "-no-user-config", "-nodefaults"]);
106
+ var BOOT_DISK_BPS = "104857600";
107
+ var BOOT_DISK_IOPS = "2000";
108
+ function assertPort(port, field) {
109
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid ${field}`);
110
+ }
111
+ function isProbe(args) {
112
+ return ALLOWED_PROBES.has(args.join("\0"));
113
+ }
114
+ function rejectForbiddenArg(arg) {
115
+ const lower = arg.toLowerCase();
116
+ if (lower.includes("virtiofs") || lower.includes("9p") || lower.includes("usb-host") || lower.includes("vfio") || lower.includes("vhost-user") || lower.includes("-fsdev") || lower.includes("-virtfs") || lower.startsWith("-nic") || lower === "-net" || lower.startsWith("-net=") || lower === "-smb" || lower === "-redir") {
117
+ throw new Error(`QEMU argument is forbidden by worker isolation policy: ${arg}`);
118
+ }
119
+ }
120
+ function qemuValue(args, index, name) {
121
+ const value = args[index + 1];
122
+ if (value === void 0 || value.startsWith("-")) throw new Error(`Missing QEMU ${name} value`);
123
+ return value;
124
+ }
125
+ function parseCommaFields(value, description) {
126
+ const parts = value.split(",");
127
+ const kind = parts.shift();
128
+ if (!kind) throw new Error(`Malformed ${description}`);
129
+ const fields = /* @__PURE__ */ new Map();
130
+ for (const part of parts) {
131
+ const separator = part.indexOf("=");
132
+ if (separator < 1) throw new Error(`Malformed ${description} option`);
133
+ const key = part.slice(0, separator);
134
+ const fieldValue = part.slice(separator + 1);
135
+ if (!key || !fieldValue || fields.has(key))
136
+ throw new Error(`Duplicate or empty ${description} option`);
137
+ fields.set(key, fieldValue);
138
+ }
139
+ return { kind, fields };
140
+ }
141
+ function parseKeyValueFields(value, description) {
142
+ const fields = /* @__PURE__ */ new Map();
143
+ for (const part of value.split(",")) {
144
+ const separator = part.indexOf("=");
145
+ if (separator < 1) throw new Error(`Malformed ${description} option`);
146
+ const key = part.slice(0, separator);
147
+ const fieldValue = part.slice(separator + 1);
148
+ if (!key || !fieldValue || fields.has(key))
149
+ throw new Error(`Duplicate or empty ${description} option`);
150
+ fields.set(key, fieldValue);
151
+ }
152
+ return fields;
153
+ }
154
+ function assertFields(fields, expected, description) {
155
+ if (fields.size !== Object.keys(expected).length)
156
+ throw new Error(`Unexpected ${description} options`);
157
+ for (const [key, expectedValue] of Object.entries(expected)) {
158
+ const value = fields.get(key);
159
+ if (!value || (typeof expectedValue === "string" ? value !== expectedValue : !expectedValue.test(value))) {
160
+ throw new Error(`Unexpected ${description} option: ${key}`);
161
+ }
162
+ }
163
+ }
164
+ function assertInstancePath(path, filename, context) {
165
+ if (!path.startsWith("/") || path.includes("\0") || path.split("/").includes("..")) {
166
+ throw new Error("QEMU path must be an absolute normalized path");
167
+ }
168
+ if (context.instanceDir) {
169
+ const root = context.instanceDir.endsWith("/") ? context.instanceDir.slice(0, -1) : context.instanceDir;
170
+ if (!root.startsWith("/") || root.includes("\0") || root.split("/").includes("..")) {
171
+ throw new Error("Invalid Lima instance directory");
172
+ }
173
+ if (path !== `${root}/${filename}`) throw new Error(`Unexpected Lima instance path: ${path}`);
174
+ } else if (path.slice(path.lastIndexOf("/") + 1) !== filename) {
175
+ throw new Error(`Unexpected Lima instance filename: ${path}`);
176
+ }
177
+ }
178
+ function validateLimaChardev(value, context) {
179
+ const { kind, fields } = parseCommaFields(value, "QEMU chardev");
180
+ if (kind !== "socket")
181
+ throw new Error("Only Lima Unix control sockets are allowed as QEMU chardevs");
182
+ const id = fields.get("id");
183
+ const filename = id ? CONTROL_SOCKETS.get(id) : void 0;
184
+ if (!id || !filename) throw new Error("Unexpected QEMU chardev socket");
185
+ const path = fields.get("path");
186
+ if (!path) throw new Error("Missing QEMU chardev path");
187
+ assertInstancePath(path, filename, context);
188
+ const logFilename = CONTROL_LOGS.get(id);
189
+ if (logFilename) {
190
+ assertFields(
191
+ fields,
192
+ { id, path: /.+/, server: "on", wait: "off", logfile: /.+/ },
193
+ "QEMU chardev"
194
+ );
195
+ const logfile = fields.get("logfile");
196
+ if (!logfile) throw new Error("Missing QEMU serial logfile");
197
+ assertInstancePath(logfile, logFilename, context);
198
+ } else {
199
+ assertFields(fields, { id, path: /.+/, server: "on", wait: "off" }, "QEMU chardev");
200
+ }
201
+ return `socket,id=${id},path=${path},server=on,wait=off`;
202
+ }
203
+ function validateMachine(value) {
204
+ const { kind, fields } = parseCommaFields(value, "QEMU machine");
205
+ if (!(/* @__PURE__ */ new Set(["virt", "q35"])).has(kind)) throw new Error("Unexpected QEMU machine type");
206
+ assertFields(fields, { accel: /^(?:hvf|kvm)$/ }, "QEMU machine");
207
+ return fields.get("accel");
208
+ }
209
+ function validateDrive(value, context) {
210
+ const fields = parseKeyValueFields(value, "QEMU drive");
211
+ const path = fields.get("file");
212
+ if (!path) throw new Error("QEMU drive is missing a file");
213
+ if (path.includes(":") || path.includes("//")) throw new Error("Remote QEMU disks are forbidden");
214
+ if (fields.get("if") === "pflash") {
215
+ assertFields(
216
+ fields,
217
+ { if: "pflash", format: "raw", readonly: "on", file: /.+/ },
218
+ "firmware drive"
219
+ );
220
+ assertInstancePath(path, "qemu-efi-code.fd", context);
221
+ return { kind: "firmware", value };
222
+ }
223
+ if (fields.get("id") === "boot-disk") {
224
+ for (const dangerousLimit of ["bps", "iops", "bps_rd", "bps_wr", "iops_rd", "iops_wr"]) {
225
+ if (fields.has(dangerousLimit)) throw new Error("Lima may not choose a boot-disk throttle");
226
+ }
227
+ assertFields(fields, { file: /.+/, if: "none", discard: "on", id: "boot-disk" }, "boot drive");
228
+ assertInstancePath(path, "disk", context);
229
+ return {
230
+ kind: "boot",
231
+ value: `${value},bps=${BOOT_DISK_BPS},iops=${BOOT_DISK_IOPS}`
232
+ };
233
+ }
234
+ if (fields.get("id") === "cdrom0") {
235
+ assertFields(
236
+ fields,
237
+ { id: "cdrom0", if: "none", format: "raw", readonly: "on", file: /.+/ },
238
+ "cloud-init drive"
239
+ );
240
+ assertInstancePath(path, "cidata.iso", context);
241
+ return { kind: "cidata", value };
242
+ }
243
+ throw new Error("Unexpected QEMU drive");
244
+ }
245
+ function validateLimaDevice(value) {
246
+ const { kind, fields } = parseCommaFields(value, "QEMU device");
247
+ const options = {
248
+ "qemu-xhci": { id: "usb-bus" },
249
+ "virtio-blk-pci": { drive: "boot-disk", bootindex: "1" },
250
+ "virtio-scsi": { id: "scsi0" },
251
+ "scsi-cd": { bus: "scsi0.0", drive: "cdrom0" },
252
+ "virtio-net-pci": { netdev: "net0", mac: /^(?:[0-9a-f]{2}:){5}[0-9a-f]{2}$/i },
253
+ "virtio-rng-pci": {},
254
+ "pci-serial": { chardev: "char-serial-pci" },
255
+ "virtio-serial-pci": { id: "virtio-serial0", max_ports: "1" },
256
+ virtconsole: { chardev: "char-serial-virtio", id: "console0" }
257
+ };
258
+ const expected = options[kind];
259
+ if (!expected) throw new Error(`Unexpected QEMU device: ${kind}`);
260
+ assertFields(fields, expected, `QEMU device ${kind}`);
261
+ return kind;
262
+ }
263
+ function parseOriginalUsernet(value, sshPort) {
264
+ const { kind, fields } = parseCommaFields(value, "QEMU user network");
265
+ if (kind !== "user") throw new Error("Expected QEMU user network");
266
+ assertFields(
267
+ fields,
268
+ {
269
+ id: "net0",
270
+ net: WORKER_SUBNET,
271
+ dhcpstart: WORKER_IP,
272
+ hostfwd: `tcp:127.0.0.1:${sshPort}-:22`
273
+ },
274
+ "Lima QEMU user network"
275
+ );
276
+ }
277
+ function restrictedUsernet(network, forwards, ncPath) {
278
+ const guestForwards = forwards.map(
279
+ (forward) => `guestfwd=tcp:${forward.guestIp}:${forward.port}-cmd:${ncPath} -4 -n -w 60 ${forward.address} ${forward.port}`
280
+ );
281
+ return [
282
+ "user",
283
+ "id=net0",
284
+ `net=${WORKER_SUBNET}`,
285
+ `dhcpstart=${WORKER_IP}`,
286
+ "restrict=on",
287
+ "ipv6=off",
288
+ `hostfwd=tcp:127.0.0.1:${network.sshPort}-:22`,
289
+ ...guestForwards
290
+ ].join(",");
291
+ }
292
+ function validateForwards(forwards) {
293
+ const seen = /* @__PURE__ */ new Set();
294
+ for (const forward of forwards) {
295
+ assertPort(forward.port, "guest forwarding port");
296
+ const key = `${forward.guestIp}:${forward.port}`;
297
+ if (seen.has(key)) throw new Error("Duplicate QEMU guest forwarding rule");
298
+ seen.add(key);
299
+ if (!/^192\.168\.5\.(?:1\d\d)$/.test(forward.guestIp) || !isGloballyRoutableIPv4(forward.address) || !/^(?:[1-9]?\d|1\d\d|2[0-4]\d|25[0-5])(?:\.(?:[1-9]?\d|1\d\d|2[0-4]\d|25[0-5])){3}$/.test(
300
+ forward.address
301
+ )) {
302
+ throw new Error("Invalid pinned forwarding address");
303
+ }
304
+ }
305
+ }
306
+ function fixedControl(control, expected) {
307
+ return (value) => {
308
+ if (value !== expected) throw new Error(`Unexpected QEMU ${control}`);
309
+ return { value, control };
310
+ };
311
+ }
312
+ var SIMPLE_VALIDATORS = {
313
+ "-m": (value) => {
314
+ if (!/^\d{3,6}$/.test(value) || Number(value) < 512) throw new Error("Invalid QEMU memory");
315
+ return { value, control: "memory" };
316
+ },
317
+ "-cpu": (value) => {
318
+ if (!/^(?:max|host)$/.test(value)) throw new Error("Unexpected QEMU CPU model");
319
+ return { value, control: "cpu" };
320
+ },
321
+ "-machine": (value) => ({ value, control: `machine:${validateMachine(value)}` }),
322
+ "-smp": (value) => {
323
+ if (!/^\d+(?:,sockets=\d+,cores=\d+,threads=\d+)?$/.test(value))
324
+ throw new Error("Invalid QEMU SMP configuration");
325
+ return { value, control: "smp" };
326
+ },
327
+ "-drive": (value, context) => {
328
+ const drive = validateDrive(value, context);
329
+ return { value: drive.value, control: `drive:${drive.kind}` };
330
+ },
331
+ "-boot": fixedControl("boot", "order=c,splash-time=0,menu=on"),
332
+ "-audiodev": fixedControl("audio", "none,id=snd0"),
333
+ "-display": fixedControl("-display", "none"),
334
+ "-vga": fixedControl("-vga", "none"),
335
+ "-parallel": fixedControl("-parallel", "none"),
336
+ "-serial": fixedControl("serial", "chardev:char-serial"),
337
+ "-qmp": fixedControl("qmp", "chardev:char-qmp"),
338
+ "-name": fixedControl("name", "lima-conveyor"),
339
+ "-pidfile": (value, context) => {
340
+ assertInstancePath(value, "qemu.pid", context);
341
+ return { value, control: "pidfile" };
342
+ },
343
+ "-accel": (value) => {
344
+ if (!/^(?:hvf|kvm)$/.test(value)) throw new Error("Software QEMU acceleration is forbidden");
345
+ return { value, control: `accel:${value}` };
346
+ }
347
+ };
348
+ function validateSimpleArg(args, index, arg, context) {
349
+ const validator = SIMPLE_VALIDATORS[arg];
350
+ if (!validator) throw new Error(`Unsupported QEMU option: ${arg}`);
351
+ const value = qemuValue(args, index, arg);
352
+ rejectForbiddenArg(value);
353
+ return validator(value, context);
354
+ }
355
+ function appendSpecialArgument(args, index, network, forwards, ncPath, context, state, output) {
356
+ const arg = args[index];
357
+ if (arg === "-netdev") {
358
+ const value2 = qemuValue(args, index, "netdev");
359
+ state.usernetCount += 1;
360
+ if (state.usernetCount !== 1) throw new Error("Expected exactly one QEMU user network");
361
+ parseOriginalUsernet(value2, network.sshPort);
362
+ output.push("-netdev", restrictedUsernet(network, forwards, ncPath));
363
+ return index + 1;
364
+ }
365
+ if (arg === "-chardev") {
366
+ const value2 = qemuValue(args, index, "chardev");
367
+ const rewritten = validateLimaChardev(value2, context);
368
+ const id = parseCommaFields(value2, "QEMU chardev").fields.get("id");
369
+ if (state.chardevs.has(id)) throw new Error(`Duplicate QEMU chardev: ${id}`);
370
+ state.chardevs.add(id);
371
+ output.push(arg, rewritten);
372
+ return index + 1;
373
+ }
374
+ if (arg !== "-device") return void 0;
375
+ const value = qemuValue(args, index, "device");
376
+ rejectForbiddenArg(value);
377
+ const device = validateLimaDevice(value);
378
+ if (state.devices.has(device)) throw new Error(`Duplicate QEMU device: ${device}`);
379
+ state.devices.add(device);
380
+ output.push(arg, value);
381
+ return index + 1;
382
+ }
383
+ function recordControl(state, control) {
384
+ if (state.controls.has(control)) throw new Error(`Duplicate QEMU control: ${control}`);
385
+ state.controls.add(control);
386
+ if (control.startsWith("machine:")) state.machineAccel = control.slice("machine:".length);
387
+ if (control.startsWith("accel:")) state.explicitAccel = control.slice("accel:".length);
388
+ }
389
+ function assertCompleteProfile(state) {
390
+ if (state.usernetCount !== 1) throw new Error("Expected exactly one Lima QEMU user network");
391
+ if (!state.machineAccel || state.explicitAccel && state.explicitAccel !== state.machineAccel) {
392
+ throw new Error("QEMU hardware accelerator is missing or conflicts with machine configuration");
393
+ }
394
+ for (const control of REQUIRED_CONTROLS) {
395
+ if (!state.controls.has(control)) throw new Error(`Missing required QEMU control: ${control}`);
396
+ }
397
+ for (const device of REQUIRED_DEVICES) {
398
+ if (!state.devices.has(device)) throw new Error(`Missing required QEMU device: ${device}`);
399
+ }
400
+ for (const id of CONTROL_SOCKETS.keys()) {
401
+ if (id !== "char-serial-pci" && !state.chardevs.has(id)) {
402
+ throw new Error(`Missing required QEMU chardev: ${id}`);
403
+ }
404
+ }
405
+ if (state.devices.has("pci-serial") !== state.chardevs.has("char-serial-pci")) {
406
+ throw new Error("QEMU PCI serial device and chardev must appear together");
407
+ }
408
+ }
409
+ function rewriteQemuArgs(args, network, ncPath = "/usr/bin/nc", context = {}) {
410
+ if (isProbe(args)) return [...args];
411
+ assertPort(network.sshPort, "SSH port");
412
+ if (!TRUSTED_NC_PATH.test(ncPath)) throw new Error("Invalid netcat path");
413
+ const forwards = network.hosts.flatMap(
414
+ (host) => host.ports.map((port) => ({ guestIp: host.guestIp, address: host.address, port }))
415
+ );
416
+ if (forwards.length > MAX_NETWORK_FORWARDS) throw new Error("Too many QEMU forwarding rules");
417
+ validateForwards(forwards);
418
+ const output = [];
419
+ const state = {
420
+ controls: /* @__PURE__ */ new Set(),
421
+ devices: /* @__PURE__ */ new Set(),
422
+ chardevs: /* @__PURE__ */ new Set(),
423
+ usernetCount: 0
424
+ };
425
+ for (let index = 0; index < args.length; index += 1) {
426
+ const arg = args[index];
427
+ if (arg === void 0) throw new Error("Missing QEMU argument");
428
+ rejectForbiddenArg(arg);
429
+ if (DISALLOWED_CONTROLS.has(arg))
430
+ throw new Error(`QEMU hardening flag is launcher-owned: ${arg}`);
431
+ if (arg.startsWith("-netdev") && arg !== "-netdev")
432
+ throw new Error("Unexpected QEMU netdev syntax");
433
+ const consumed = appendSpecialArgument(
434
+ args,
435
+ index,
436
+ network,
437
+ forwards,
438
+ ncPath,
439
+ context,
440
+ state,
441
+ output
442
+ );
443
+ if (consumed !== void 0) {
444
+ index = consumed;
445
+ continue;
446
+ }
447
+ const { value, control } = validateSimpleArg(args, index, arg, context);
448
+ recordControl(state, control);
449
+ output.push(arg, value);
450
+ index += 1;
451
+ }
452
+ assertCompleteProfile(state);
453
+ return output;
454
+ }
455
+
456
+ // src/vm/qemu-process.ts
457
+ import { spawn as spawn2 } from "child_process";
458
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
459
+ import { join as join4 } from "path";
460
+
461
+ // src/vm/resource-guard.ts
462
+ import { lstatSync as lstatSync2, statfsSync, writeFileSync as writeFileSync2, renameSync, rmSync as rmSync2 } from "fs";
463
+ import { randomUUID } from "crypto";
464
+ import { execFileSync as execFileSync2 } from "child_process";
465
+ import { join as join2 } from "path";
466
+
467
+ // src/vm/relay.ts
468
+ import { kill } from "process";
469
+ import { execFileSync } from "child_process";
470
+ import { lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
471
+ import { join } from "path";
472
+ import { Socket } from "net";
473
+ var MAX_RELAYS = 16;
474
+ var MAX_RELAY_BYTES = 1024 * 1024 * 1024;
475
+ var RELAY_LIFETIME_MS = 6e4;
476
+ var RELAY_IDLE_MS = 3e4;
477
+ var GUARD_FRESH_MS = 6e3;
478
+ var RELAY_PREFIX = "--conveyor-vm-relay";
479
+ function validPid(value) {
480
+ return Number.isInteger(value) && value > 1 && value <= 4194304;
481
+ }
482
+ function validRunId(value) {
483
+ return typeof value === "string" && /^[a-f0-9-]{36}$/i.test(value);
484
+ }
485
+ function alive(pid) {
486
+ try {
487
+ kill(pid, 0);
488
+ return true;
489
+ } catch {
490
+ return false;
491
+ }
492
+ }
493
+ function relayDirectory(root) {
494
+ return join(root, "relays");
495
+ }
496
+ function safeDirectory(path) {
497
+ mkdirSync(path, { recursive: true, mode: 448 });
498
+ const stat = lstatSync(path);
499
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.()) {
500
+ throw new Error(`Refusing unsafe relay directory: ${path}`);
501
+ }
502
+ }
503
+ function readLiveGuard(root) {
504
+ let parsed;
505
+ try {
506
+ parsed = JSON.parse(readFileSync(join(root, "guard-state.json"), "utf8"));
507
+ } catch {
508
+ throw new Error("VM relay requires a live resource guard");
509
+ }
510
+ if (typeof parsed !== "object" || parsed === null)
511
+ throw new Error("VM relay requires a live resource guard");
512
+ const guard = parsed;
513
+ const checkedAt = typeof guard.lastCheckedAt === "string" ? Date.parse(guard.lastCheckedAt) : Number.NaN;
514
+ if (guard.schema !== 1 || guard.status !== "running" || !validRunId(guard.runId) || !validPid(guard.qemuPid) || !Number.isFinite(checkedAt) || Date.now() - checkedAt > GUARD_FRESH_MS || !alive(guard.qemuPid)) {
515
+ throw new Error("VM relay requires a fresh running resource guard");
516
+ }
517
+ return guard;
518
+ }
519
+ function readNetwork(root) {
520
+ let parsed;
521
+ try {
522
+ parsed = JSON.parse(readFileSync(join(root, "network.json"), "utf8"));
523
+ } catch {
524
+ throw new Error("VM relay cannot read the host network policy");
525
+ }
526
+ if (typeof parsed !== "object" || parsed === null)
527
+ throw new Error("VM relay network policy is invalid");
528
+ const network = parsed;
529
+ if (!Array.isArray(network.hosts)) throw new Error("VM relay network policy is invalid");
530
+ return network;
531
+ }
532
+ function assertApprovedDestination(root, invocation) {
533
+ const allowed = readNetwork(root).hosts.some(
534
+ (host) => host && host.address === invocation.address && Array.isArray(host.ports) && host.ports.includes(invocation.port)
535
+ );
536
+ if (!allowed) throw new Error("VM relay destination is not in the host network policy");
537
+ }
538
+ function recordPath(root, pid) {
539
+ return join(relayDirectory(root), `${pid}.json`);
540
+ }
541
+ function slotPath(root, slot) {
542
+ return join(relayDirectory(root), `slot-${slot}`);
543
+ }
544
+ function claimSlot(root) {
545
+ safeDirectory(relayDirectory(root));
546
+ for (let slot = 0; slot < MAX_RELAYS; slot += 1) {
547
+ try {
548
+ mkdirSync(slotPath(root, slot), { mode: 448 });
549
+ return slot;
550
+ } catch (error) {
551
+ if (error.code !== "EEXIST") throw error;
552
+ }
553
+ }
554
+ throw new Error(`VM relay limit reached (${MAX_RELAYS})`);
555
+ }
556
+ function registerRelay(root, runId) {
557
+ const slot = claimSlot(root);
558
+ const record = {
559
+ schema: 1,
560
+ pid: process.pid,
561
+ runId,
562
+ slot,
563
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
564
+ };
565
+ try {
566
+ writeFileSync(join(slotPath(root, slot), "owner.json"), JSON.stringify(record), {
567
+ mode: 384,
568
+ flag: "wx"
569
+ });
570
+ writeFileSync(recordPath(root, process.pid), JSON.stringify(record), {
571
+ mode: 384,
572
+ flag: "wx"
573
+ });
574
+ return record;
575
+ } catch (error) {
576
+ rmSync(recordPath(root, process.pid), { force: true });
577
+ rmSync(slotPath(root, slot), { recursive: true, force: true });
578
+ throw error;
579
+ }
580
+ }
581
+ function unregisterRelay(root, record) {
582
+ rmSync(recordPath(root, record.pid), { force: true });
583
+ rmSync(slotPath(root, record.slot), { recursive: true, force: true });
584
+ }
585
+ function parseRelayInvocation(args) {
586
+ if (args[0] !== RELAY_PREFIX) return null;
587
+ if (args.length !== 7 || args[1] !== "-4" || args[2] !== "-n" || args[3] !== "-w" || args[4] !== "60") {
588
+ throw new Error("Invalid VM relay invocation");
589
+ }
590
+ const address = args[5] ?? "";
591
+ const port = Number(args[6]);
592
+ if (!isGloballyRoutableIPv4(address) || !Number.isInteger(port) || port < 1 || port > 65535) {
593
+ throw new Error("Invalid VM relay destination");
594
+ }
595
+ return { address, port };
596
+ }
597
+ function parsePs(output) {
598
+ const result = /* @__PURE__ */ new Map();
599
+ for (const line of output.trim().split("\n")) {
600
+ if (!line.trim()) continue;
601
+ const match = /^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/.exec(line.trim());
602
+ if (!match) throw new Error("Invalid relay process measurement");
603
+ const [pidText, pgidText, rssText, command] = match.slice(1);
604
+ const pid = Number(pidText);
605
+ const pgid = Number(pgidText);
606
+ const rssKiB = Number(rssText);
607
+ if (![pid, pgid, rssKiB].every(Number.isSafeInteger) || pid < 0 || pgid < 0 || rssKiB < 0) {
608
+ throw new Error("Invalid relay process measurement");
609
+ }
610
+ result.set(pid, { pid, pgid, rssKiB, command: command ?? "" });
611
+ }
612
+ return result;
613
+ }
614
+ function listedRecords(root, runId) {
615
+ const records = [];
616
+ for (let slot = 0; slot < MAX_RELAYS; slot += 1) {
617
+ try {
618
+ const record = JSON.parse(
619
+ readFileSync(join(slotPath(root, slot), "owner.json"), "utf8")
620
+ );
621
+ if (record.schema === 1 && validPid(record.pid) && validRunId(record.runId) && record.runId === runId && record.slot === slot)
622
+ records.push(record);
623
+ } catch {
624
+ }
625
+ }
626
+ return records;
627
+ }
628
+ function sampleRelayProcesses(root, runId, psOutput) {
629
+ const processes = parsePs(psOutput);
630
+ const expectedCommand = `${join(root, "vm-qemu.js")} ${RELAY_PREFIX}`;
631
+ return listedRecords(root, runId).flatMap((record) => {
632
+ const process2 = processes.get(record.pid);
633
+ if (!process2 || process2.pgid !== process2.pid || !process2.command.includes(expectedCommand))
634
+ return [];
635
+ return [{ pid: process2.pid, pgid: process2.pgid, rssKiB: process2.rssKiB }];
636
+ });
637
+ }
638
+ function terminateRelayProcesses(root, runId, psOutput) {
639
+ const relays = sampleRelayProcesses(root, runId, psOutput);
640
+ for (const relay of relays) {
641
+ try {
642
+ kill(-relay.pgid, "SIGKILL");
643
+ } catch {
644
+ }
645
+ }
646
+ return relays.map((relay) => relay.pid);
647
+ }
648
+ function cleanupRelays(root, runId) {
649
+ try {
650
+ const psOutput = execFileSync("/bin/ps", ["-axo", "pid=,pgid=,rss=,command="], {
651
+ encoding: "utf8",
652
+ timeout: 1e3,
653
+ maxBuffer: 512 * 1024,
654
+ env: { PATH: "/usr/bin:/bin", LANG: "C" }
655
+ });
656
+ return terminateRelayProcesses(root, runId, psOutput);
657
+ } catch {
658
+ return [];
659
+ }
660
+ }
661
+ function currentGuardStillValid(root, runId) {
662
+ try {
663
+ return readLiveGuard(root).runId === runId;
664
+ } catch {
665
+ return false;
666
+ }
667
+ }
668
+ function runRelay(root, invocation) {
669
+ return Promise.resolve().then(() => runRelayNow(root, invocation));
670
+ }
671
+ function runRelayNow(root, invocation) {
672
+ const guard = readLiveGuard(root);
673
+ assertApprovedDestination(root, invocation);
674
+ const record = registerRelay(root, guard.runId);
675
+ return new Promise((resolve) => {
676
+ let finished = false;
677
+ let sent = 0;
678
+ let received = 0;
679
+ let guardTimer;
680
+ let lifetimeTimer;
681
+ const socket = new Socket();
682
+ const finish = (error) => {
683
+ if (finished) return;
684
+ finished = true;
685
+ if (guardTimer) clearInterval(guardTimer);
686
+ if (lifetimeTimer) clearTimeout(lifetimeTimer);
687
+ socket.destroy();
688
+ process.stdin.destroy();
689
+ try {
690
+ unregisterRelay(root, record);
691
+ } catch {
692
+ }
693
+ if (error) process.exitCode = 1;
694
+ resolve();
695
+ };
696
+ const exceedsLimit = (total) => total > MAX_RELAY_BYTES;
697
+ socket.setTimeout(RELAY_IDLE_MS, () => finish(new Error("VM relay idle timeout")));
698
+ socket.on("error", () => finish(new Error("VM relay connection failed")));
699
+ socket.on("close", () => finish());
700
+ socket.on("data", (chunk) => {
701
+ received += chunk.length;
702
+ if (exceedsLimit(received)) return finish(new Error("VM relay receive limit exceeded"));
703
+ if (!process.stdout.write(chunk)) socket.pause();
704
+ });
705
+ process.stdout.on("drain", () => socket.resume());
706
+ process.stdout.on("error", () => finish(new Error("VM relay output failed")));
707
+ process.stdin.on("data", (chunk) => {
708
+ sent += chunk.length;
709
+ if (exceedsLimit(sent)) return finish(new Error("VM relay send limit exceeded"));
710
+ if (!socket.write(chunk)) process.stdin.pause();
711
+ });
712
+ socket.on("drain", () => process.stdin.resume());
713
+ process.stdin.on("end", () => socket.end());
714
+ process.stdin.on("error", () => finish(new Error("VM relay input failed")));
715
+ guardTimer = setInterval(() => {
716
+ if (!currentGuardStillValid(root, record.runId))
717
+ finish(new Error("VM relay guard is no longer healthy"));
718
+ }, 2e3);
719
+ lifetimeTimer = setTimeout(
720
+ () => finish(new Error("VM relay lifetime exceeded")),
721
+ RELAY_LIFETIME_MS
722
+ );
723
+ socket.connect(invocation.port, invocation.address);
724
+ });
725
+ }
726
+
727
+ // src/vm/resource-guard.ts
728
+ var GIB = 1024 ** 3;
729
+ var GUARD_INTERVAL_MS = 2e3;
730
+ function guardLimits(resources) {
731
+ if (!Number.isInteger(resources.memoryGiB) || resources.memoryGiB < 2 || resources.memoryGiB > 1024 || !Number.isInteger(resources.diskGiB) || resources.diskGiB < 20 || resources.diskGiB > 512) {
732
+ throw new Error("Invalid resource limits in host state");
733
+ }
734
+ return {
735
+ minFreeBytes: 10 * GIB,
736
+ maxRssKiB: (resources.memoryGiB + 1) * 1024 ** 2,
737
+ maxProcesses: 64,
738
+ maxOutputBytes: 1024 * 1024,
739
+ maxDiskBytes: (resources.diskGiB + 1) * GIB,
740
+ maxLogBytes: 8 * 1024 ** 2
741
+ };
742
+ }
743
+ function resourceViolation(sample, limits) {
744
+ if (Object.values(sample).some((value) => !Number.isFinite(value) || value < 0))
745
+ return "invalid resource measurement";
746
+ if (sample.freeBytes < limits.minFreeBytes) return "host free disk space below reserve";
747
+ if (sample.diskBytes > limits.maxDiskBytes) return "VM disk file exceeds its allocation";
748
+ if (sample.largestLogBytes > limits.maxLogBytes) return "VM host log exceeds its size limit";
749
+ if (sample.rssKiB > limits.maxRssKiB) return "VM process group exceeds its memory budget";
750
+ if (sample.processes > limits.maxProcesses) return "VM process group exceeds its process budget";
751
+ if (sample.processes < 1) return "QEMU process group is missing";
752
+ return null;
753
+ }
754
+ function parseProcessGroup(output, group) {
755
+ let rssKiB = 0;
756
+ let processes = 0;
757
+ for (const line of output.trim().split("\n")) {
758
+ const values = line.trim().split(/\s+/).map(Number);
759
+ if (values.length !== 3 || values.some((value) => !Number.isSafeInteger(value) || value < 0)) {
760
+ throw new Error("Invalid host process measurement");
761
+ }
762
+ if (values[0] === group) {
763
+ processes += 1;
764
+ rssKiB += values[2] ?? 0;
765
+ }
766
+ }
767
+ return { rssKiB, processes };
768
+ }
769
+ function regularFileSize(path) {
770
+ let stat;
771
+ try {
772
+ stat = lstatSync2(path);
773
+ } catch (error) {
774
+ if (error.code === "ENOENT") return 0;
775
+ throw error;
776
+ }
777
+ if (!stat.isFile() || stat.isSymbolicLink())
778
+ throw new Error("Unexpected VM disk or log file type");
779
+ return stat.size;
780
+ }
781
+ function hostPs(args) {
782
+ return execFileSync2("/bin/ps", args, {
783
+ encoding: "utf8",
784
+ // Allow scheduling delays during host builds, within the independent watchdog
785
+ // heartbeat deadline. A failed sample still shuts down the VM.
786
+ timeout: 3e3,
787
+ maxBuffer: 512 * 1024,
788
+ env: { PATH: "/usr/bin:/bin", LANG: "C" }
789
+ });
790
+ }
791
+ function sampleResources(root, qemuPid, runId) {
792
+ const fs = statfsSync(root);
793
+ const instance = join2(root, "lima", "conveyor");
794
+ const processes = parseProcessGroup(hostPs(["-axo", "pgid=,pid=,rss="]), qemuPid);
795
+ const relays = sampleRelayProcesses(root, runId, hostPs(["-axo", "pid=,pgid=,rss=,command="]));
796
+ return {
797
+ freeBytes: fs.bavail * fs.bsize,
798
+ rssKiB: processes.rssKiB + relays.reduce((total, relay) => total + relay.rssKiB, 0),
799
+ processes: processes.processes + relays.length,
800
+ relayProcesses: relays.length,
801
+ diskBytes: regularFileSize(join2(instance, "disk")),
802
+ largestLogBytes: Math.max(
803
+ ...["ha.stdout.log", "ha.stderr.log", "serial.log", "serialp.log", "serialv.log"].map(
804
+ (file) => regularFileSize(join2(instance, file))
805
+ )
806
+ )
807
+ };
808
+ }
809
+ function writeGuardEvidence(root, evidence) {
810
+ const path = join2(root, "guard-state.json");
811
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
812
+ try {
813
+ writeFileSync2(temporary, JSON.stringify(evidence), { mode: 384, flag: "wx" });
814
+ renameSync(temporary, path);
815
+ } finally {
816
+ rmSync2(temporary, { force: true });
817
+ }
818
+ }
819
+ function startResourceGuard(root, identity, limits, stop) {
820
+ const evidence = {
821
+ schema: 1,
822
+ ...identity,
823
+ status: "running",
824
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
825
+ lastCheckedAt: (/* @__PURE__ */ new Date()).toISOString(),
826
+ limits
827
+ };
828
+ let finished = false;
829
+ function finish(status, reason) {
830
+ if (finished) return;
831
+ finished = true;
832
+ clearInterval(timer);
833
+ evidence.status = status;
834
+ evidence.reason = reason;
835
+ evidence.lastCheckedAt = (/* @__PURE__ */ new Date()).toISOString();
836
+ try {
837
+ writeGuardEvidence(root, evidence);
838
+ } catch {
839
+ }
840
+ }
841
+ function check() {
842
+ if (finished) return;
843
+ try {
844
+ evidence.observed = sampleResources(root, identity.qemuPid, identity.runId);
845
+ const violation = resourceViolation(evidence.observed, limits);
846
+ if (violation) throw new Error(violation);
847
+ evidence.lastCheckedAt = (/* @__PURE__ */ new Date()).toISOString();
848
+ writeGuardEvidence(root, evidence);
849
+ } catch (error) {
850
+ const reason = error instanceof Error ? error.message : "resource guard failed";
851
+ finish("terminated", reason);
852
+ stop(reason);
853
+ }
854
+ }
855
+ const timer = setInterval(check, GUARD_INTERVAL_MS);
856
+ setImmediate(check);
857
+ return { finish };
858
+ }
859
+
860
+ // src/vm/watchdog.ts
861
+ import { readFileSync as readFileSync2 } from "fs";
862
+ import { spawn } from "child_process";
863
+ import { join as join3 } from "path";
864
+ var WATCHDOG_ARG = "--conveyor-vm-watchdog";
865
+ var HEARTBEAT_MAX_AGE_MS = 1e4;
866
+ var HEARTBEAT_MAX_FUTURE_MS = 5e3;
867
+ var STARTUP_GRACE_MS = 12e3;
868
+ var WATCH_INTERVAL_MS = 1e3;
869
+ function parseHeartbeat(value) {
870
+ if (typeof value !== "object" || value === null) return null;
871
+ const record = value;
872
+ if (record.schema !== 1 || typeof record.runId !== "string" || typeof record.wrapperPid !== "number" || typeof record.qemuPid !== "number" || typeof record.watchdogPid !== "number" || typeof record.networkHash !== "string" || record.status !== "running" || typeof record.lastCheckedAt !== "string")
873
+ return null;
874
+ return record;
875
+ }
876
+ function heartbeatFresh(value, now) {
877
+ const checkedAt = Date.parse(value.lastCheckedAt);
878
+ return Number.isFinite(checkedAt) && checkedAt >= now - HEARTBEAT_MAX_AGE_MS && checkedAt <= now + HEARTBEAT_MAX_FUTURE_MS;
879
+ }
880
+ function matchesIdentity(heartbeat, identity, watchdogPid) {
881
+ return heartbeat !== null && heartbeat.runId === identity.runId && heartbeat.wrapperPid === identity.wrapperPid && heartbeat.qemuPid === identity.qemuPid && heartbeat.watchdogPid === watchdogPid && heartbeat.networkHash === identity.networkHash;
882
+ }
883
+ function watchdogDecision(evidence, identity, watchdogPid, startedAt, now, wrapperAlive, qemuAlive) {
884
+ if (!qemuAlive) return "finished";
885
+ if (!wrapperAlive) return "terminate";
886
+ const heartbeat = parseHeartbeat(evidence);
887
+ if (matchesIdentity(heartbeat, identity, watchdogPid) && heartbeatFresh(heartbeat, now))
888
+ return "healthy";
889
+ return now < startedAt + STARTUP_GRACE_MS ? "wait" : "terminate";
890
+ }
891
+ function pidAlive(pid) {
892
+ try {
893
+ process.kill(pid, 0);
894
+ return true;
895
+ } catch (error) {
896
+ return error.code === "EPERM";
897
+ }
898
+ }
899
+ function killQemuGroup(pid) {
900
+ try {
901
+ process.kill(-pid, "SIGKILL");
902
+ } catch (error) {
903
+ if (error.code === "ESRCH") return;
904
+ try {
905
+ process.kill(pid, "SIGKILL");
906
+ } catch {
907
+ }
908
+ }
909
+ }
910
+ function readHeartbeat(root) {
911
+ try {
912
+ return JSON.parse(readFileSync2(join3(root, "guard-state.json"), "utf8"));
913
+ } catch {
914
+ return null;
915
+ }
916
+ }
917
+ function persistWatchdogTermination(root, raw, identity, watchdogPid) {
918
+ try {
919
+ if (!matchesIdentity(parseHeartbeat(raw), identity, watchdogPid)) return;
920
+ const evidence = raw;
921
+ if (!evidence.limits || typeof evidence.startedAt !== "string") return;
922
+ writeGuardEvidence(root, {
923
+ ...evidence,
924
+ status: "terminated",
925
+ reason: "host watchdog detected a missing supervisor or invalid guard heartbeat",
926
+ lastCheckedAt: (/* @__PURE__ */ new Date()).toISOString()
927
+ });
928
+ } catch {
929
+ }
930
+ }
931
+ function runWatchdog(root, identity) {
932
+ const startedAt = Date.now();
933
+ let finished = false;
934
+ const finish = (code) => {
935
+ if (finished) return;
936
+ finished = true;
937
+ clearInterval(timer);
938
+ process.exitCode = code;
939
+ };
940
+ const check = () => {
941
+ const evidence = readHeartbeat(root);
942
+ const decision = watchdogDecision(
943
+ evidence,
944
+ identity,
945
+ process.pid,
946
+ startedAt,
947
+ Date.now(),
948
+ pidAlive(identity.wrapperPid),
949
+ pidAlive(identity.qemuPid)
950
+ );
951
+ if (decision === "finished") {
952
+ cleanupRelays(root, identity.runId);
953
+ finish(0);
954
+ } else if (decision === "terminate") {
955
+ persistWatchdogTermination(root, evidence, identity, process.pid);
956
+ killQemuGroup(identity.qemuPid);
957
+ cleanupRelays(root, identity.runId);
958
+ finish(1);
959
+ }
960
+ };
961
+ const timer = setInterval(check, WATCH_INTERVAL_MS);
962
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
963
+ process.on(signal, () => finish(0));
964
+ check();
965
+ }
966
+ function encodedIdentity(identity) {
967
+ return JSON.stringify(identity);
968
+ }
969
+ function startWatchdog(root, identity, onUnexpectedExit) {
970
+ const child = spawn(
971
+ process.execPath,
972
+ [join3(root, "vm-qemu.js"), WATCHDOG_ARG, encodedIdentity(identity)],
973
+ {
974
+ detached: true,
975
+ stdio: "ignore",
976
+ env: process.env
977
+ }
978
+ );
979
+ child.unref();
980
+ child.on("error", onUnexpectedExit);
981
+ child.on("exit", () => onUnexpectedExit());
982
+ return child;
983
+ }
984
+ function parseWatchdogInvocation(args) {
985
+ if (args.length !== 2 || args[0] !== WATCHDOG_ARG) return null;
986
+ try {
987
+ const value = JSON.parse(args[1] ?? "");
988
+ if (typeof value.runId !== "string" || !/^[0-9a-f-]{36}$/i.test(value.runId) || !Number.isInteger(value.wrapperPid) || (value.wrapperPid ?? 0) <= 1 || !Number.isInteger(value.qemuPid) || (value.qemuPid ?? 0) <= 1 || typeof value.networkHash !== "string" || !/^[a-f0-9]{64}$/i.test(value.networkHash))
989
+ return null;
990
+ return value;
991
+ } catch {
992
+ return null;
993
+ }
994
+ }
995
+
996
+ // src/vm/qemu-process.ts
997
+ function signalVmGroup(child, signal) {
998
+ if (!child.pid) return true;
999
+ try {
1000
+ process.kill(-child.pid, signal);
1001
+ return true;
1002
+ } catch (error) {
1003
+ if (error.code === "ESRCH") return true;
1004
+ try {
1005
+ return child.kill(signal);
1006
+ } catch {
1007
+ return false;
1008
+ }
1009
+ }
1010
+ }
1011
+ function watchdogExitNeedsTermination(qemuExited) {
1012
+ return !qemuExited;
1013
+ }
1014
+ function stopWatchdog(watchdog) {
1015
+ if (!watchdog?.pid) return;
1016
+ try {
1017
+ process.kill(-watchdog.pid, "SIGTERM");
1018
+ } catch {
1019
+ try {
1020
+ watchdog.kill("SIGTERM");
1021
+ } catch {
1022
+ }
1023
+ }
1024
+ }
1025
+ function startIdentity(root) {
1026
+ const request = JSON.parse(readFileSync3(join4(root, "start-request.json"), "utf8"));
1027
+ if (!request.runId || !/^[0-9a-f-]{36}$/i.test(request.runId))
1028
+ throw new Error("Missing host startup identity");
1029
+ return request.runId;
1030
+ }
1031
+ function attachBoundedOutput(child, limit, terminate) {
1032
+ let bytes = 0;
1033
+ let logged = 0;
1034
+ const consume = (chunk) => {
1035
+ bytes += chunk.length;
1036
+ const remaining = Math.max(0, 64 * 1024 - logged);
1037
+ if (remaining) {
1038
+ const output = chunk.subarray(0, remaining);
1039
+ process.stderr.write(output);
1040
+ logged += output.length;
1041
+ }
1042
+ if (bytes > limit) terminate("QEMU diagnostic output exceeds its limit");
1043
+ };
1044
+ child.stdout?.on("data", consume);
1045
+ child.stderr?.on("data", consume);
1046
+ }
1047
+ function runGuardedQemu(root, qemu, args, networkHash, resources) {
1048
+ const runId = startIdentity(root);
1049
+ const limits = guardLimits(resources);
1050
+ const hardenedArgs = [...args, "-no-user-config", "-nodefaults"];
1051
+ const child = spawn2(qemu, hardenedArgs, {
1052
+ stdio: ["ignore", "pipe", "pipe"],
1053
+ detached: true,
1054
+ env: process.env
1055
+ });
1056
+ const qemuPid = child.pid;
1057
+ if (!qemuPid) {
1058
+ child.on("error", () => {
1059
+ process.exitCode = 1;
1060
+ });
1061
+ return;
1062
+ }
1063
+ let stopping = false;
1064
+ let qemuExited = false;
1065
+ let killTimer;
1066
+ let guard;
1067
+ let watchdog;
1068
+ const terminate = (reason) => {
1069
+ if (stopping) return;
1070
+ stopping = true;
1071
+ guard?.finish("terminated", reason);
1072
+ cleanupRelays(root, runId);
1073
+ process.stderr.write(`[conveyor-vm] Stopping VM: ${reason}
1074
+ `);
1075
+ signalVmGroup(child, "SIGTERM");
1076
+ killTimer = setTimeout(() => signalVmGroup(child, "SIGKILL"), 2e3);
1077
+ killTimer.unref();
1078
+ };
1079
+ const watchdogIdentity = { runId, wrapperPid: process.pid, qemuPid, networkHash };
1080
+ const watchdogFailure = () => {
1081
+ if (watchdogExitNeedsTermination(qemuExited)) terminate("host watchdog exited unexpectedly");
1082
+ };
1083
+ attachBoundedOutput(child, limits.maxOutputBytes, terminate);
1084
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
1085
+ process.on(signal, () => terminate(`host received ${signal}`));
1086
+ process.on("uncaughtException", () => terminate("host supervisor exception"));
1087
+ process.on("unhandledRejection", () => terminate("host supervisor rejection"));
1088
+ child.on("error", () => terminate("QEMU process error"));
1089
+ child.on("exit", (code) => {
1090
+ qemuExited = true;
1091
+ if (killTimer) clearTimeout(killTimer);
1092
+ stopWatchdog(watchdog);
1093
+ cleanupRelays(root, runId);
1094
+ signalVmGroup(child, "SIGKILL");
1095
+ guard?.finish("stopped");
1096
+ process.exitCode = stopping ? 1 : code ?? 1;
1097
+ });
1098
+ try {
1099
+ watchdog = startWatchdog(root, watchdogIdentity, watchdogFailure);
1100
+ if (!watchdog.pid) throw new Error("host watchdog did not start");
1101
+ const identity = { ...watchdogIdentity, watchdogPid: watchdog.pid };
1102
+ writeFileSync3(
1103
+ join4(root, "applied-network.json"),
1104
+ JSON.stringify({ ...identity, args: hardenedArgs, startedAt: (/* @__PURE__ */ new Date()).toISOString() }),
1105
+ { mode: 384 }
1106
+ );
1107
+ guard = startResourceGuard(root, identity, limits, terminate);
1108
+ } catch {
1109
+ terminate("failed to record or start host resource supervision");
1110
+ }
1111
+ }
1112
+
1113
+ // src/vm-qemu.ts
1114
+ function main() {
1115
+ const root = dirname(fileURLToPath(import.meta.url));
1116
+ const watchdog = parseWatchdogInvocation(process.argv.slice(2));
1117
+ if (watchdog) {
1118
+ runWatchdog(root, watchdog);
1119
+ return;
1120
+ }
1121
+ if (process.argv[2] === "--conveyor-vm-watchdog")
1122
+ throw new Error("Invalid VM watchdog invocation");
1123
+ const relay = parseRelayInvocation(process.argv.slice(2));
1124
+ if (relay) {
1125
+ void runRelay(root, relay).catch((error) => {
1126
+ process.stderr.write(
1127
+ `[conveyor-vm] ${error instanceof Error ? error.message : String(error)}
1128
+ `
1129
+ );
1130
+ process.exitCode = 1;
1131
+ });
1132
+ return;
1133
+ }
1134
+ const state = JSON.parse(readFileSync4(join5(root, "state.json"), "utf8"));
1135
+ if (state.schema !== 1 || !state.qemu.startsWith("/") || state.qemu.includes("wrapper")) {
1136
+ throw new Error("Invalid QEMU host state");
1137
+ }
1138
+ const networkText = readFileSync4(join5(root, "network.json"), "utf8");
1139
+ const network = JSON.parse(networkText);
1140
+ const args = process.argv.slice(2);
1141
+ const original = args.find((arg) => arg.startsWith("user,"));
1142
+ const sshPort = Number(
1143
+ original?.match(/hostfwd=tcp:127\.0\.0\.1:(\d+)-(?:192\.168\.5\.15)?:22/)?.[1] ?? 0
1144
+ );
1145
+ const rewritten = rewriteQemuArgs(args, { ...network, sshPort }, join5(root, "qemu-relay"), {
1146
+ instanceDir: join5(root, "lima", "conveyor")
1147
+ });
1148
+ if (original) {
1149
+ const expected = process.platform === "darwin" ? "hvf" : "kvm";
1150
+ if (!rewritten.some((arg) => arg.includes(`accel=${expected}`)) && !rewritten.some((arg, index) => arg === "-accel" && rewritten[index + 1] === expected)) {
1151
+ throw new Error("Hardware virtualization is required; refusing emulation fallback");
1152
+ }
1153
+ runGuardedQemu(
1154
+ root,
1155
+ state.qemu,
1156
+ rewritten,
1157
+ createHash("sha256").update(networkText).digest("hex"),
1158
+ state.resources
1159
+ );
1160
+ return;
1161
+ }
1162
+ const child = spawn3(state.qemu, rewritten, { stdio: "inherit", env: process.env });
1163
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
1164
+ process.on(signal, () => {
1165
+ child.kill(signal);
1166
+ });
1167
+ }
1168
+ child.on("error", () => {
1169
+ process.stderr.write("QEMU could not start.\n");
1170
+ process.exitCode = 1;
1171
+ });
1172
+ child.on("exit", (code) => {
1173
+ process.exitCode = code ?? 1;
1174
+ });
1175
+ }
1176
+ try {
1177
+ main();
1178
+ } catch (error) {
1179
+ process.stderr.write(`[conveyor-vm] ${error instanceof Error ? error.message : String(error)}
1180
+ `);
1181
+ process.exitCode = 1;
1182
+ }