@forgezero/agent 0.1.31 → 0.1.33

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/provision.js CHANGED
@@ -2,8 +2,11 @@
2
2
  import { createHash, timingSafeEqual, randomUUID } from "node:crypto";
3
3
  import {
4
4
  chmodSync,
5
+ closeSync,
5
6
  existsSync,
7
+ fsyncSync,
6
8
  mkdirSync,
9
+ openSync,
7
10
  readFileSync,
8
11
  readlinkSync,
9
12
  renameSync,
@@ -17,6 +20,25 @@ var DEFAULT_AGENT_UPDATE_SOCKET = "/run/forgezero-update/helper.sock";
17
20
  var MAX_AGENT_TARBALL_BYTES = 32 * 1024 * 1024;
18
21
  var VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
19
22
  var REGISTRY = "registry.npmjs.org";
23
+ var syncPath = (path) => {
24
+ const descriptor = openSync(path, "r");
25
+ try {
26
+ fsyncSync(descriptor);
27
+ } finally {
28
+ closeSync(descriptor);
29
+ }
30
+ };
31
+ var syncReleaseDirectory = (directory) => {
32
+ for (const path of [
33
+ join(directory, "package.json"),
34
+ join(directory, "dist", "fz-agent.js"),
35
+ join(directory, "dist", "fz.js"),
36
+ join(directory, "dist"),
37
+ directory,
38
+ dirname(directory)
39
+ ])
40
+ syncPath(path);
41
+ };
20
42
  function validateAgentRelease(release) {
21
43
  if (release?.package !== "@forgezero/agent")
22
44
  throw new Error("agent update package is fixed");
@@ -135,16 +157,24 @@ async function stageAgentRelease(releaseInput, options) {
135
157
  ]
136
158
  }, "agent update extraction");
137
159
  await validateReleaseDirectory(unpacked, release, run);
138
- if (!existsSync(finalDirectory))
160
+ if (!existsSync(finalDirectory)) {
139
161
  renameSync(unpacked, finalDirectory);
140
- else
162
+ syncReleaseDirectory(finalDirectory);
163
+ } else
141
164
  await validateReleaseDirectory(finalDirectory, release, run);
142
165
  if (!existsSync(currentLink)) {
143
166
  throw new Error("agent update requires an active immutable release to roll back to");
144
167
  }
145
168
  const previousTarget = readlinkSync(currentLink);
169
+ if (previousTarget !== join("versions", options.currentVersion)) {
170
+ throw new Error("agent update current release does not match the running version");
171
+ }
172
+ if (!existsSync(join(root, previousTarget))) {
173
+ throw new Error("agent update rollback release is missing");
174
+ }
146
175
  return {
147
176
  version: release.version,
177
+ fromVersion: options.currentVersion,
148
178
  directory: finalDirectory,
149
179
  previousTarget,
150
180
  nextTarget: join("versions", release.version),
@@ -159,6 +189,7 @@ function selectAgentRelease(staged) {
159
189
  try {
160
190
  symlinkSync(staged.nextTarget, next);
161
191
  renameSync(next, staged.currentLink);
192
+ syncPath(dirname(staged.currentLink));
162
193
  } finally {
163
194
  rmSync(next, { force: true });
164
195
  }
@@ -168,25 +199,189 @@ function restoreAgentRelease(staged) {
168
199
  try {
169
200
  symlinkSync(staged.previousTarget, next);
170
201
  renameSync(next, staged.currentLink);
202
+ syncPath(dirname(staged.currentLink));
171
203
  } finally {
172
204
  rmSync(next, { force: true });
173
205
  }
174
206
  }
175
207
 
176
208
  // src/agent-update-helper.ts
177
- import { chmodSync as chmodSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, renameSync as renameSync2, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
209
+ import { randomUUID as randomUUID2 } from "node:crypto";
210
+ import {
211
+ chmodSync as chmodSync2,
212
+ closeSync as closeSync2,
213
+ existsSync as existsSync2,
214
+ fsyncSync as fsyncSync2,
215
+ mkdirSync as mkdirSync2,
216
+ openSync as openSync2,
217
+ readFileSync as readFileSync2,
218
+ renameSync as renameSync2,
219
+ rmSync as rmSync2,
220
+ unlinkSync,
221
+ writeFileSync as writeFileSync2
222
+ } from "node:fs";
178
223
  import { connect, createServer } from "node:net";
179
- import { dirname as dirname2 } from "node:path";
224
+ import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
180
225
  import { DEFAULT_SOCKET } from "@forgezero/vault";
181
226
  var AGENT_UPDATE_GROUP = "forgezero-update";
182
227
  var AGENT_UPDATE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-agent-update-helper.service";
183
- var AGENT_UPDATE_RECEIPT = "/var/lib/forgezero/agent-update.json";
228
+ var AGENT_UPDATE_JOURNAL = "/var/lib/forgezero/agent-update.json";
229
+ var AGENT_UPDATE_RECEIPT = "/var/lib/forgezero/agent-update-receipt.json";
184
230
  var MAX_REQUEST_BYTES = 8 * 1024;
185
231
  var COMPUTE_HELPER_UNITS = [
232
+ "forgezero-agent-egress.service",
186
233
  "forgezero-deploy-runner.service",
187
234
  "forgezero-lifecycle-helper.service",
188
235
  "forgezero-software-helper.service"
189
236
  ];
237
+ var VERSION2 = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
238
+ var ATTEMPT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
239
+ var REASON_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
240
+ var MAX_REASON_BYTES = 512;
241
+ var UPDATE_RETRY_BASE_MS = 5 * 60000;
242
+ var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
243
+ var boundedMessage = (value) => {
244
+ let message = value.replace(/[\r\n]+/g, " ").trim();
245
+ while (Buffer.byteLength(message, "utf8") > MAX_REASON_BYTES)
246
+ message = message.slice(0, -1);
247
+ return message;
248
+ };
249
+ var reason = (code, message) => ({
250
+ code: REASON_CODE.test(code) ? code : "UPDATE_FAILED",
251
+ message: boundedMessage(message) || "Agent update failed"
252
+ });
253
+ var validTime = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
254
+ function validateReceipt(value) {
255
+ if (!value || typeof value !== "object")
256
+ throw new Error("Agent update receipt is malformed");
257
+ const receipt = value;
258
+ if (!receipt.attemptId || !ATTEMPT_ID.test(receipt.attemptId))
259
+ throw new Error("Agent update attempt ID is invalid");
260
+ if (!receipt.fromVersion || !VERSION2.test(receipt.fromVersion))
261
+ throw new Error("Agent update source version is invalid");
262
+ if (!receipt.targetVersion || !VERSION2.test(receipt.targetVersion))
263
+ throw new Error("Agent update target version is invalid");
264
+ if (!["activating", "active", "rolled-back", "failed"].includes(receipt.outcome ?? "")) {
265
+ throw new Error("Agent update outcome is invalid");
266
+ }
267
+ if (!validTime(receipt.startedAtTs) || !validTime(receipt.updatedAtTs)) {
268
+ throw new Error("Agent update timestamps are invalid");
269
+ }
270
+ if (receipt.retryAfterTs !== undefined && !validTime(receipt.retryAfterTs)) {
271
+ throw new Error("Agent update retry timestamp is invalid");
272
+ }
273
+ if (receipt.rollbackHealthy !== undefined && typeof receipt.rollbackHealthy !== "boolean") {
274
+ throw new Error("Agent update rollback health is invalid");
275
+ }
276
+ if (receipt.reason && (!REASON_CODE.test(receipt.reason.code) || typeof receipt.reason.message !== "string" || Buffer.byteLength(receipt.reason.message, "utf8") > MAX_REASON_BYTES))
277
+ throw new Error("Agent update failure reason is invalid");
278
+ return {
279
+ attemptId: receipt.attemptId,
280
+ fromVersion: receipt.fromVersion,
281
+ targetVersion: receipt.targetVersion,
282
+ outcome: receipt.outcome,
283
+ startedAtTs: receipt.startedAtTs,
284
+ updatedAtTs: receipt.updatedAtTs,
285
+ ...receipt.retryAfterTs === undefined ? {} : { retryAfterTs: receipt.retryAfterTs },
286
+ ...receipt.rollbackHealthy === undefined ? {} : { rollbackHealthy: receipt.rollbackHealthy },
287
+ ...receipt.reason === undefined ? {} : { reason: receipt.reason }
288
+ };
289
+ }
290
+ function validateJournal(value, root) {
291
+ if (!value || typeof value !== "object")
292
+ throw new Error("Agent update journal is malformed");
293
+ const legacy = value;
294
+ if (legacy.schemaVersion === undefined) {
295
+ if (typeof legacy.version === "string" && VERSION2.test(legacy.version) && legacy.outcome === "active" && validTime(legacy.updatedAtTs) && Object.keys(value).every((key) => ["version", "outcome", "updatedAtTs"].includes(key)))
296
+ return;
297
+ throw new Error("Agent update legacy receipt is malformed");
298
+ }
299
+ const journal = value;
300
+ const receipt = validateReceipt(journal);
301
+ if (journal.schemaVersion !== 1)
302
+ throw new Error("Agent update journal schema is unsupported");
303
+ if (journal.target !== "compute" && journal.target !== "metal")
304
+ throw new Error("Agent update target is invalid");
305
+ if (!Number.isSafeInteger(journal.failureCount) || (journal.failureCount ?? -1) < 0) {
306
+ throw new Error("Agent update failure count is invalid");
307
+ }
308
+ const releaseRoot = resolve2(root);
309
+ if (journal.currentLink !== join2(releaseRoot, "current"))
310
+ throw new Error("Agent update current link is invalid");
311
+ if (journal.previousTarget !== join2("versions", receipt.fromVersion)) {
312
+ throw new Error("Agent update rollback target is invalid");
313
+ }
314
+ if (journal.nextTarget !== join2("versions", receipt.targetVersion)) {
315
+ throw new Error("Agent update next target is invalid");
316
+ }
317
+ return journal;
318
+ }
319
+ function readJournal(path, root) {
320
+ if (!existsSync2(path))
321
+ return;
322
+ return validateJournal(JSON.parse(readFileSync2(path, "utf8")), root);
323
+ }
324
+ function writeAtomic(path, value, mode) {
325
+ mkdirSync2(dirname2(path), { recursive: true, mode: 493 });
326
+ const next = `${path}.${randomUUID2()}.next`;
327
+ let file;
328
+ try {
329
+ file = openSync2(next, "wx", mode);
330
+ writeFileSync2(file, `${JSON.stringify(value)}
331
+ `);
332
+ fsyncSync2(file);
333
+ closeSync2(file);
334
+ file = undefined;
335
+ renameSync2(next, path);
336
+ const directory = openSync2(dirname2(path), "r");
337
+ try {
338
+ fsyncSync2(directory);
339
+ } finally {
340
+ closeSync2(directory);
341
+ }
342
+ } finally {
343
+ if (file !== undefined)
344
+ closeSync2(file);
345
+ rmSync2(next, { force: true });
346
+ }
347
+ }
348
+ var publicReceipt = (journal) => {
349
+ const {
350
+ attemptId,
351
+ fromVersion,
352
+ targetVersion,
353
+ outcome,
354
+ startedAtTs,
355
+ updatedAtTs,
356
+ retryAfterTs,
357
+ rollbackHealthy,
358
+ reason: failureReason
359
+ } = journal;
360
+ return {
361
+ attemptId,
362
+ fromVersion,
363
+ targetVersion,
364
+ outcome,
365
+ startedAtTs,
366
+ updatedAtTs,
367
+ ...retryAfterTs === undefined ? {} : { retryAfterTs },
368
+ ...rollbackHealthy === undefined ? {} : { rollbackHealthy },
369
+ ...failureReason === undefined ? {} : { reason: failureReason }
370
+ };
371
+ };
372
+ function writeUpdateState(journalPath, receiptPath, journal) {
373
+ writeAtomic(journalPath, journal, 384);
374
+ writeAtomic(receiptPath, publicReceipt(journal), 416);
375
+ }
376
+ function readAgentUpdateReceipt(path = AGENT_UPDATE_RECEIPT) {
377
+ try {
378
+ if (!existsSync2(path))
379
+ return;
380
+ return validateReceipt(JSON.parse(readFileSync2(path, "utf8")));
381
+ } catch {
382
+ return;
383
+ }
384
+ }
190
385
  var runCommand = async (input) => {
191
386
  const child = Bun.spawn([input.command, ...input.args], {
192
387
  cwd: input.cwd,
@@ -202,8 +397,27 @@ var runCommand = async (input) => {
202
397
  return { exitCode, output: `${stdout}${stderr}` };
203
398
  };
204
399
  var runOk = async (run, command2, args) => (await run({ command: command2, args })).exitCode === 0;
400
+ var retryAfter = (now, failures) => now + Math.min(UPDATE_RETRY_MAX_MS, UPDATE_RETRY_BASE_MS * 2 ** Math.min(16, Math.max(0, failures - 1)));
401
+ var stagedFromJournal = (journal, root) => ({
402
+ version: journal.targetVersion,
403
+ fromVersion: journal.fromVersion,
404
+ directory: join2(resolve2(root), journal.nextTarget),
405
+ previousTarget: journal.previousTarget,
406
+ nextTarget: journal.nextTarget,
407
+ currentLink: journal.currentLink
408
+ });
409
+ var restartAgent = async (target, run) => {
410
+ const helpers = target === "compute" ? COMPUTE_HELPER_UNITS : ["forgezero-metal-helper.service"];
411
+ for (const unit of helpers)
412
+ await run({ command: "/usr/bin/systemctl", args: ["try-restart", unit] });
413
+ const service = target === "compute" ? "forgezero-agent.service" : "forgezero-metal-agent.service";
414
+ if (!await runOk(run, "/usr/bin/systemctl", ["restart", service])) {
415
+ throw new Error(`systemd could not restart ${service}`);
416
+ }
417
+ };
418
+ var targetProbe = (target, run) => target === "compute" ? () => probeAgentSocket() : async () => await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-agent.service"]) && await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-helper.service"]);
205
419
  function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
206
- return new Promise((resolve2) => {
420
+ return new Promise((resolve3) => {
207
421
  const socket = connect(socketPath);
208
422
  let settled = false;
209
423
  let buffer = "";
@@ -213,7 +427,7 @@ function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
213
427
  settled = true;
214
428
  clearTimeout(timer);
215
429
  socket.destroy();
216
- resolve2(value);
430
+ resolve3(value);
217
431
  };
218
432
  const timer = setTimeout(() => finish(false), timeoutMs);
219
433
  socket.on("connect", () => socket.write(`{"op":"identity"}
@@ -237,55 +451,136 @@ function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
237
451
  async function activateAgentRelease(staged, options = {}) {
238
452
  const run = options.run ?? runCommand;
239
453
  const target = options.target ?? "compute";
240
- const probe = options.probe ?? (target === "compute" ? () => probeAgentSocket() : async () => await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-agent.service"]) && await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-helper.service"]));
241
- const restart = async () => {
242
- const helpers = target === "compute" ? COMPUTE_HELPER_UNITS : ["forgezero-metal-helper.service"];
243
- for (const unit of helpers) {
244
- await run({ command: "/usr/bin/systemctl", args: ["try-restart", unit] });
245
- }
246
- const service = target === "compute" ? "forgezero-agent.service" : "forgezero-metal-agent.service";
247
- const restarted = await runOk(run, "/usr/bin/systemctl", ["restart", service]);
248
- if (!restarted)
249
- throw new Error(`systemd could not restart ${service}`);
454
+ const probe = options.probe ?? targetProbe(target, run);
455
+ const now = options.now ?? Date.now;
456
+ const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
457
+ const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
458
+ const previous = readJournal(journalPath, dirname2(staged.currentLink));
459
+ const attemptId = options.attemptId ?? randomUUID2();
460
+ if (!ATTEMPT_ID.test(attemptId))
461
+ throw new Error("Agent update attempt ID is invalid");
462
+ const startedAtTs = now();
463
+ const failureCount = previous?.targetVersion === staged.version ? previous.failureCount : 0;
464
+ let journal = {
465
+ schemaVersion: 1,
466
+ attemptId,
467
+ target,
468
+ fromVersion: staged.fromVersion,
469
+ targetVersion: staged.version,
470
+ outcome: "activating",
471
+ startedAtTs,
472
+ updatedAtTs: startedAtTs,
473
+ currentLink: staged.currentLink,
474
+ previousTarget: staged.previousTarget,
475
+ nextTarget: staged.nextTarget,
476
+ failureCount
250
477
  };
251
- let selected = false;
478
+ writeUpdateState(journalPath, receiptPath, journal);
479
+ let selectionAttempted = false;
252
480
  try {
481
+ selectionAttempted = true;
253
482
  selectAgentRelease(staged);
254
- selected = true;
255
- await restart();
483
+ await restartAgent(target, run);
256
484
  if (!await probe())
257
485
  throw new Error("the replacement Agent did not answer its retained Vault socket");
258
- const receipt = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
259
- mkdirSync2(dirname2(receipt), { recursive: true, mode: 493 });
260
- const next = `${receipt}.next`;
261
- writeFileSync2(next, JSON.stringify({
262
- version: staged.version,
263
- outcome: "active",
264
- updatedAtTs: (options.now ?? Date.now)()
265
- }) + `
266
- `, { mode: 420 });
267
- renameSync2(next, receipt);
486
+ journal = { ...journal, outcome: "active", updatedAtTs: now(), rollbackHealthy: undefined };
487
+ writeUpdateState(journalPath, receiptPath, journal);
268
488
  run({
269
489
  command: "/usr/bin/systemctl",
270
490
  args: ["try-restart", "--no-block", "forgezero-agent-update-helper.service"]
271
491
  });
272
492
  return { ok: true, version: staged.version };
273
493
  } catch (cause) {
274
- const reason = cause instanceof Error ? cause.message : String(cause);
275
- if (selected) {
276
- restoreAgentRelease(staged);
277
- await restart();
494
+ const message = cause instanceof Error ? cause.message : String(cause);
495
+ let rollbackHealthy = false;
496
+ let restored = false;
497
+ if (selectionAttempted) {
498
+ try {
499
+ restoreAgentRelease(staged);
500
+ restored = true;
501
+ await restartAgent(target, run);
502
+ rollbackHealthy = await probe();
503
+ } catch {
504
+ rollbackHealthy = false;
505
+ }
278
506
  }
279
- return { ok: false, rolledBack: selected, reason };
507
+ const failures = failureCount + 1;
508
+ const updatedAtTs = now();
509
+ journal = {
510
+ ...journal,
511
+ outcome: rollbackHealthy ? "rolled-back" : "failed",
512
+ updatedAtTs,
513
+ retryAfterTs: retryAfter(updatedAtTs, failures),
514
+ rollbackHealthy,
515
+ reason: reason(rollbackHealthy ? "REPLACEMENT_UNHEALTHY" : "ROLLBACK_UNHEALTHY", rollbackHealthy ? message : `${message}; the restored Agent did not pass its health probe`),
516
+ failureCount: failures
517
+ };
518
+ writeUpdateState(journalPath, receiptPath, journal);
519
+ return { ok: false, rolledBack: restored, rollbackHealthy, reason: message };
280
520
  }
281
521
  }
522
+ async function recoverInterruptedAgentUpdate(options = {}) {
523
+ const root = resolve2(options.root ?? DEFAULT_AGENT_RELEASE_ROOT);
524
+ const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
525
+ const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
526
+ const journal = readJournal(journalPath, root);
527
+ if (!journal)
528
+ return;
529
+ if (journal.outcome !== "activating") {
530
+ writeAtomic(receiptPath, publicReceipt(journal), 416);
531
+ return publicReceipt(journal);
532
+ }
533
+ const staged = stagedFromJournal(journal, root);
534
+ if (!existsSync2(join2(root, journal.previousTarget))) {
535
+ throw new Error("Agent update rollback release is missing");
536
+ }
537
+ const run = options.run ?? runCommand;
538
+ const probe = options.probe ?? targetProbe(journal.target, run);
539
+ restoreAgentRelease(staged);
540
+ let rollbackHealthy = false;
541
+ let failureMessage = "activation was interrupted before its health verdict became durable";
542
+ try {
543
+ await restartAgent(journal.target, run);
544
+ rollbackHealthy = await probe();
545
+ } catch (cause) {
546
+ failureMessage = `${failureMessage}; ${cause instanceof Error ? cause.message : String(cause)}`;
547
+ }
548
+ const failures = journal.failureCount + 1;
549
+ const updatedAtTs = (options.now ?? Date.now)();
550
+ const recovered = {
551
+ ...journal,
552
+ outcome: rollbackHealthy ? "rolled-back" : "failed",
553
+ updatedAtTs,
554
+ retryAfterTs: retryAfter(updatedAtTs, failures),
555
+ rollbackHealthy,
556
+ reason: reason(rollbackHealthy ? "ACTIVATION_INTERRUPTED" : "ROLLBACK_UNHEALTHY", failureMessage),
557
+ failureCount: failures
558
+ };
559
+ writeUpdateState(journalPath, receiptPath, recovered);
560
+ return readAgentUpdateReceipt(receiptPath);
561
+ }
282
562
  function startAgentUpdateHelper(options = {}) {
283
563
  const socketPath = options.socketPath ?? DEFAULT_AGENT_UPDATE_SOCKET;
284
564
  if (existsSync2(socketPath))
285
565
  unlinkSync(socketPath);
286
566
  mkdirSync2(dirname2(socketPath), { recursive: true, mode: 488 });
287
567
  const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
288
- const activate = options.activate ?? ((staged, target) => activateAgentRelease(staged, { target }));
568
+ const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
569
+ const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
570
+ const releaseRoot = options.root ?? DEFAULT_AGENT_RELEASE_ROOT;
571
+ const activate = options.activate ?? ((staged, target, attemptId) => activateAgentRelease(staged, { target, attemptId, journalPath, receiptPath, now: options.now }));
572
+ let busy = true;
573
+ let blocked;
574
+ (options.recover ?? (() => recoverInterruptedAgentUpdate({
575
+ root: releaseRoot,
576
+ journalPath,
577
+ receiptPath,
578
+ now: options.now
579
+ })))().catch((cause) => {
580
+ blocked = cause instanceof Error ? cause.message : String(cause);
581
+ }).finally(() => {
582
+ busy = false;
583
+ });
289
584
  const server = createServer((socket) => {
290
585
  let buffer = "";
291
586
  socket.on("data", (chunk) => {
@@ -301,22 +596,42 @@ function startAgentUpdateHelper(options = {}) {
301
596
  return;
302
597
  const line = buffer.slice(0, newline);
303
598
  buffer = "";
599
+ let ownsBusy = false;
304
600
  Promise.resolve().then(() => JSON.parse(line)).then(async (request) => {
601
+ if (blocked)
602
+ throw new Error(`update journal needs operator recovery: ${blocked}`);
603
+ if (busy)
604
+ throw new Error("another Agent update or recovery is already active");
305
605
  if (request.op !== "apply")
306
606
  throw new Error("unknown update operation");
307
607
  if (request.target !== "compute" && request.target !== "metal") {
308
608
  throw new Error("agent update target is invalid");
309
609
  }
610
+ const attemptId = request.attemptId ?? randomUUID2();
611
+ if (!ATTEMPT_ID.test(attemptId))
612
+ throw new Error("Agent update attempt ID is invalid");
613
+ const prior = readJournal(journalPath, releaseRoot);
614
+ const now = (options.now ?? Date.now)();
615
+ if (prior?.targetVersion === request.release.version && (prior.outcome === "rolled-back" || prior.outcome === "failed") && (prior.retryAfterTs ?? 0) > now)
616
+ throw new Error(`Agent update ${request.release.version} is quarantined until ${prior.retryAfterTs}`);
617
+ busy = true;
618
+ ownsBusy = true;
310
619
  const staged = await stageAgentRelease(request.release, {
311
620
  currentVersion: request.currentVersion,
312
- root: options.root ?? DEFAULT_AGENT_RELEASE_ROOT
621
+ root: releaseRoot
313
622
  });
314
- const response = { ok: true, status: "staged", version: staged.version };
623
+ const response = { ok: true, status: "staged", version: staged.version, attemptId };
315
624
  socket.end(`${JSON.stringify(response)}
316
- `, () => {
317
- setTimer(() => void activate(staged, request.target), 100);
318
- });
625
+ `);
626
+ setTimer(() => void activate(staged, request.target, attemptId).catch((cause) => {
627
+ blocked = cause instanceof Error ? cause.message : String(cause);
628
+ }).finally(() => {
629
+ busy = false;
630
+ }), 100);
631
+ ownsBusy = false;
319
632
  }).catch((cause) => {
633
+ if (ownsBusy)
634
+ busy = false;
320
635
  const response = {
321
636
  ok: false,
322
637
  error: { code: "UPDATE_REFUSED", message: cause instanceof Error ? cause.message : String(cause) }
@@ -331,7 +646,7 @@ function startAgentUpdateHelper(options = {}) {
331
646
  return server;
332
647
  }
333
648
  function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, timeoutMs = 90000) {
334
- return new Promise((resolve2, reject) => {
649
+ return new Promise((resolve3, reject) => {
335
650
  const socket = connect(socketPath, () => socket.write(`${JSON.stringify(request)}
336
651
  `));
337
652
  let buffer = "";
@@ -347,7 +662,7 @@ function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, t
347
662
  return;
348
663
  socket.end();
349
664
  try {
350
- resolve2(JSON.parse(buffer.slice(0, newline)));
665
+ resolve3(JSON.parse(buffer.slice(0, newline)));
351
666
  } catch (cause) {
352
667
  reject(cause);
353
668
  }
@@ -357,7 +672,7 @@ function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, t
357
672
  }
358
673
 
359
674
  // src/software.ts
360
- import { readFileSync as readFileSync2 } from "node:fs";
675
+ import { readFileSync as readFileSync3 } from "node:fs";
361
676
  var BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
362
677
  var ARANGO_SHA256 = "b5a9197b4343f2ed554e1ebc1ef8e6529c7c39cde0035cdc311a4747a3355066";
363
678
  var CLOUDFLARED_SHA256 = "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17";
@@ -398,7 +713,7 @@ var UBUNTU_2604_X64 = [
398
713
  install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y ufw"
399
714
  }
400
715
  ];
401
- function observeSoftwareHost(osRelease = readFileSync2("/etc/os-release", "utf8"), architecture = process.arch) {
716
+ function observeSoftwareHost(osRelease = readFileSync3("/etc/os-release", "utf8"), architecture = process.arch) {
402
717
  const values = Object.fromEntries(osRelease.split(`
403
718
  `).flatMap((line) => {
404
719
  const separator = line.indexOf("=");
@@ -540,7 +855,7 @@ function startSoftwareHelper(options = {}) {
540
855
  }
541
856
  function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCKET, timeoutMs = 15 * 60000) {
542
857
  validateSoftwareRequirements(requirements);
543
- return new Promise((resolve2, reject) => {
858
+ return new Promise((resolve3, reject) => {
544
859
  const socket = connect2(socketPath, () => socket.write(`${JSON.stringify({ op: "ensure", requirements })}
545
860
  `));
546
861
  let buffer = "";
@@ -559,7 +874,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
559
874
  const response = JSON.parse(buffer.slice(0, newline));
560
875
  if (!response.ok || !response.results)
561
876
  throw new Error(response.error?.message ?? "software helper refused the request");
562
- resolve2(response.results);
877
+ resolve3(response.results);
563
878
  } catch (cause) {
564
879
  reject(cause);
565
880
  }
@@ -569,9 +884,71 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
569
884
  }
570
885
 
571
886
  // src/version.ts
572
- var VERSION2 = "0.1.31";
887
+ var VERSION3 = "0.1.33";
888
+
889
+ // src/egress-policy.ts
890
+ import { realpathSync } from "node:fs";
891
+ var AGENT_EGRESS_TABLE = "forgezero_agent_egress";
892
+ var SYSTEMD_RESOLVED_ADDRESS = "127.0.0.53";
893
+ var BLOCKED_IPV4 = [
894
+ "0.0.0.0/8",
895
+ "10.0.0.0/8",
896
+ "100.64.0.0/10",
897
+ "127.0.0.0/8",
898
+ "168.63.129.16/32",
899
+ "169.254.0.0/16",
900
+ "172.16.0.0/12",
901
+ "192.0.0.0/24",
902
+ "192.0.2.0/24",
903
+ "192.88.99.0/24",
904
+ "192.168.0.0/16",
905
+ "198.18.0.0/15",
906
+ "198.51.100.0/24",
907
+ "203.0.113.0/24",
908
+ "224.0.0.0/4",
909
+ "240.0.0.0/4"
910
+ ];
911
+ var BLOCKED_IPV6 = [
912
+ "::/128",
913
+ "::1/128",
914
+ "::ffff:0:0/96",
915
+ "64:ff9b::/96",
916
+ "64:ff9b:1::/48",
917
+ "100::/64",
918
+ "fc00::/7",
919
+ "fec0::/10",
920
+ "fe80::/10",
921
+ "ff00::/8",
922
+ "2001::/32",
923
+ "2001:2::/48",
924
+ "2001:10::/28",
925
+ "2001:20::/28",
926
+ "2001:db8::/32",
927
+ "2002::/16",
928
+ "3fff::/20"
929
+ ];
930
+ var normalizeEgressTcpPorts = (ports) => {
931
+ for (const port of ports) {
932
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
933
+ throw new Error("Agent egress policy refuses an invalid loopback TCP port.");
934
+ }
935
+ }
936
+ return [...new Set(ports)].sort((left, right) => left - right);
937
+ };
938
+ function systemdAgentEgressDirectives(loopbackTcpPorts = []) {
939
+ const ports = normalizeEgressTcpPorts(loopbackTcpPorts);
940
+ return [
941
+ "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6",
942
+ `IPAddressAllow=${SYSTEMD_RESOLVED_ADDRESS}/32`,
943
+ ...ports.length > 0 ? ["IPAddressAllow=127.0.0.1/32", "IPAddressAllow=::1/128"] : [],
944
+ ...BLOCKED_IPV4.map((network) => `IPAddressDeny=${network}`),
945
+ ...BLOCKED_IPV6.map((network) => `IPAddressDeny=${network}`)
946
+ ].join(`
947
+ `);
948
+ }
573
949
 
574
950
  // src/provision.ts
951
+ import { isIP } from "node:net";
575
952
  function atLeast(version, floor) {
576
953
  const parse = (value) => (value.trim().replace(/^v/, "").match(/\d+/g) ?? []).slice(0, 3).map(Number);
577
954
  const got = parse(version);
@@ -625,6 +1002,63 @@ var LIFECYCLE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-lifecycle-helper
625
1002
  var LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
626
1003
  var WARP_CONFIG_UNIT_PATH = "/etc/systemd/system/forgezero-warp-config.service";
627
1004
  var WARP_SERVICE_DROP_IN_PATH = "/etc/systemd/system/warp-svc.service.d/forgezero.conf";
1005
+ var AGENT_EGRESS_UNIT_PATH = "/etc/systemd/system/forgezero-agent-egress.service";
1006
+ var DEFAULT_RUNNER_PUBLIC_TCP_PORTS = [443];
1007
+ function agentEgressUnit(options) {
1008
+ const bin = options.binPath ?? "fz-agent";
1009
+ const user = options.user ?? "forgezero";
1010
+ if (!/^[a-z_][a-z0-9_-]{0,30}$/.test(user))
1011
+ throw new Error("invalid Agent service user");
1012
+ const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
1013
+ const runnerLoopbackPorts = normalizeEgressTcpPorts(options.runnerLoopbackPorts ?? []);
1014
+ const runnerPublicTcpPorts = normalizeEgressTcpPorts(options.runnerPublicTcpPorts ?? DEFAULT_RUNNER_PUBLIC_TCP_PORTS);
1015
+ if (deploymentEnabled && runnerPublicTcpPorts.length < 1) {
1016
+ throw new Error("deployed project runner needs at least one vetted public TCP port");
1017
+ }
1018
+ if (deploymentEnabled)
1019
+ systemdAgentEgressDirectives(runnerLoopbackPorts);
1020
+ const users = [user, ...deploymentEnabled ? [DEPLOYMENT_RUNNER_USER] : []];
1021
+ const runnerGrant = deploymentEnabled ? ` --loopback-user=${DEPLOYMENT_RUNNER_USER}` + runnerLoopbackPorts.map((port) => ` --loopback-tcp-port=${port}`).join("") + runnerPublicTcpPorts.map((port) => ` --public-tcp-port=${port}`).join("") : "";
1022
+ const policyProofs = deploymentEnabled ? [
1023
+ ...runnerLoopbackPorts.length > 0 ? [`loopback=.*:${runnerLoopbackPorts.join(",")}`] : [],
1024
+ `public-tcp=${runnerPublicTcpPorts.join(",")}`
1025
+ ].map((pattern) => `ExecStartPost=/bin/sh -c '/usr/sbin/nft --numeric list table inet ${AGENT_EGRESS_TABLE} | /usr/bin/grep -q "${pattern}"'`).join(`
1026
+ `) : "";
1027
+ return `[Unit]
1028
+ Description=ForgeZero Agent host egress policy
1029
+ Documentation=https://www.forgezero.net/docs/agent
1030
+ After=systemd-resolved.service nftables.service
1031
+ Requires=systemd-resolved.service
1032
+ Before=forgezero-agent-enrol.service forgezero-agent.service
1033
+
1034
+ [Service]
1035
+ Type=notify
1036
+ NotifyAccess=all
1037
+ User=root
1038
+ Group=root
1039
+ ExecStart=${bin} egress-policy ${users.map((name) => `--user=${name}`).join(" ")}${runnerGrant}
1040
+ ${policyProofs}
1041
+ Restart=on-failure
1042
+ RestartSec=2
1043
+ LimitCORE=0
1044
+ NoNewPrivileges=true
1045
+ PrivateTmp=true
1046
+ ProtectSystem=strict
1047
+ ProtectHome=true
1048
+ ProtectKernelTunables=true
1049
+ ProtectKernelModules=true
1050
+ ProtectControlGroups=true
1051
+ RestrictSUIDSGID=true
1052
+ RestrictRealtime=true
1053
+ MemoryDenyWriteExecute=true
1054
+ LockPersonality=true
1055
+ CapabilityBoundingSet=CAP_NET_ADMIN
1056
+ RestrictAddressFamilies=AF_UNIX AF_NETLINK
1057
+
1058
+ [Install]
1059
+ WantedBy=multi-user.target
1060
+ `;
1061
+ }
628
1062
  function softwareHelperUnit(options) {
629
1063
  const bin = options.binPath ?? "fz-agent";
630
1064
  return `[Unit]
@@ -869,11 +1303,16 @@ function agentEnrolmentUnit(options) {
869
1303
  ` : ""
870
1304
  ].join("");
871
1305
  const stateDir = options.enrolStatePath.replace(/\/[^/]+$/, "");
1306
+ const egressDependency = options.enforceEgress ? `After=forgezero-agent-egress.service
1307
+ Requires=forgezero-agent-egress.service
1308
+ BindsTo=forgezero-agent-egress.service
1309
+ ` : "";
1310
+ const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives() : "";
872
1311
  return `[Unit]
873
1312
  Description=Bind this machine to its ForgeZero compute
874
1313
  After=network-online.target
875
1314
  Wants=network-online.target
876
- Before=forgezero-agent.service
1315
+ ${egressDependency}Before=forgezero-agent.service
877
1316
  ConditionPathExists=!${options.enrolStatePath}
878
1317
 
879
1318
  [Service]
@@ -896,6 +1335,7 @@ ProtectSystem=strict
896
1335
  ProtectHome=true
897
1336
  ReadWritePaths=${stateDir}
898
1337
  LimitCORE=0
1338
+ ${egressDirectives}
899
1339
 
900
1340
  [Install]
901
1341
  WantedBy=multi-user.target
@@ -905,9 +1345,15 @@ function deploymentRunnerUnit(options) {
905
1345
  const bin = options.binPath ?? "fz-agent";
906
1346
  const root = options.deployRoot ?? "/opt/forgezero";
907
1347
  const agentUser = options.user ?? "forgezero-agent";
1348
+ const egressDependency = options.enforceEgress ? `After=forgezero-agent-egress.service
1349
+ Requires=forgezero-agent-egress.service
1350
+ BindsTo=forgezero-agent-egress.service
1351
+ ` : "";
1352
+ const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives(options.runnerLoopbackPorts ?? []) : "";
908
1353
  return `[Unit]
909
1354
  Description=ForgeZero credential-free project command runner
910
1355
  Documentation=https://www.forgezero.net/docs/agent
1356
+ ${egressDependency}
911
1357
 
912
1358
  [Service]
913
1359
  Type=notify
@@ -938,6 +1384,7 @@ ProtectControlGroups=true
938
1384
  RestrictRealtime=true
939
1385
  MemoryDenyWriteExecute=true
940
1386
  LockPersonality=true
1387
+ ${egressDirectives}
941
1388
  ReadWritePaths=${root}/releases ${root}/runner-home
942
1389
 
943
1390
  [Install]
@@ -947,6 +1394,21 @@ WantedBy=multi-user.target
947
1394
  function agentUnit(options) {
948
1395
  if (!validNodeHostname(options.nodeHostname))
949
1396
  throw new Error("node hostname is invalid");
1397
+ if (!options.telemetryEndpoint) {
1398
+ throw new Error("compute Agent provisioning requires OTEL_EXPORTER_OTLP_ENDPOINT as an explicit public HTTPS collector coordinate");
1399
+ }
1400
+ let telemetryEndpoint;
1401
+ {
1402
+ let endpoint;
1403
+ try {
1404
+ endpoint = new URL(options.telemetryEndpoint);
1405
+ } catch {
1406
+ throw new Error("compute telemetry endpoint must be an absolute public HTTPS URL");
1407
+ }
1408
+ if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password || endpoint.search || endpoint.hash || isIP(endpoint.hostname) !== 0 || !endpoint.hostname.includes(".") || endpoint.hostname === "localhost" || endpoint.hostname.endsWith(".local"))
1409
+ throw new Error("compute telemetry endpoint must be an absolute public HTTPS URL without credentials, query or fragment");
1410
+ telemetryEndpoint = endpoint.toString().replace(/\/$/, "");
1411
+ }
950
1412
  const bin = options.binPath ?? "fz-agent";
951
1413
  const user = options.user ?? "forgezero";
952
1414
  const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
@@ -995,6 +1457,7 @@ function agentUnit(options) {
995
1457
  }
996
1458
  }
997
1459
  const environment = [
1460
+ "NODE_ENV=production",
998
1461
  `FZ_SOCKET_PATH=${agentBackendSocketPath(options.socketPath)}`,
999
1462
  `FZ_CONTROL_SOCKET=${controlSocketPath}`,
1000
1463
  `FZ_SEED_CREDENTIAL=agent-seed`,
@@ -1005,6 +1468,8 @@ function agentUnit(options) {
1005
1468
  options.enrolStatePath ? `FZ_ENROL_STATE_FILE=${options.enrolStatePath}` : null,
1006
1469
  options.nodeLabel ? `FZ_NODE_LABEL=${options.nodeLabel}` : null,
1007
1470
  options.nodeHostname ? `FZ_NODE_HOSTNAME=${options.nodeHostname}` : null,
1471
+ `OTEL_EXPORTER_OTLP_ENDPOINT=${telemetryEndpoint}`,
1472
+ "OTEL_SERVICE_NAME=forgezero-agent",
1008
1473
  options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
1009
1474
  options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
1010
1475
  options.branch ? `FZ_DEPLOY_BRANCH=${options.branch}` : null,
@@ -1040,6 +1505,7 @@ function agentUnit(options) {
1040
1505
  const after = [
1041
1506
  "network-online.target",
1042
1507
  "forgezero-agent-update-helper.service",
1508
+ options.enforceEgress ? "forgezero-agent-egress.service" : null,
1043
1509
  deploymentEnabled ? "forgezero-deploy-runner.service" : null,
1044
1510
  deploymentEnabled ? "forgezero-software-helper.service" : null,
1045
1511
  lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
@@ -1048,6 +1514,7 @@ function agentUnit(options) {
1048
1514
  ].filter((value) => value !== null);
1049
1515
  const requires = [
1050
1516
  "forgezero-agent-update-helper.service",
1517
+ options.enforceEgress ? "forgezero-agent-egress.service" : null,
1051
1518
  deploymentEnabled ? "forgezero-deploy-runner.service" : null,
1052
1519
  deploymentEnabled ? "forgezero-software-helper.service" : null,
1053
1520
  lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
@@ -1057,7 +1524,8 @@ function agentUnit(options) {
1057
1524
  const deploymentDependency = [
1058
1525
  `After=${after.join(" ")}`,
1059
1526
  "Wants=network-online.target",
1060
- requires.length > 0 ? `Requires=${requires.join(" ")}` : null
1527
+ requires.length > 0 ? `Requires=${requires.join(" ")}` : null,
1528
+ options.enforceEgress ? "BindsTo=forgezero-agent-egress.service" : null
1061
1529
  ].filter((value) => value !== null).join(`
1062
1530
  `);
1063
1531
  const snpDevice = options.mode === "attested" ? `DevicePolicy=closed
@@ -1065,6 +1533,7 @@ DeviceAllow=/dev/sev-guest rw` : "";
1065
1533
  const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
1066
1534
  ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
1067
1535
  ` : "";
1536
+ const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives() : "";
1068
1537
  return `[Unit]
1069
1538
  Description=ForgeZero node agent (${options.mode})
1070
1539
  Documentation=https://www.forgezero.net/docs/agent
@@ -1109,6 +1578,7 @@ RestrictSUIDSGID=true
1109
1578
  RestrictRealtime=true
1110
1579
  MemoryDenyWriteExecute=true
1111
1580
  LockPersonality=true
1581
+ ${egressDirectives}
1112
1582
  ${snpDevice}
1113
1583
  ${deploymentWrites}
1114
1584
 
@@ -1124,6 +1594,8 @@ function planProvision(options) {
1124
1594
  const credentialDir = seedCredentialPath.replace(/\/[^/]+$/, "");
1125
1595
  const deployRoot = options.deployRoot ?? "/opt/forgezero";
1126
1596
  const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
1597
+ const runnerLoopbackPorts = normalizeEgressTcpPorts(options.runnerLoopbackPorts ?? []);
1598
+ const runnerPublicTcpPorts = normalizeEgressTcpPorts(options.runnerPublicTcpPorts ?? DEFAULT_RUNNER_PUBLIC_TCP_PORTS);
1127
1599
  const lifecycleEnabled = Boolean(options.pullMigrations && options.lifecycleProfilePath);
1128
1600
  if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
1129
1601
  throw new Error("migration pull and lifecycle profile must be supplied together");
@@ -1164,6 +1636,9 @@ function planProvision(options) {
1164
1636
  { path: AGENT_SOCKET_UNIT_PATH, unit: agentSocketUnit(options) },
1165
1637
  { path: AGENT_SOCKET_PROXY_UNIT_PATH, unit: agentSocketProxyUnit(options) },
1166
1638
  { path: AGENT_UPDATE_HELPER_UNIT_PATH, unit: agentUpdateHelperUnit(options) },
1639
+ ...options.enforceEgress ? [
1640
+ { path: AGENT_EGRESS_UNIT_PATH, unit: agentEgressUnit(options) }
1641
+ ] : [],
1167
1642
  ...deploymentEnabled ? [
1168
1643
  { path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) },
1169
1644
  { path: SOFTWARE_HELPER_UNIT_PATH, unit: softwareHelperUnit(options) }
@@ -1190,6 +1665,10 @@ function planProvision(options) {
1190
1665
  socketPath: options.socketPath,
1191
1666
  user,
1192
1667
  steps: [
1668
+ ...options.enforceEgress ? [{
1669
+ label: "Ubuntu Agent egress prerequisites",
1670
+ command: `. /etc/os-release; test "$ID" = ubuntu; ` + `DEBIAN_FRONTEND=noninteractive apt-get update -qq; ` + `DEBIAN_FRONTEND=noninteractive apt-get install -y nftables; ` + `systemctl enable --now systemd-resolved.service; ` + `test "$(readlink -f /etc/resolv.conf)" = /run/systemd/resolve/stub-resolv.conf; ` + `test -s /run/systemd/resolve/stub-resolv.conf`
1671
+ }] : [],
1193
1672
  {
1194
1673
  label: "vault socket access group",
1195
1674
  command: `groupadd --system ${VAULT_GROUP} || true`
@@ -1200,7 +1679,7 @@ function planProvision(options) {
1200
1679
  },
1201
1680
  ...sourceBinPath && binPath ? [{
1202
1681
  label: "root-owned agent runtime",
1203
- command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")} ` + `${DEFAULT_AGENT_RELEASE_ROOT}/versions/${VERSION2}/dist; ` + `install -o root -g root -m 0755 ${sourceBinPath} ` + `${DEFAULT_AGENT_RELEASE_ROOT}/versions/${VERSION2}/dist/fz-agent.js; ` + `ln -sfn versions/${VERSION2} ${DEFAULT_AGENT_RELEASE_ROOT}/current.next; ` + `mv -Tf ${DEFAULT_AGENT_RELEASE_ROOT}/current.next ${DEFAULT_AGENT_RELEASE_ROOT}/current; ` + `rm -f ${binPath}; ln -s ${DEFAULT_AGENT_RELEASE_ROOT}/current/dist/fz-agent.js ${binPath}`
1682
+ command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")} ` + `${DEFAULT_AGENT_RELEASE_ROOT}/versions/${VERSION3}/dist; ` + `install -o root -g root -m 0755 ${sourceBinPath} ` + `${DEFAULT_AGENT_RELEASE_ROOT}/versions/${VERSION3}/dist/fz-agent.js; ` + `ln -sfn versions/${VERSION3} ${DEFAULT_AGENT_RELEASE_ROOT}/current.next; ` + `mv -Tf ${DEFAULT_AGENT_RELEASE_ROOT}/current.next ${DEFAULT_AGENT_RELEASE_ROOT}/current; ` + `rm -f ${binPath}; ln -s ${DEFAULT_AGENT_RELEASE_ROOT}/current/dist/fz-agent.js ${binPath}`
1204
1683
  }] : [],
1205
1684
  ...warpEnabled ? [{
1206
1685
  label: "Cloudflare One client for Ubuntu 26.04",
@@ -1280,6 +1759,7 @@ function planProvision(options) {
1280
1759
  command: `systemctl enable ${[
1281
1760
  "forgezero-agent.socket",
1282
1761
  "forgezero-agent-update-helper.service",
1762
+ ...options.enforceEgress ? ["forgezero-agent-egress.service"] : [],
1283
1763
  ...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
1284
1764
  ...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
1285
1765
  ...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
@@ -1287,6 +1767,7 @@ function planProvision(options) {
1287
1767
  "forgezero-agent.service"
1288
1768
  ].join(" ")}; systemctl reset-failed forgezero-agent.service || true; systemctl restart ${[
1289
1769
  "forgezero-agent-update-helper.service",
1770
+ ...options.enforceEgress ? ["forgezero-agent-egress.service"] : [],
1290
1771
  ...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
1291
1772
  ...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
1292
1773
  ...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
@@ -1297,6 +1778,10 @@ function planProvision(options) {
1297
1778
  label: "prove the compute binding is durable",
1298
1779
  command: `test -s ${enrolStatePath}`
1299
1780
  }] : [],
1781
+ ...options.enforceEgress ? [{
1782
+ label: "prove the Agent egress policy is active",
1783
+ command: "systemctl is-active forgezero-agent-egress.service && " + "nft --numeric list table inet forgezero_agent_egress | grep -q forgezero-agent-egress-v1" + (deploymentEnabled ? ` && nft --numeric list table inet forgezero_agent_egress | grep -q 'public-tcp=${runnerPublicTcpPorts.join(",")}'` + (runnerLoopbackPorts.length > 0 ? ` && nft --numeric list table inet forgezero_agent_egress | grep -Eq 'loopback=[0-9]+:${runnerLoopbackPorts.join(",")}( |")'` : "") : "")
1784
+ }] : [],
1300
1785
  { label: "prove it is running", command: "systemctl is-active forgezero-agent.service" },
1301
1786
  { label: "prove the public Vault socket exists", command: awaitSocketCommand(options.socketPath) },
1302
1787
  { label: "prove the Agent Vault backend exists", command: awaitSocketCommand(agentBackendSocketPath(options.socketPath)) },
@@ -1338,6 +1823,7 @@ export {
1338
1823
  agentSocketUnit,
1339
1824
  agentSocketProxyUnit,
1340
1825
  agentEnrolmentUnit,
1826
+ agentEgressUnit,
1341
1827
  agentBackendSocketPath,
1342
1828
  WARP_SERVICE_DROP_IN_PATH,
1343
1829
  WARP_CONFIG_UNIT_PATH,
@@ -1351,7 +1837,9 @@ export {
1351
1837
  DEPLOYMENT_RUNNER_UNIT_PATH,
1352
1838
  DEPLOYMENT_RUNNER_SOCKET,
1353
1839
  DEPLOYMENT_GROUP,
1840
+ DEFAULT_RUNNER_PUBLIC_TCP_PORTS,
1354
1841
  CAPABILITY_CHECKS,
1355
1842
  AGENT_SOCKET_UNIT_PATH,
1356
- AGENT_SOCKET_PROXY_UNIT_PATH
1843
+ AGENT_SOCKET_PROXY_UNIT_PATH,
1844
+ AGENT_EGRESS_UNIT_PATH
1357
1845
  };