@forgezero/agent 0.1.87 → 0.1.89

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.
@@ -355,7 +355,12 @@ async function executeSoftwareOperation(operation) {
355
355
  }
356
356
  if (software === "nginx") {
357
357
  const binary = await run(["/usr/sbin/nginx", "-v"]);
358
- return binary.exitCode === 0 ? run(["/usr/bin/systemctl", "is-active", "--quiet", "nginx.service"]) : binary;
358
+ if (binary.exitCode !== 0)
359
+ return binary;
360
+ if (!existsSync("/usr/lib/nginx/modules/ngx_http_headers_more_filter_module.so")) {
361
+ return { exitCode: 1, output: "Nginx response-header suppression module is unavailable" };
362
+ }
363
+ return run(["/usr/bin/systemctl", "is-active", "--quiet", "nginx.service"]);
359
364
  }
360
365
  if (software === "arangodb") {
361
366
  const binary = await run(["/usr/sbin/arangod", "--version"]);
@@ -387,7 +392,7 @@ async function executeSoftwareOperation(operation) {
387
392
  }
388
393
  if (software === "docker" || software === "nginx" || software === "ufw" || software === "openssh-client" || software === "git") {
389
394
  const packageName = software === "openssh-client" ? "openssh-client" : software === "docker" ? "docker.io" : software;
390
- const installed = await aptInstall(packageName);
395
+ const installed = software === "nginx" ? await aptInstallMany(["nginx", "libnginx-mod-http-headers-more-filter"]) : await aptInstall(packageName);
391
396
  if (installed.exitCode !== 0 || software !== "nginx" && software !== "docker")
392
397
  return installed;
393
398
  if (software === "docker") {
@@ -112,6 +112,8 @@ export interface UnitOptions {
112
112
  enforceEgress?: boolean;
113
113
  /** Provisioning-owned runner health endpoints; repository commands cannot change them. */
114
114
  runnerLoopbackPorts?: readonly number[];
115
+ /** Local platform API ports needed by the credential-bearing Agent. */
116
+ agentLoopbackPorts?: readonly number[];
115
117
  /** Vetted public TCP surface for project commands. Defaults to HTTPS only. */
116
118
  runnerPublicTcpPorts?: readonly number[];
117
119
  /** Enable PQ-authenticated lifecycle claims through a constrained root helper. */
@@ -172,7 +174,7 @@ export declare const WARP_SERVICE_DROP_IN_PATH = "/etc/systemd/system/warp-svc.s
172
174
  export declare const AGENT_EGRESS_UNIT_PATH = "/etc/systemd/system/forgezero-agent-egress.service";
173
175
  export declare const DEFAULT_RUNNER_PUBLIC_TCP_PORTS: readonly [443];
174
176
  /** Root-owned policy monitor. The credential-bearing Agent is bound to it. */
175
- export declare function agentEgressUnit(options: Pick<UnitOptions, 'binPath' | 'user' | 'repository' | 'pullDeployments' | 'bootstrapBundlePath' | 'bootstrapBundleManifestPath' | 'runnerLoopbackPorts' | 'runnerPublicTcpPorts'>): string;
177
+ export declare function agentEgressUnit(options: Pick<UnitOptions, 'binPath' | 'user' | 'repository' | 'pullDeployments' | 'bootstrapBundlePath' | 'bootstrapBundleManifestPath' | 'runnerLoopbackPorts' | 'runnerPublicTcpPorts' | 'agentLoopbackPorts'>): string;
176
178
  /** Root may execute only package-owned OS strategies selected by id + version. */
177
179
  export declare function softwareHelperUnit(options: Pick<UnitOptions, 'binPath' | 'deployRoot'>): string;
178
180
  /** Fixed root boundary for verified, health-gated Agent replacement. */
@@ -281,8 +283,10 @@ export type ProvisionOperation = {
281
283
  path: string;
282
284
  } | {
283
285
  kind: 'verify-egress';
286
+ deploymentEnabled: boolean;
284
287
  runnerPublicTcpPorts: readonly number[];
285
288
  runnerLoopbackPorts: readonly number[];
289
+ agentLoopbackPorts: readonly number[];
286
290
  } | {
287
291
  kind: 'verify-resolved-stub';
288
292
  } | {
package/dist/provision.js CHANGED
@@ -284,7 +284,7 @@ function probeAgentCandidate(expected, socketPath = DEFAULT_AGENT_CANDIDATE_READ
284
284
  return;
285
285
  try {
286
286
  const value = JSON.parse(buffer.slice(0, newline));
287
- finish(value.version === expected.version && typeof value.nodeKey === "string" && typeof value.bound === "boolean" && ["ready", "partial", "unavailable", "unbound"].includes(value.vault ?? ""));
287
+ finish(value.version === expected.version && value.nodeKey === expected.nodeKey && value.bound === expected.bound && expected.vault.includes(value.vault));
288
288
  } catch {
289
289
  finish(false);
290
290
  }
@@ -487,13 +487,18 @@ var restartAgent = async (target, run) => {
487
487
  }
488
488
  };
489
489
  var candidateUnit = (target) => target === "compute" ? "forgezero-agent-candidate.service" : "forgezero-metal-agent-candidate.service";
490
- var startCandidate = async (staged, target, run, probe = (expected) => probeAgentCandidate(expected, DEFAULT_AGENT_CANDIDATE_READY_SOCKET)) => {
490
+ var startCandidate = async (staged, target, expectedNodeKey, run, probe = (expected) => probeAgentCandidate(expected, DEFAULT_AGENT_CANDIDATE_READY_SOCKET)) => {
491
491
  selectAgentCandidate(staged);
492
492
  if (!await runOk(run, "/usr/bin/systemctl", ["restart", candidateUnit(target)])) {
493
493
  clearAgentCandidate(dirname3(staged.currentLink));
494
494
  throw new Error(`systemd could not start ${candidateUnit(target)}`);
495
495
  }
496
- if (!await probe({ version: staged.version })) {
496
+ if (!await probe({
497
+ version: staged.version,
498
+ nodeKey: expectedNodeKey,
499
+ bound: true,
500
+ vault: target === "compute" ? ["ready"] : ["unbound"]
501
+ })) {
497
502
  await run({ command: "/usr/bin/systemctl", args: ["stop", candidateUnit(target)] });
498
503
  clearAgentCandidate(dirname3(staged.currentLink));
499
504
  throw new Error("the candidate Agent did not prove its identity and durable state");
@@ -572,8 +577,9 @@ async function activateAgentRelease(staged, options = {}) {
572
577
  writeUpdateState(journalPath, receiptPath, journal);
573
578
  let selectionAttempted = false;
574
579
  try {
575
- if (!options.candidatePrepared)
576
- await startCandidate(staged, target, run);
580
+ if (!options.candidatePrepared) {
581
+ throw new Error("direct activation requires a separately identity-bound candidate preparation");
582
+ }
577
583
  if (target === "compute")
578
584
  switchAgentSocketRoute(paths.route, paths.candidate);
579
585
  selectionAttempted = true;
@@ -763,6 +769,9 @@ function startAgentUpdateHelper(options = {}) {
763
769
  }
764
770
  if (request.op !== "prepare")
765
771
  throw new Error("unknown update operation");
772
+ if (typeof request.expectedNodeKey !== "string" || request.expectedNodeKey.length < 16 || Buffer.byteLength(request.expectedNodeKey, "utf8") > 512 || /[\0\r\n]/.test(request.expectedNodeKey)) {
773
+ throw new Error("Agent update expected node identity is invalid");
774
+ }
766
775
  if (busy)
767
776
  throw new Error("another Agent update or recovery is already active");
768
777
  const prior = readJournal(journalPath, releaseRoot);
@@ -775,8 +784,8 @@ function startAgentUpdateHelper(options = {}) {
775
784
  currentVersion: request.currentVersion,
776
785
  root: releaseRoot
777
786
  });
778
- await startCandidate(staged, request.target, runCommand);
779
- pending = { staged, target: request.target, attemptId };
787
+ await startCandidate(staged, request.target, request.expectedNodeKey, runCommand);
788
+ pending = { staged, target: request.target, attemptId, expectedNodeKey: request.expectedNodeKey };
780
789
  ownsBusy = false;
781
790
  setTimer(() => {
782
791
  if (pending?.attemptId !== attemptId)
@@ -1189,7 +1198,12 @@ async function executeSoftwareOperation(operation) {
1189
1198
  }
1190
1199
  if (software === "nginx") {
1191
1200
  const binary = await run(["/usr/sbin/nginx", "-v"]);
1192
- return binary.exitCode === 0 ? run(["/usr/bin/systemctl", "is-active", "--quiet", "nginx.service"]) : binary;
1201
+ if (binary.exitCode !== 0)
1202
+ return binary;
1203
+ if (!existsSync4("/usr/lib/nginx/modules/ngx_http_headers_more_filter_module.so")) {
1204
+ return { exitCode: 1, output: "Nginx response-header suppression module is unavailable" };
1205
+ }
1206
+ return run(["/usr/bin/systemctl", "is-active", "--quiet", "nginx.service"]);
1193
1207
  }
1194
1208
  if (software === "arangodb") {
1195
1209
  const binary = await run(["/usr/sbin/arangod", "--version"]);
@@ -1221,7 +1235,7 @@ async function executeSoftwareOperation(operation) {
1221
1235
  }
1222
1236
  if (software === "docker" || software === "nginx" || software === "ufw" || software === "openssh-client" || software === "git") {
1223
1237
  const packageName = software === "openssh-client" ? "openssh-client" : software === "docker" ? "docker.io" : software;
1224
- const installed = await aptInstall(packageName);
1238
+ const installed = software === "nginx" ? await aptInstallMany(["nginx", "libnginx-mod-http-headers-more-filter"]) : await aptInstall(packageName);
1225
1239
  if (installed.exitCode !== 0 || software !== "nginx" && software !== "docker")
1226
1240
  return installed;
1227
1241
  if (software === "docker") {
@@ -1373,11 +1387,22 @@ async function ensureSoftwareRequirements(requirementsInput, options) {
1373
1387
  }
1374
1388
 
1375
1389
  // src/capacity-calibration.ts
1376
- var percentile95 = (values) => {
1390
+ import { cpus, freemem, totalmem } from "node:os";
1391
+ var percentile = (values, quantile) => {
1377
1392
  if (values.length === 0)
1378
1393
  return Number.POSITIVE_INFINITY;
1379
1394
  const sorted = values.toSorted((left, right) => left - right);
1380
- return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)];
1395
+ return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * quantile) - 1)];
1396
+ };
1397
+ var hostSample = () => {
1398
+ let cpuIdle = 0;
1399
+ let cpuTotal = 0;
1400
+ for (const cpu of cpus()) {
1401
+ cpuIdle += cpu.times.idle;
1402
+ cpuTotal += Object.values(cpu.times).reduce((sum, value) => sum + value, 0);
1403
+ }
1404
+ const memoryTotal = totalmem();
1405
+ return { cpuIdle, cpuTotal, memoryUsed: memoryTotal - freemem(), memoryTotal };
1381
1406
  };
1382
1407
  function localCalibrationEndpoint(value) {
1383
1408
  const endpoint = new URL(value);
@@ -1392,9 +1417,12 @@ function validateCapacityCalibrationOptions(options) {
1392
1417
  const requestsPerWorker = options.requestsPerWorker ?? 8;
1393
1418
  const maxP95Ms = options.maxP95Ms ?? 250;
1394
1419
  const maxErrorRate = options.maxErrorRate ?? 0.01;
1395
- const headroomRatio = options.headroomRatio ?? 0.8;
1420
+ const safetyRatio = options.safetyRatio ?? 0.8;
1396
1421
  const requestTimeoutMs = options.requestTimeoutMs ?? 5000;
1397
- if (!Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 4096 || !Number.isSafeInteger(requestsPerWorker) || requestsPerWorker < 2 || requestsPerWorker > 100 || !Number.isFinite(maxP95Ms) || maxP95Ms < 1 || !Number.isFinite(maxErrorRate) || maxErrorRate < 0 || maxErrorRate > 0.2 || !Number.isFinite(headroomRatio) || headroomRatio < 0.25 || headroomRatio > 0.95 || !Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs < 100 || requestTimeoutMs > 30000) {
1422
+ const minimumStageDurationMs = options.minimumStageDurationMs ?? 5000;
1423
+ const maxCpuUtilizationPercent = options.maxCpuUtilizationPercent ?? 90;
1424
+ const maxMemoryUtilizationPercent = options.maxMemoryUtilizationPercent ?? 90;
1425
+ if (!Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 4096 || !Number.isSafeInteger(requestsPerWorker) || requestsPerWorker < 2 || requestsPerWorker > 100 || !Number.isFinite(maxP95Ms) || maxP95Ms < 1 || !Number.isFinite(maxErrorRate) || maxErrorRate < 0 || maxErrorRate > 0.2 || !Number.isFinite(safetyRatio) || safetyRatio < 0.25 || safetyRatio > 0.95 || !Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs < 100 || requestTimeoutMs > 30000 || !Number.isSafeInteger(minimumStageDurationMs) || minimumStageDurationMs < 100 || minimumStageDurationMs > 60000 || !Number.isFinite(maxCpuUtilizationPercent) || maxCpuUtilizationPercent < 25 || maxCpuUtilizationPercent > 100 || !Number.isFinite(maxMemoryUtilizationPercent) || maxMemoryUtilizationPercent < 25 || maxMemoryUtilizationPercent > 100) {
1398
1426
  throw new Error("Capacity calibration bounds are invalid.");
1399
1427
  }
1400
1428
  return {
@@ -1403,18 +1431,24 @@ function validateCapacityCalibrationOptions(options) {
1403
1431
  requestsPerWorker,
1404
1432
  maxP95Ms,
1405
1433
  maxErrorRate,
1406
- headroomRatio,
1407
- requestTimeoutMs
1434
+ safetyRatio,
1435
+ requestTimeoutMs,
1436
+ minimumStageDurationMs,
1437
+ maxCpuUtilizationPercent,
1438
+ maxMemoryUtilizationPercent
1408
1439
  };
1409
1440
  }
1410
- async function stage(endpoint, concurrency, requestsPerWorker, timeoutMs, fetcher) {
1441
+ async function stage(endpoint, concurrency, requestsPerWorker, timeoutMs, minimumDurationMs, fetcher, sampler) {
1411
1442
  const latencies = [];
1412
1443
  let succeeded = 0;
1413
1444
  let failed = 0;
1414
1445
  let overloaded = 0;
1446
+ const systemBefore = sampler();
1415
1447
  const started = performance.now();
1416
1448
  await Promise.all(Array.from({ length: concurrency }, async () => {
1417
- for (let request = 0;request < requestsPerWorker; request += 1) {
1449
+ let request = 0;
1450
+ while (request < requestsPerWorker || performance.now() - started < minimumDurationMs) {
1451
+ request += 1;
1418
1452
  const requestStarted = performance.now();
1419
1453
  try {
1420
1454
  const response = await fetcher(endpoint, {
@@ -1438,50 +1472,74 @@ async function stage(endpoint, concurrency, requestsPerWorker, timeoutMs, fetche
1438
1472
  }
1439
1473
  }
1440
1474
  }));
1441
- const elapsedSeconds = Math.max((performance.now() - started) / 1000, 0.001);
1475
+ const durationMs = Math.max(performance.now() - started, 1);
1476
+ const elapsedSeconds = durationMs / 1000;
1477
+ const systemAfter = sampler();
1478
+ const cpuTotal = Math.max(0, systemAfter.cpuTotal - systemBefore.cpuTotal);
1479
+ const cpuIdle = Math.max(0, systemAfter.cpuIdle - systemBefore.cpuIdle);
1480
+ const cpuUtilizationPercent = cpuTotal === 0 ? 0 : (cpuTotal - cpuIdle) / cpuTotal * 100;
1481
+ const memoryUtilizationPercent = Math.max(systemBefore.memoryTotal > 0 ? systemBefore.memoryUsed / systemBefore.memoryTotal * 100 : 0, systemAfter.memoryTotal > 0 ? systemAfter.memoryUsed / systemAfter.memoryTotal * 100 : 0);
1482
+ const requests = succeeded + failed;
1442
1483
  return {
1443
1484
  concurrency,
1444
- requests: concurrency * requestsPerWorker,
1485
+ requests,
1445
1486
  succeeded,
1446
1487
  failed,
1447
1488
  overloaded,
1448
- throughputPerSecond: Number(((succeeded + failed) / elapsedSeconds).toFixed(2)),
1449
- p95Ms: Number(percentile95(latencies).toFixed(2))
1489
+ successfulRequestsPerSecond: Number((succeeded / elapsedSeconds).toFixed(2)),
1490
+ attemptedRequestsPerSecond: Number((requests / elapsedSeconds).toFixed(2)),
1491
+ durationMs: Number(durationMs.toFixed(2)),
1492
+ errorRate: Number((failed / Math.max(requests, 1)).toFixed(6)),
1493
+ p95Ms: Number(percentile(latencies, 0.95).toFixed(2)),
1494
+ p99Ms: Number(percentile(latencies, 0.99).toFixed(2)),
1495
+ cpuUtilizationPercent: Number(cpuUtilizationPercent.toFixed(2)),
1496
+ memoryUtilizationPercent: Number(memoryUtilizationPercent.toFixed(2))
1450
1497
  };
1451
1498
  }
1452
- async function calibrateHttpConcurrency(options, fetcher = fetch) {
1499
+ async function calibrateHttpConcurrency(options, fetcher = fetch, sampler = hostSample) {
1453
1500
  const {
1454
1501
  endpoint,
1455
1502
  maxConcurrency,
1456
1503
  requestsPerWorker,
1457
1504
  maxP95Ms,
1458
1505
  maxErrorRate,
1459
- headroomRatio,
1460
- requestTimeoutMs: timeoutMs
1506
+ safetyRatio,
1507
+ requestTimeoutMs: timeoutMs,
1508
+ minimumStageDurationMs,
1509
+ maxCpuUtilizationPercent,
1510
+ maxMemoryUtilizationPercent
1461
1511
  } = validateCapacityCalibrationOptions(options);
1462
1512
  const stages = [];
1463
- let lastSafe = 1;
1513
+ let lastSafe;
1464
1514
  let stopReason = "maximum-tested";
1465
1515
  for (let concurrency = 1;; concurrency = Math.min(maxConcurrency, concurrency * 2)) {
1466
- const measured = await stage(endpoint, concurrency, requestsPerWorker, timeoutMs, fetcher);
1516
+ const measured = await stage(endpoint, concurrency, requestsPerWorker, timeoutMs, minimumStageDurationMs, fetcher, sampler);
1467
1517
  stages.push(measured);
1468
- const errorRate = measured.failed / measured.requests;
1469
1518
  const previous = stages.at(-2);
1470
- const throughputRegressed = Boolean(previous && concurrency > 1 && measured.throughputPerSecond < previous.throughputPerSecond * 0.9);
1471
- if (measured.overloaded > 0 || errorRate > maxErrorRate)
1519
+ const throughputRegressed = Boolean(previous && concurrency > 1 && measured.successfulRequestsPerSecond < previous.successfulRequestsPerSecond * 0.9);
1520
+ if (measured.overloaded > 0 || measured.errorRate > maxErrorRate)
1472
1521
  stopReason = "errors";
1473
1522
  else if (measured.p95Ms > maxP95Ms)
1474
1523
  stopReason = "latency";
1524
+ else if (measured.cpuUtilizationPercent > maxCpuUtilizationPercent)
1525
+ stopReason = "cpu";
1526
+ else if (measured.memoryUtilizationPercent > maxMemoryUtilizationPercent)
1527
+ stopReason = "memory";
1475
1528
  else if (throughputRegressed)
1476
1529
  stopReason = "throughput-regression";
1477
1530
  else
1478
- lastSafe = concurrency;
1531
+ lastSafe = measured;
1479
1532
  if (stopReason !== "maximum-tested" || concurrency === maxConcurrency)
1480
1533
  break;
1481
1534
  }
1535
+ if (!lastSafe)
1536
+ throw new Error("Capacity calibration found no safe request stage.");
1482
1537
  return {
1483
1538
  endpoint: endpoint.toString(),
1484
- recommendedConcurrency: Math.max(1, Math.floor(lastSafe * headroomRatio)),
1539
+ measuredSustainableRequestsPerSecond: lastSafe.successfulRequestsPerSecond,
1540
+ allowedRequestsPerSecond: Math.max(1, Math.floor(lastSafe.successfulRequestsPerSecond * safetyRatio)),
1541
+ safetyRatio,
1542
+ recommendedConcurrency: Math.max(1, Math.floor(lastSafe.concurrency * safetyRatio)),
1485
1543
  stopReason,
1486
1544
  stages
1487
1545
  };
@@ -1558,8 +1616,11 @@ function capacityCalibration(value, where) {
1558
1616
  "requestsPerWorker",
1559
1617
  "maxP95Ms",
1560
1618
  "maxErrorRate",
1561
- "headroomRatio",
1562
- "requestTimeoutMs"
1619
+ "safetyRatio",
1620
+ "requestTimeoutMs",
1621
+ "minimumStageDurationMs",
1622
+ "maxCpuUtilizationPercent",
1623
+ "maxMemoryUtilizationPercent"
1563
1624
  ], where);
1564
1625
  const endpoint = text(calibration.endpoint, `${where}.endpoint`);
1565
1626
  try {
@@ -1583,8 +1644,11 @@ function capacityCalibration(value, where) {
1583
1644
  "requestsPerWorker",
1584
1645
  "maxP95Ms",
1585
1646
  "maxErrorRate",
1586
- "headroomRatio",
1587
- "requestTimeoutMs"
1647
+ "safetyRatio",
1648
+ "requestTimeoutMs",
1649
+ "minimumStageDurationMs",
1650
+ "maxCpuUtilizationPercent",
1651
+ "maxMemoryUtilizationPercent"
1588
1652
  ].flatMap((name) => {
1589
1653
  const found = optionalNumber(name);
1590
1654
  return found === undefined ? [] : [[name, found]];
@@ -2775,35 +2839,76 @@ function requestServiceActivation(request, socketPath = DEFAULT_SOFTWARE_HELPER_
2775
2839
  function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCKET, timeoutMs = 15 * 60000) {
2776
2840
  validateSoftwareRequirements(requirements);
2777
2841
  return new Promise((resolve5, reject) => {
2778
- const socket = connect3(socketPath, () => socket.write(`${JSON.stringify({ op: "ensure", requirements })}
2779
- `));
2780
- let buffer = "";
2781
- socket.setTimeout(timeoutMs, () => {
2782
- socket.destroy();
2783
- reject(new Error("software helper did not answer before its deadline"));
2784
- });
2785
- socket.on("data", (chunk) => {
2786
- buffer += chunk.toString("utf8");
2787
- const newline = buffer.indexOf(`
2788
- `);
2789
- if (newline < 0)
2842
+ const deadline = Date.now() + timeoutMs;
2843
+ let settled = false;
2844
+ const finish = (cause, results) => {
2845
+ if (settled)
2790
2846
  return;
2791
- socket.end();
2847
+ settled = true;
2848
+ if (cause)
2849
+ reject(cause);
2850
+ else
2851
+ resolve5(results);
2852
+ };
2853
+ const attempt = () => {
2854
+ if (settled)
2855
+ return;
2856
+ const remaining = deadline - Date.now();
2857
+ if (remaining <= 0)
2858
+ return finish(new Error("software helper did not answer before its deadline"));
2859
+ if (!existsSync7(socketPath)) {
2860
+ setTimeout(attempt, Math.min(100, Math.max(1, remaining)));
2861
+ return;
2862
+ }
2863
+ let buffer = "";
2864
+ let socket;
2792
2865
  try {
2793
- const response = JSON.parse(buffer.slice(0, newline));
2794
- if (!response.ok || !response.results)
2795
- throw new Error(response.error?.message ?? "software helper refused the request");
2796
- resolve5(response.results);
2866
+ socket = connect3(socketPath, () => socket.write(`${JSON.stringify({ op: "ensure", requirements })}
2867
+ `));
2797
2868
  } catch (cause) {
2798
- reject(cause);
2869
+ const error = cause;
2870
+ if ((error.code === "ENOENT" || error.code === "ECONNREFUSED") && Date.now() < deadline) {
2871
+ setTimeout(attempt, Math.min(100, Math.max(1, deadline - Date.now())));
2872
+ return;
2873
+ }
2874
+ finish(error);
2875
+ return;
2799
2876
  }
2800
- });
2801
- socket.on("error", reject);
2877
+ socket.setTimeout(remaining, () => {
2878
+ socket.destroy();
2879
+ finish(new Error("software helper did not answer before its deadline"));
2880
+ });
2881
+ socket.on("data", (chunk) => {
2882
+ buffer += chunk.toString("utf8");
2883
+ const newline = buffer.indexOf(`
2884
+ `);
2885
+ if (newline < 0)
2886
+ return;
2887
+ socket.end();
2888
+ try {
2889
+ const response = JSON.parse(buffer.slice(0, newline));
2890
+ if (!response.ok || !response.results)
2891
+ throw new Error(response.error?.message ?? "software helper refused the request");
2892
+ finish(undefined, response.results);
2893
+ } catch (cause) {
2894
+ finish(cause);
2895
+ }
2896
+ });
2897
+ socket.on("error", (cause) => {
2898
+ socket.destroy();
2899
+ if ((cause.code === "ENOENT" || cause.code === "ECONNREFUSED") && Date.now() < deadline) {
2900
+ setTimeout(attempt, Math.min(100, Math.max(1, deadline - Date.now())));
2901
+ return;
2902
+ }
2903
+ finish(cause);
2904
+ });
2905
+ };
2906
+ attempt();
2802
2907
  });
2803
2908
  }
2804
2909
 
2805
2910
  // src/version.ts
2806
- var VERSION3 = "0.1.87";
2911
+ var VERSION3 = "0.1.89";
2807
2912
 
2808
2913
  // src/egress-policy.ts
2809
2914
  import { realpathSync as realpathSync3 } from "node:fs";
@@ -2939,6 +3044,7 @@ function agentEgressUnit(options) {
2939
3044
  }
2940
3045
  const deploymentEnabled = Boolean(options.repository || bootstrapBundleEnabled || options.pullDeployments);
2941
3046
  const runnerLoopbackPorts = normalizeEgressTcpPorts(options.runnerLoopbackPorts ?? []);
3047
+ const agentLoopbackPorts = normalizeEgressTcpPorts(options.agentLoopbackPorts ?? []);
2942
3048
  const runnerPublicTcpPorts = normalizeEgressTcpPorts(options.runnerPublicTcpPorts ?? DEFAULT_RUNNER_PUBLIC_TCP_PORTS);
2943
3049
  if (deploymentEnabled && runnerPublicTcpPorts.length < 1) {
2944
3050
  throw new Error("deployed project runner needs at least one vetted public TCP port");
@@ -2946,8 +3052,10 @@ function agentEgressUnit(options) {
2946
3052
  if (deploymentEnabled)
2947
3053
  systemdAgentEgressDirectives(runnerLoopbackPorts);
2948
3054
  const users = [user, ...deploymentEnabled ? [DEPLOYMENT_RUNNER_USER] : []];
2949
- const runnerGrant = deploymentEnabled ? ` --loopback-user=${DEPLOYMENT_RUNNER_USER}` + runnerLoopbackPorts.map((port) => ` --loopback-tcp-port=${port}`).join("") + runnerPublicTcpPorts.map((port) => ` --public-tcp-port=${port}`).join("") : "";
2950
- const policyProof = `ExecStartPost=${bin} egress-policy-check ${users.map((name) => `--user=${name}`).join(" ")}${runnerGrant}`;
3055
+ const portGrantEnabled = deploymentEnabled || agentLoopbackPorts.length > 0;
3056
+ const restrictedUser = deploymentEnabled ? DEPLOYMENT_RUNNER_USER : user;
3057
+ const portGrant = portGrantEnabled ? ` --loopback-user=${restrictedUser}` + (deploymentEnabled ? runnerLoopbackPorts.map((port) => ` --loopback-tcp-port=${port}`).join("") : "") + agentLoopbackPorts.map((port) => ` --shared-loopback-tcp-port=${port}`).join("") + (deploymentEnabled ? runnerPublicTcpPorts.map((port) => ` --public-tcp-port=${port}`).join("") : "") : "";
3058
+ const policyProof = `ExecStartPost=${bin} egress-policy-check ${users.map((name) => `--user=${name}`).join(" ")}${portGrant}`;
2951
3059
  return `[Unit]
2952
3060
  Description=ForgeZero Agent host egress policy
2953
3061
  Documentation=https://www.forgezero.net/docs/agent
@@ -2960,7 +3068,7 @@ Type=notify
2960
3068
  NotifyAccess=all
2961
3069
  User=root
2962
3070
  Group=root
2963
- ExecStart=${bin} egress-policy ${users.map((name) => `--user=${name}`).join(" ")}${runnerGrant}
3071
+ ExecStart=${bin} egress-policy ${users.map((name) => `--user=${name}`).join(" ")}${portGrant}
2964
3072
  ${policyProof}
2965
3073
  Restart=on-failure
2966
3074
  RestartSec=2
@@ -3244,7 +3352,7 @@ function agentEnrolmentUnit(options) {
3244
3352
  Requires=forgezero-agent-egress.service
3245
3353
  BindsTo=forgezero-agent-egress.service
3246
3354
  ` : "";
3247
- const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives() : "";
3355
+ const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives(options.agentLoopbackPorts ?? []) : "";
3248
3356
  return `[Unit]
3249
3357
  Description=Bind this machine to its ForgeZero compute
3250
3358
  After=network-online.target
@@ -3281,7 +3389,7 @@ WantedBy=multi-user.target
3281
3389
  function deploymentRunnerUnit(options) {
3282
3390
  const bin = options.binPath ?? "fz-agent";
3283
3391
  const root = options.deployRoot ?? "/opt/forgezero";
3284
- const agentUser = options.user ?? "forgezero-agent";
3392
+ const agentUser = options.user ?? "forgezero";
3285
3393
  const egressDependency = options.enforceEgress ? `After=forgezero-agent-egress.service
3286
3394
  Requires=forgezero-agent-egress.service
3287
3395
  BindsTo=forgezero-agent-egress.service
@@ -3322,7 +3430,7 @@ RestrictRealtime=true
3322
3430
  MemoryDenyWriteExecute=true
3323
3431
  LockPersonality=true
3324
3432
  ${egressDirectives}
3325
- ReadWritePaths=${root}/releases ${root}/runner-home
3433
+ ReadWritePaths=${root}/releases ${root}/runner-home ${root}/slots /etc/nginx/conf.d /var/log/nginx
3326
3434
 
3327
3435
  [Install]
3328
3436
  WantedBy=multi-user.target
@@ -3457,7 +3565,7 @@ function agentUnit(options) {
3457
3565
  const supplementaryGroups = [
3458
3566
  AGENT_UPDATE_GROUP,
3459
3567
  deploymentEnabled ? DEPLOYMENT_GROUP : null,
3460
- deploymentEnabled ? SOFTWARE_HELPER_GROUP : null,
3568
+ SOFTWARE_HELPER_GROUP,
3461
3569
  lifecycleEnabled ? LIFECYCLE_GROUP : null
3462
3570
  ].filter((value) => value !== null);
3463
3571
  const deploymentGroup = supplementaryGroups.length > 0 ? `SupplementaryGroups=${supplementaryGroups.join(" ")}` : "";
@@ -3466,7 +3574,7 @@ function agentUnit(options) {
3466
3574
  "forgezero-agent-update-helper.service",
3467
3575
  options.enforceEgress ? "forgezero-agent-egress.service" : null,
3468
3576
  deploymentEnabled ? "forgezero-deploy-runner.service" : null,
3469
- deploymentEnabled ? "forgezero-software-helper.service" : null,
3577
+ "forgezero-software-helper.service",
3470
3578
  lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
3471
3579
  warpEnabled ? "warp-svc.service" : null,
3472
3580
  enrolmentEnabled ? "forgezero-agent-enrol.service" : null
@@ -3475,7 +3583,7 @@ function agentUnit(options) {
3475
3583
  "forgezero-agent-update-helper.service",
3476
3584
  options.enforceEgress ? "forgezero-agent-egress.service" : null,
3477
3585
  deploymentEnabled ? "forgezero-deploy-runner.service" : null,
3478
- deploymentEnabled ? "forgezero-software-helper.service" : null,
3586
+ "forgezero-software-helper.service",
3479
3587
  lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
3480
3588
  warpEnabled ? "warp-svc.service" : null,
3481
3589
  enrolmentEnabled ? "forgezero-agent-enrol.service" : null
@@ -3490,9 +3598,9 @@ function agentUnit(options) {
3490
3598
  const snpDevice = options.mode === "attested" ? `DevicePolicy=closed
3491
3599
  DeviceAllow=/dev/sev-guest rw` : "";
3492
3600
  const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
3493
- ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
3601
+ ExecStartPre=+/bin/chmod 0660 /dev/sev-guest
3494
3602
  ` : "";
3495
- const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives() : "";
3603
+ const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives(options.agentLoopbackPorts ?? []) : "";
3496
3604
  return `[Unit]
3497
3605
  Description=ForgeZero node agent (${options.mode})
3498
3606
  Documentation=https://www.forgezero.net/docs/agent
@@ -3623,6 +3731,7 @@ function planProvision(options) {
3623
3731
  const deployRoot = options.deployRoot ?? "/opt/forgezero";
3624
3732
  const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
3625
3733
  const runnerLoopbackPorts = normalizeEgressTcpPorts(options.runnerLoopbackPorts ?? []);
3734
+ const agentLoopbackPorts = normalizeEgressTcpPorts(options.agentLoopbackPorts ?? []);
3626
3735
  const runnerPublicTcpPorts = normalizeEgressTcpPorts(options.runnerPublicTcpPorts ?? DEFAULT_RUNNER_PUBLIC_TCP_PORTS);
3627
3736
  const lifecycleEnabled = Boolean(options.pullMigrations && options.lifecycleProfilePath);
3628
3737
  if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
@@ -3671,8 +3780,9 @@ function planProvision(options) {
3671
3780
  const enabledUnits = [
3672
3781
  "forgezero-agent.socket",
3673
3782
  "forgezero-agent-update-helper.service",
3783
+ "forgezero-software-helper.service",
3674
3784
  ...options.enforceEgress ? ["forgezero-agent-egress.service"] : [],
3675
- ...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
3785
+ ...deploymentEnabled ? ["forgezero-deploy-runner.service"] : [],
3676
3786
  ...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
3677
3787
  ...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
3678
3788
  ...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : [],
@@ -3680,8 +3790,9 @@ function planProvision(options) {
3680
3790
  ];
3681
3791
  const restartedUnits = [
3682
3792
  "forgezero-agent-update-helper.service",
3793
+ "forgezero-software-helper.service",
3683
3794
  ...options.enforceEgress ? ["forgezero-agent-egress.service"] : [],
3684
- ...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
3795
+ ...deploymentEnabled ? ["forgezero-deploy-runner.service"] : [],
3685
3796
  ...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
3686
3797
  ...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
3687
3798
  ...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : []
@@ -3705,9 +3816,9 @@ function planProvision(options) {
3705
3816
  ...options.enforceEgress ? [
3706
3817
  { path: AGENT_EGRESS_UNIT_PATH, unit: agentEgressUnit(options) }
3707
3818
  ] : [],
3819
+ { path: SOFTWARE_HELPER_UNIT_PATH, unit: softwareHelperUnit(options) },
3708
3820
  ...deploymentEnabled ? [
3709
- { path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) },
3710
- { path: SOFTWARE_HELPER_UNIT_PATH, unit: softwareHelperUnit(options) }
3821
+ { path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) }
3711
3822
  ] : [],
3712
3823
  ...enrolmentEnabled ? [
3713
3824
  { path: ENROLMENT_UNIT_PATH, unit: agentEnrolmentUnit(options) }
@@ -3740,24 +3851,29 @@ function planProvision(options) {
3740
3851
  step("Agent update helper access group", { kind: "commands", commands: [{ argv: ["/usr/sbin/groupadd", "--system", AGENT_UPDATE_GROUP], acceptedExitCodes: [0, 9] }] }),
3741
3852
  ...sourceBinPath && binPath ? [step("root-owned agent runtime", { kind: "install-runtime", source: sourceBinPath, binary: binPath, version: VERSION3 })] : [],
3742
3853
  ...warpEnabled ? [step("Cloudflare One client for Ubuntu 26.04", { kind: "install-warp" })] : [],
3743
- ...deploymentEnabled ? [step("deployment isolation group", { kind: "commands", commands: [
3744
- { argv: ["/usr/sbin/groupadd", "--system", DEPLOYMENT_GROUP], acceptedExitCodes: [0, 9] },
3854
+ step("software strategy helper group", { kind: "commands", commands: [
3745
3855
  { argv: ["/usr/sbin/groupadd", "--system", SOFTWARE_HELPER_GROUP], acceptedExitCodes: [0, 9] }
3856
+ ] }),
3857
+ ...deploymentEnabled ? [step("deployment isolation group", { kind: "commands", commands: [
3858
+ { argv: ["/usr/sbin/groupadd", "--system", DEPLOYMENT_GROUP], acceptedExitCodes: [0, 9] }
3746
3859
  ] })] : [],
3747
3860
  ...lifecycleEnabled ? [step("lifecycle helper access group", { kind: "commands", commands: [{ argv: ["/usr/sbin/groupadd", "--system", LIFECYCLE_GROUP], acceptedExitCodes: [0, 9] }] })] : [],
3748
3861
  step("service account", { kind: "commands", commands: [{ argv: ["/usr/sbin/useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", user], acceptedExitCodes: [0, 9] }] }),
3749
3862
  step("bind service account to vault group", { kind: "commands", commands: [{ argv: ["/usr/sbin/usermod", "-g", VAULT_GROUP, user] }] }),
3750
3863
  step("grant verified Agent update access", { kind: "commands", commands: [{ argv: ["/usr/sbin/usermod", "-a", "-G", AGENT_UPDATE_GROUP, user] }] }),
3864
+ step("grant software helper socket access", { kind: "commands", commands: [
3865
+ { argv: ["/usr/sbin/usermod", "-a", "-G", SOFTWARE_HELPER_GROUP, user] }
3866
+ ] }),
3751
3867
  ...lifecycleEnabled ? [step("grant lifecycle helper socket access", { kind: "commands", commands: [{ argv: ["/usr/sbin/usermod", "-a", "-G", LIFECYCLE_GROUP, user] }] })] : [],
3752
3868
  ...deploymentEnabled ? [step("credential-free deployment account", { kind: "commands", commands: [
3753
3869
  { argv: ["/usr/sbin/useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", "--gid", DEPLOYMENT_GROUP, DEPLOYMENT_RUNNER_USER], acceptedExitCodes: [0, 9] },
3754
3870
  { argv: ["/usr/sbin/useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", "--gid", VAULT_GROUP, APPLICATION_RUNTIME_USER], acceptedExitCodes: [0, 9] },
3755
3871
  { argv: ["/usr/sbin/usermod", "-a", "-G", DEPLOYMENT_GROUP, APPLICATION_RUNTIME_USER] },
3756
- { argv: ["/usr/sbin/usermod", "-a", "-G", `${DEPLOYMENT_GROUP},${SOFTWARE_HELPER_GROUP}`, user] }
3872
+ { argv: ["/usr/sbin/usermod", "-a", "-G", DEPLOYMENT_GROUP, user] }
3757
3873
  ] })] : [],
3758
3874
  step("credential and state directories", { kind: "directories", directories: [
3759
3875
  { path: credentialDir, mode: 448, owner: "root", group: "root" },
3760
- { path: "/var/lib/forgezero", mode: 488, owner: "root", group: "root" }
3876
+ { path: "/var/lib/forgezero", mode: 488, owner: "root", group: VAULT_GROUP }
3761
3877
  ] }),
3762
3878
  step("encrypted node identity", { kind: "ensure-seed", credential: seedCredentialPath }),
3763
3879
  ...options.generateGitIdentity && gitCredentialPath && gitPublicKeyPath ? [
@@ -3808,19 +3924,26 @@ function planProvision(options) {
3808
3924
  { argv: ["/usr/bin/systemctl", "enable", ...enabledUnits] },
3809
3925
  { argv: ["/usr/bin/systemctl", "reset-failed", "forgezero-agent.service"], acceptedExitCodes: [0, 1] },
3810
3926
  ...restartedUnits.length ? [{ argv: ["/usr/bin/systemctl", "restart", ...restartedUnits] }] : [],
3927
+ { argv: ["/usr/bin/systemctl", "stop", "forgezero-agent-proxy.service"], acceptedExitCodes: [0, 1, 5] },
3811
3928
  { argv: ["/usr/bin/systemctl", "restart", "forgezero-agent.socket"] },
3812
3929
  { argv: ["/usr/bin/systemctl", "reset-failed", "forgezero-agent.service"], acceptedExitCodes: [0, 1] },
3813
3930
  { argv: ["/usr/bin/systemctl", "restart", "forgezero-agent.service"] }
3814
3931
  ] }),
3815
3932
  ...enrolmentEnabled ? [step("prove the compute binding is durable", { kind: "verify-file", path: enrolStatePath })] : [],
3816
- ...options.enforceEgress ? [step("prove the Agent egress policy is active", { kind: "verify-egress", runnerPublicTcpPorts, runnerLoopbackPorts })] : [],
3933
+ ...options.enforceEgress ? [step("prove the Agent egress policy is active", {
3934
+ kind: "verify-egress",
3935
+ deploymentEnabled,
3936
+ runnerPublicTcpPorts,
3937
+ runnerLoopbackPorts,
3938
+ agentLoopbackPorts
3939
+ })] : [],
3817
3940
  step("prove it is running", { kind: "commands", commands: [{ argv: ["/usr/bin/systemctl", "is-active", "forgezero-agent.service"] }] }),
3818
3941
  step("prove the public Vault socket exists", { kind: "wait-socket", path: options.socketPath, attempts: 100, intervalMs: 100 }),
3819
3942
  step("prove the Agent Vault backend exists", { kind: "wait-socket", path: agentBackendSocketPath(options.socketPath), attempts: 100, intervalMs: 100 }),
3820
3943
  step("prove the Agent update helper exists", { kind: "wait-socket", path: DEFAULT_AGENT_UPDATE_SOCKET, attempts: 100, intervalMs: 100 }),
3944
+ step("prove the software strategy helper socket exists", { kind: "wait-socket", path: DEFAULT_SOFTWARE_HELPER_SOCKET, attempts: 100, intervalMs: 100 }),
3821
3945
  ...deploymentEnabled ? [
3822
- step("prove the deployment runner socket exists", { kind: "wait-socket", path: DEPLOYMENT_RUNNER_SOCKET, attempts: 100, intervalMs: 100 }),
3823
- step("prove the software strategy helper socket exists", { kind: "wait-socket", path: DEFAULT_SOFTWARE_HELPER_SOCKET, attempts: 100, intervalMs: 100 })
3946
+ step("prove the deployment runner socket exists", { kind: "wait-socket", path: DEPLOYMENT_RUNNER_SOCKET, attempts: 100, intervalMs: 100 })
3824
3947
  ] : [],
3825
3948
  ...lifecycleEnabled ? [step("prove the lifecycle helper socket exists", { kind: "wait-socket", path: lifecycleHelperSocketPath, attempts: 100, intervalMs: 100 })] : [],
3826
3949
  ...warpEnabled ? [step("prove Cloudflare WARP is connected", { kind: "verify-warp" })] : [],
@@ -14,7 +14,7 @@ var checked = async (run, argv, expected = 0) => {
14
14
  return result;
15
15
  };
16
16
  var activeSlot = () => {
17
- const slot = readFileSync("/opt/forgezero/.forge-slot", "utf8").trim();
17
+ const slot = readFileSync("/opt/forgezero/slots/.active", "utf8").trim();
18
18
  if (slot !== "blue" && slot !== "green")
19
19
  throw new Error("invalid active slot");
20
20
  return slot;