@forgezero/agent 0.1.26 → 0.1.28

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
@@ -1,3 +1,561 @@
1
+ // src/agent-update.ts
2
+ import { createHash, timingSafeEqual, randomUUID } from "node:crypto";
3
+ import {
4
+ chmodSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ readlinkSync,
9
+ renameSync,
10
+ rmSync,
11
+ symlinkSync,
12
+ writeFileSync
13
+ } from "node:fs";
14
+ import { dirname, join, resolve } from "node:path";
15
+ var DEFAULT_AGENT_RELEASE_ROOT = "/opt/forgezero/agent";
16
+ var DEFAULT_AGENT_UPDATE_SOCKET = "/run/forgezero-update/helper.sock";
17
+ var MAX_AGENT_TARBALL_BYTES = 32 * 1024 * 1024;
18
+ var VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
19
+ var REGISTRY = "registry.npmjs.org";
20
+ function validateAgentRelease(release) {
21
+ if (release?.package !== "@forgezero/agent")
22
+ throw new Error("agent update package is fixed");
23
+ if (!VERSION.test(release.version))
24
+ throw new Error("agent update version must be exact semver");
25
+ const expectedTarball = `/@forgezero/agent/-/agent-${release.version}.tgz`;
26
+ let url;
27
+ try {
28
+ url = new URL(release.tarball);
29
+ } catch {
30
+ throw new Error("agent update tarball URL is malformed");
31
+ }
32
+ if (url.protocol !== "https:" || url.hostname !== REGISTRY || url.port || url.username || url.password || url.search || url.hash || url.pathname !== expectedTarball)
33
+ throw new Error("agent update tarball must be the exact official npm artifact");
34
+ const match = /^sha512-([A-Za-z0-9+/]+={0,2})$/.exec(release.integrity);
35
+ if (!match || Buffer.from(match[1], "base64").length !== 64) {
36
+ throw new Error("agent update requires one sha512 npm integrity");
37
+ }
38
+ return release;
39
+ }
40
+ function compareVersions(left, right) {
41
+ if (!VERSION.test(left) || !VERSION.test(right))
42
+ throw new Error("agent version must be exact semver");
43
+ const a = left.split(".").map(Number);
44
+ const b = right.split(".").map(Number);
45
+ for (let index = 0;index < 3; index += 1) {
46
+ if (a[index] > b[index])
47
+ return 1;
48
+ if (a[index] < b[index])
49
+ return -1;
50
+ }
51
+ return 0;
52
+ }
53
+ var command = async (input) => {
54
+ const child = Bun.spawn([input.command, ...input.args], {
55
+ cwd: input.cwd,
56
+ stdout: "pipe",
57
+ stderr: "pipe",
58
+ env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }
59
+ });
60
+ const [stdout, stderr, exitCode] = await Promise.all([
61
+ new Response(child.stdout).text(),
62
+ new Response(child.stderr).text(),
63
+ child.exited
64
+ ]);
65
+ return { exitCode, output: `${stdout}${stderr}` };
66
+ };
67
+ var checked = async (run, input, label) => {
68
+ const result = await run(input);
69
+ if (result.exitCode !== 0)
70
+ throw new Error(`${label} failed: ${result.output.trim()}`);
71
+ return result;
72
+ };
73
+ async function validateReleaseDirectory(directory, release, run) {
74
+ const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
75
+ if (manifest.name !== release.package || manifest.version !== release.version) {
76
+ throw new Error("agent update manifest does not match the selected release");
77
+ }
78
+ const agent = join(directory, "dist", "fz-agent.js");
79
+ const cli = join(directory, "dist", "fz.js");
80
+ for (const binary of [agent, cli]) {
81
+ if (!readFileSync(binary, "utf8").startsWith(`#!/usr/bin/env bun
82
+ `)) {
83
+ throw new Error("agent update artifact is not a self-contained Bun executable");
84
+ }
85
+ chmodSync(binary, 493);
86
+ }
87
+ const version = (await checked(run, { command: agent, args: ["--version"] }, "agent update smoke test")).output.trim();
88
+ if (version !== release.version)
89
+ throw new Error(`agent update binary reports ${version}`);
90
+ }
91
+ async function stageAgentRelease(releaseInput, options) {
92
+ const release = validateAgentRelease(releaseInput);
93
+ if (compareVersions(release.version, options.currentVersion) <= 0) {
94
+ throw new Error(`agent update ${release.version} is not newer than ${options.currentVersion}`);
95
+ }
96
+ const root = resolve(options.root ?? DEFAULT_AGENT_RELEASE_ROOT);
97
+ const versions = join(root, "versions");
98
+ const finalDirectory = join(versions, release.version);
99
+ const currentLink = join(root, "current");
100
+ const stage = join(versions, `.${release.version}.${randomUUID()}.staging`);
101
+ const archive = join(stage, "agent.tgz");
102
+ const unpacked = join(stage, "unpacked");
103
+ const run = options.run ?? command;
104
+ mkdirSync(unpacked, { recursive: true, mode: 448 });
105
+ try {
106
+ const response = await (options.fetch ?? globalThis.fetch)(release.tarball, {
107
+ redirect: "error",
108
+ signal: AbortSignal.timeout(30000)
109
+ });
110
+ if (!response.ok)
111
+ throw new Error(`npm returned HTTP ${response.status}`);
112
+ const declared = Number(response.headers.get("content-length") ?? "0");
113
+ if (declared > MAX_AGENT_TARBALL_BYTES)
114
+ throw new Error("agent update tarball exceeds the size limit");
115
+ const bytes = Buffer.from(await response.arrayBuffer());
116
+ if (bytes.length === 0 || bytes.length > MAX_AGENT_TARBALL_BYTES) {
117
+ throw new Error("agent update tarball is empty or exceeds the size limit");
118
+ }
119
+ const expected = Buffer.from(release.integrity.slice("sha512-".length), "base64");
120
+ const actual = createHash("sha512").update(bytes).digest();
121
+ if (!timingSafeEqual(actual, expected))
122
+ throw new Error("agent update integrity mismatch");
123
+ writeFileSync(archive, bytes, { mode: 384, flag: "wx" });
124
+ await checked(run, {
125
+ command: "/usr/bin/tar",
126
+ args: [
127
+ "-xzf",
128
+ archive,
129
+ "-C",
130
+ unpacked,
131
+ "--strip-components=1",
132
+ "package/package.json",
133
+ "package/dist/fz-agent.js",
134
+ "package/dist/fz.js"
135
+ ]
136
+ }, "agent update extraction");
137
+ await validateReleaseDirectory(unpacked, release, run);
138
+ if (!existsSync(finalDirectory))
139
+ renameSync(unpacked, finalDirectory);
140
+ else
141
+ await validateReleaseDirectory(finalDirectory, release, run);
142
+ if (!existsSync(currentLink)) {
143
+ throw new Error("agent update requires an active immutable release to roll back to");
144
+ }
145
+ const previousTarget = readlinkSync(currentLink);
146
+ return {
147
+ version: release.version,
148
+ directory: finalDirectory,
149
+ previousTarget,
150
+ nextTarget: join("versions", release.version),
151
+ currentLink
152
+ };
153
+ } finally {
154
+ rmSync(stage, { recursive: true, force: true });
155
+ }
156
+ }
157
+ function selectAgentRelease(staged) {
158
+ const next = join(dirname(staged.currentLink), `.current.${randomUUID()}.next`);
159
+ try {
160
+ symlinkSync(staged.nextTarget, next);
161
+ renameSync(next, staged.currentLink);
162
+ } finally {
163
+ rmSync(next, { force: true });
164
+ }
165
+ }
166
+ function restoreAgentRelease(staged) {
167
+ const next = join(dirname(staged.currentLink), `.current.${randomUUID()}.rollback`);
168
+ try {
169
+ symlinkSync(staged.previousTarget, next);
170
+ renameSync(next, staged.currentLink);
171
+ } finally {
172
+ rmSync(next, { force: true });
173
+ }
174
+ }
175
+
176
+ // 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";
178
+ import { connect, createServer } from "node:net";
179
+ import { dirname as dirname2 } from "node:path";
180
+ import { DEFAULT_SOCKET } from "@forgezero/vault";
181
+ var AGENT_UPDATE_GROUP = "forgezero-update";
182
+ 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";
184
+ var MAX_REQUEST_BYTES = 8 * 1024;
185
+ var COMPUTE_HELPER_UNITS = [
186
+ "forgezero-deploy-runner.service",
187
+ "forgezero-lifecycle-helper.service",
188
+ "forgezero-software-helper.service"
189
+ ];
190
+ var runCommand = async (input) => {
191
+ const child = Bun.spawn([input.command, ...input.args], {
192
+ cwd: input.cwd,
193
+ stdout: "pipe",
194
+ stderr: "pipe",
195
+ env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }
196
+ });
197
+ const [stdout, stderr, exitCode] = await Promise.all([
198
+ new Response(child.stdout).text(),
199
+ new Response(child.stderr).text(),
200
+ child.exited
201
+ ]);
202
+ return { exitCode, output: `${stdout}${stderr}` };
203
+ };
204
+ var runOk = async (run, command2, args) => (await run({ command: command2, args })).exitCode === 0;
205
+ function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
206
+ return new Promise((resolve2) => {
207
+ const socket = connect(socketPath);
208
+ let settled = false;
209
+ let buffer = "";
210
+ const finish = (value) => {
211
+ if (settled)
212
+ return;
213
+ settled = true;
214
+ clearTimeout(timer);
215
+ socket.destroy();
216
+ resolve2(value);
217
+ };
218
+ const timer = setTimeout(() => finish(false), timeoutMs);
219
+ socket.on("connect", () => socket.write(`{"op":"identity"}
220
+ `));
221
+ socket.on("data", (chunk) => {
222
+ buffer += chunk.toString("utf8");
223
+ const newline = buffer.indexOf(`
224
+ `);
225
+ if (newline < 0)
226
+ return;
227
+ try {
228
+ const response = JSON.parse(buffer.slice(0, newline));
229
+ finish(response.ok === false && response.error?.code === "APP_OPERATION_REFUSED");
230
+ } catch {
231
+ finish(false);
232
+ }
233
+ });
234
+ socket.on("error", () => finish(false));
235
+ });
236
+ }
237
+ async function activateAgentRelease(staged, options = {}) {
238
+ const run = options.run ?? runCommand;
239
+ 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}`);
250
+ };
251
+ let selected = false;
252
+ try {
253
+ selectAgentRelease(staged);
254
+ selected = true;
255
+ await restart();
256
+ if (!await probe())
257
+ 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);
268
+ run({
269
+ command: "/usr/bin/systemctl",
270
+ args: ["try-restart", "--no-block", "forgezero-agent-update-helper.service"]
271
+ });
272
+ return { ok: true, version: staged.version };
273
+ } catch (cause) {
274
+ const reason = cause instanceof Error ? cause.message : String(cause);
275
+ if (selected) {
276
+ restoreAgentRelease(staged);
277
+ await restart();
278
+ }
279
+ return { ok: false, rolledBack: selected, reason };
280
+ }
281
+ }
282
+ function startAgentUpdateHelper(options = {}) {
283
+ const socketPath = options.socketPath ?? DEFAULT_AGENT_UPDATE_SOCKET;
284
+ if (existsSync2(socketPath))
285
+ unlinkSync(socketPath);
286
+ mkdirSync2(dirname2(socketPath), { recursive: true, mode: 488 });
287
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
288
+ const activate = options.activate ?? ((staged, target) => activateAgentRelease(staged, { target }));
289
+ const server = createServer((socket) => {
290
+ let buffer = "";
291
+ socket.on("data", (chunk) => {
292
+ buffer += chunk.toString("utf8");
293
+ if (Buffer.byteLength(buffer) > MAX_REQUEST_BYTES) {
294
+ socket.end(`${JSON.stringify({ ok: false, error: { code: "TOO_LARGE", message: "update request too large" } })}
295
+ `);
296
+ return;
297
+ }
298
+ const newline = buffer.indexOf(`
299
+ `);
300
+ if (newline < 0)
301
+ return;
302
+ const line = buffer.slice(0, newline);
303
+ buffer = "";
304
+ Promise.resolve().then(() => JSON.parse(line)).then(async (request) => {
305
+ if (request.op !== "apply")
306
+ throw new Error("unknown update operation");
307
+ if (request.target !== "compute" && request.target !== "metal") {
308
+ throw new Error("agent update target is invalid");
309
+ }
310
+ const staged = await stageAgentRelease(request.release, {
311
+ currentVersion: request.currentVersion,
312
+ root: options.root ?? DEFAULT_AGENT_RELEASE_ROOT
313
+ });
314
+ const response = { ok: true, status: "staged", version: staged.version };
315
+ socket.end(`${JSON.stringify(response)}
316
+ `, () => {
317
+ setTimer(() => void activate(staged, request.target), 100);
318
+ });
319
+ }).catch((cause) => {
320
+ const response = {
321
+ ok: false,
322
+ error: { code: "UPDATE_REFUSED", message: cause instanceof Error ? cause.message : String(cause) }
323
+ };
324
+ socket.end(`${JSON.stringify(response)}
325
+ `);
326
+ });
327
+ });
328
+ socket.on("error", () => socket.destroy());
329
+ });
330
+ server.listen(socketPath, () => chmodSync2(socketPath, 432));
331
+ return server;
332
+ }
333
+ function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, timeoutMs = 90000) {
334
+ return new Promise((resolve2, reject) => {
335
+ const socket = connect(socketPath, () => socket.write(`${JSON.stringify(request)}
336
+ `));
337
+ let buffer = "";
338
+ socket.setTimeout(timeoutMs, () => {
339
+ socket.destroy();
340
+ reject(new Error("agent update helper did not answer before its deadline"));
341
+ });
342
+ socket.on("data", (chunk) => {
343
+ buffer += chunk.toString("utf8");
344
+ const newline = buffer.indexOf(`
345
+ `);
346
+ if (newline < 0)
347
+ return;
348
+ socket.end();
349
+ try {
350
+ resolve2(JSON.parse(buffer.slice(0, newline)));
351
+ } catch (cause) {
352
+ reject(cause);
353
+ }
354
+ });
355
+ socket.on("error", reject);
356
+ });
357
+ }
358
+
359
+ // src/software.ts
360
+ import { readFileSync as readFileSync2 } from "node:fs";
361
+ var BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
362
+ var ARANGO_SHA256 = "b5a9197b4343f2ed554e1ebc1ef8e6529c7c39cde0035cdc311a4747a3355066";
363
+ var CLOUDFLARED_SHA256 = "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17";
364
+ var UBUNTU_2604_X64 = [
365
+ {
366
+ requirement: { id: "bun", version: "1.3.14" },
367
+ check: 'test "$(/usr/local/bin/bun --version 2>/dev/null)" = 1.3.14',
368
+ install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL https://bun.sh/install -o "$tmp/install"; ` + `echo "${BUN_INSTALLER_SHA256} $tmp/install" | sha256sum -c -; ` + `BUN_INSTALL="$tmp/bun" BUN_VERSION=1.3.14 bash "$tmp/install" >/dev/null; ` + `install -d -m 0755 /usr/local/lib/forgezero/runtime; ` + `install -m 0755 "$tmp/bun/bin/bun" /usr/local/lib/forgezero/runtime/bun.next; ` + `mv -Tf /usr/local/lib/forgezero/runtime/bun.next /usr/local/lib/forgezero/runtime/bun; ` + `ln -sfn /usr/local/lib/forgezero/runtime/bun /usr/local/bin/bun`
369
+ },
370
+ {
371
+ requirement: { id: "nginx", version: "ubuntu-26.04" },
372
+ check: "command -v nginx >/dev/null && systemctl is-active --quiet nginx",
373
+ install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y nginx && systemctl enable --now nginx"
374
+ },
375
+ {
376
+ requirement: { id: "arangodb", version: "3.11.14" },
377
+ check: `arangod --version 2>/dev/null | head -1 | grep -q '3.11.14'`,
378
+ install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://download.arangodb.com/arangodb311/DEBIAN/amd64/arangodb3_3.11.14-1_amd64.deb' -o "$tmp/arangodb.deb"; ` + `echo "${ARANGO_SHA256} $tmp/arangodb.deb" | sha256sum -c -; ` + `DEBIAN_FRONTEND=noninteractive dpkg -i "$tmp/arangodb.deb" >/dev/null 2>&1 || ` + `DEBIAN_FRONTEND=noninteractive apt-get -y -f install`
379
+ },
380
+ {
381
+ requirement: { id: "cloudflared", version: "2026.7.3" },
382
+ check: `cloudflared --version 2>/dev/null | grep -q '2026.7.3'`,
383
+ install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://github.com/cloudflare/cloudflared/releases/download/2026.7.3/cloudflared-linux-amd64' -o "$tmp/cloudflared"; ` + `echo "${CLOUDFLARED_SHA256} $tmp/cloudflared" | sha256sum -c -; ` + `install -m 0755 "$tmp/cloudflared" /usr/local/bin/cloudflared`
384
+ },
385
+ {
386
+ requirement: { id: "ufw", version: "ubuntu-26.04" },
387
+ check: "command -v ufw >/dev/null",
388
+ install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y ufw"
389
+ }
390
+ ];
391
+ function observeSoftwareHost(osRelease = readFileSync2("/etc/os-release", "utf8"), architecture = process.arch) {
392
+ const values = Object.fromEntries(osRelease.split(`
393
+ `).flatMap((line) => {
394
+ const separator = line.indexOf("=");
395
+ return separator > 0 ? [[line.slice(0, separator), line.slice(separator + 1).replace(/^['"]|['"]$/g, "")]] : [];
396
+ }));
397
+ return {
398
+ os: { id: (values.ID ?? "unknown").toLowerCase(), versionId: values.VERSION_ID ?? "unknown" },
399
+ architecture
400
+ };
401
+ }
402
+ function validateSoftwareRequirements(value) {
403
+ if (!Array.isArray(value) || value.length > 32)
404
+ throw new Error("software requirements must be an array of at most 32 entries");
405
+ const seen = new Set;
406
+ return value.map((item) => {
407
+ if (!item || typeof item !== "object" || Array.isArray(item))
408
+ throw new Error("software requirement must be an object");
409
+ const row = item;
410
+ if (Object.keys(row).some((key) => key !== "id" && key !== "version")) {
411
+ throw new Error("software requirement contains an unknown field");
412
+ }
413
+ if (!["bun", "nginx", "arangodb", "cloudflared", "ufw"].includes(String(row.id)) || typeof row.version !== "string" || !/^[A-Za-z0-9][A-Za-z0-9.-]{0,31}$/.test(row.version)) {
414
+ throw new Error("software requirement coordinate is invalid");
415
+ }
416
+ const requirement = { id: row.id, version: row.version };
417
+ if (seen.has(requirement.id))
418
+ throw new Error(`duplicate software requirement: ${requirement.id}`);
419
+ seen.add(requirement.id);
420
+ return requirement;
421
+ });
422
+ }
423
+ async function ensureSoftwareRequirements(requirementsInput, options) {
424
+ const requirements = validateSoftwareRequirements(requirementsInput);
425
+ const observation = options.observation ?? observeSoftwareHost();
426
+ if (observation.os.id !== "ubuntu" || observation.os.versionId !== "26.04" || observation.architecture !== "x64") {
427
+ throw new Error(`unsupported software strategy: ${observation.os.id} ${observation.os.versionId} ${observation.architecture}`);
428
+ }
429
+ const results = [];
430
+ for (const requirement of requirements) {
431
+ const strategy = UBUNTU_2604_X64.find(({ requirement: candidate }) => candidate.id === requirement.id && candidate.version === requirement.version);
432
+ if (!strategy)
433
+ throw new Error(`unsupported software requirement: ${requirement.id}@${requirement.version}`);
434
+ const before = await options.exec(strategy.check);
435
+ if (before.exitCode === 0) {
436
+ results.push({ ...requirement, changed: false });
437
+ continue;
438
+ }
439
+ const installed = await options.exec(strategy.install);
440
+ if (installed.exitCode !== 0)
441
+ throw new Error(`could not install ${requirement.id}@${requirement.version}: ${installed.output.trim()}`);
442
+ const after = await options.exec(strategy.check);
443
+ if (after.exitCode !== 0)
444
+ throw new Error(`${requirement.id}@${requirement.version} did not pass its post-install check`);
445
+ results.push({ ...requirement, changed: true });
446
+ }
447
+ return results;
448
+ }
449
+
450
+ // src/software-helper.ts
451
+ import { chmodSync as chmodSync3, existsSync as existsSync3, mkdirSync as mkdirSync3, unlinkSync as unlinkSync2 } from "node:fs";
452
+ import { connect as connect2, createServer as createServer2 } from "node:net";
453
+ import { dirname as dirname3 } from "node:path";
454
+ var DEFAULT_SOFTWARE_HELPER_SOCKET = "/run/forgezero-software/helper.sock";
455
+ var SOFTWARE_HELPER_GROUP = "forgezero-software";
456
+ var SOFTWARE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-software-helper.service";
457
+ var MAX_REQUEST_BYTES2 = 8 * 1024;
458
+ var MAX_PENDING_REQUESTS = 128;
459
+ var execute = async (command2) => {
460
+ const child = Bun.spawn(["/bin/bash", "-Eeuo", "pipefail", "-c", command2], {
461
+ stdout: "pipe",
462
+ stderr: "pipe",
463
+ env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }
464
+ });
465
+ const [stdout, stderr, exitCode] = await Promise.all([
466
+ new Response(child.stdout).text(),
467
+ new Response(child.stderr).text(),
468
+ child.exited
469
+ ]);
470
+ return { exitCode, output: `${stdout}${stderr}` };
471
+ };
472
+ function startSoftwareHelper(options = {}) {
473
+ const socketPath = options.socketPath ?? DEFAULT_SOFTWARE_HELPER_SOCKET;
474
+ if (existsSync3(socketPath))
475
+ unlinkSync2(socketPath);
476
+ mkdirSync3(dirname3(socketPath), { recursive: true, mode: 488 });
477
+ const ensure = options.ensure ?? ensureSoftwareRequirements;
478
+ let tail = Promise.resolve();
479
+ let pending = 0;
480
+ const server = createServer2((socket) => {
481
+ let buffer = "";
482
+ socket.on("data", (chunk) => {
483
+ buffer += chunk.toString("utf8");
484
+ if (Buffer.byteLength(buffer) > MAX_REQUEST_BYTES2)
485
+ return socket.destroy();
486
+ const newline = buffer.indexOf(`
487
+ `);
488
+ if (newline < 0)
489
+ return;
490
+ const line = buffer.slice(0, newline);
491
+ buffer = "";
492
+ if (pending >= MAX_PENDING_REQUESTS) {
493
+ socket.end(`${JSON.stringify({
494
+ ok: false,
495
+ error: { code: "SOFTWARE_BUSY", message: "software helper queue is full" }
496
+ })}
497
+ `);
498
+ return;
499
+ }
500
+ pending += 1;
501
+ const work = tail.then(() => Promise.resolve().then(() => JSON.parse(line)).then(async (request) => {
502
+ if (request.op !== "ensure")
503
+ throw new Error("unknown software helper operation");
504
+ const requirements = validateSoftwareRequirements(request.requirements);
505
+ const results = await ensure(requirements, { exec: execute });
506
+ socket.end(`${JSON.stringify({ ok: true, results })}
507
+ `);
508
+ }).catch((cause) => socket.end(`${JSON.stringify({
509
+ ok: false,
510
+ error: { code: "SOFTWARE_REFUSED", message: cause instanceof Error ? cause.message : String(cause) }
511
+ })}
512
+ `)).finally(() => {
513
+ pending -= 1;
514
+ }));
515
+ tail = work.then(() => {
516
+ return;
517
+ }, () => {
518
+ return;
519
+ });
520
+ });
521
+ socket.on("error", () => socket.destroy());
522
+ });
523
+ server.listen(socketPath, () => chmodSync3(socketPath, 432));
524
+ return server;
525
+ }
526
+ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCKET, timeoutMs = 15 * 60000) {
527
+ validateSoftwareRequirements(requirements);
528
+ return new Promise((resolve2, reject) => {
529
+ const socket = connect2(socketPath, () => socket.write(`${JSON.stringify({ op: "ensure", requirements })}
530
+ `));
531
+ let buffer = "";
532
+ socket.setTimeout(timeoutMs, () => {
533
+ socket.destroy();
534
+ reject(new Error("software helper did not answer before its deadline"));
535
+ });
536
+ socket.on("data", (chunk) => {
537
+ buffer += chunk.toString("utf8");
538
+ const newline = buffer.indexOf(`
539
+ `);
540
+ if (newline < 0)
541
+ return;
542
+ socket.end();
543
+ try {
544
+ const response = JSON.parse(buffer.slice(0, newline));
545
+ if (!response.ok || !response.results)
546
+ throw new Error(response.error?.message ?? "software helper refused the request");
547
+ resolve2(response.results);
548
+ } catch (cause) {
549
+ reject(cause);
550
+ }
551
+ });
552
+ socket.on("error", reject);
553
+ });
554
+ }
555
+
556
+ // src/version.ts
557
+ var VERSION2 = "0.1.28";
558
+
1
559
  // src/provision.ts
2
560
  function atLeast(version, floor) {
3
561
  const parse = (value) => (value.trim().replace(/^v/, "").match(/\d+/g) ?? []).slice(0, 3).map(Number);
@@ -44,12 +602,102 @@ var DEPLOYMENT_GROUP = "forgezero-deploy";
44
602
  var VAULT_GROUP = "forgezero-vault";
45
603
  var LIFECYCLE_GROUP = "forgezero-lifecycle";
46
604
  var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
605
+ var AGENT_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-agent.socket";
47
606
  var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
48
607
  var ENROLMENT_UNIT_PATH = "/etc/systemd/system/forgezero-agent-enrol.service";
49
608
  var LIFECYCLE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-lifecycle-helper.service";
50
609
  var LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
51
610
  var WARP_CONFIG_UNIT_PATH = "/etc/systemd/system/forgezero-warp-config.service";
52
611
  var WARP_SERVICE_DROP_IN_PATH = "/etc/systemd/system/warp-svc.service.d/forgezero.conf";
612
+ function softwareHelperUnit(options) {
613
+ const bin = options.binPath ?? "fz-agent";
614
+ return `[Unit]
615
+ Description=ForgeZero declarative software strategy helper
616
+ Documentation=https://www.forgezero.net/docs/agent
617
+ After=network-online.target
618
+ Wants=network-online.target
619
+
620
+ [Service]
621
+ Type=simple
622
+ User=root
623
+ Group=${SOFTWARE_HELPER_GROUP}
624
+ Environment=FZ_SOFTWARE_HELPER_SOCKET=${DEFAULT_SOFTWARE_HELPER_SOCKET}
625
+ ExecStart=${bin} software-helper
626
+ Restart=always
627
+ RestartSec=2
628
+ RuntimeDirectory=forgezero-software
629
+ RuntimeDirectoryMode=0750
630
+ UMask=0007
631
+ LimitCORE=0
632
+ PrivateTmp=true
633
+ ProtectHome=true
634
+ ProtectKernelTunables=true
635
+ ProtectKernelModules=true
636
+ ProtectControlGroups=true
637
+ RestrictRealtime=true
638
+ LockPersonality=true
639
+ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
640
+
641
+ [Install]
642
+ WantedBy=multi-user.target
643
+ `;
644
+ }
645
+ function agentUpdateHelperUnit(options) {
646
+ const bin = options.binPath ?? "fz-agent";
647
+ return `[Unit]
648
+ Description=ForgeZero verified Agent update helper
649
+ Documentation=https://www.forgezero.net/docs/agent
650
+ After=network-online.target
651
+ Wants=network-online.target
652
+
653
+ [Service]
654
+ Type=simple
655
+ User=root
656
+ Group=${AGENT_UPDATE_GROUP}
657
+ Environment=FZ_AGENT_UPDATE_SOCKET=${DEFAULT_AGENT_UPDATE_SOCKET}
658
+ ExecStart=${bin} update-helper
659
+ Restart=always
660
+ RestartSec=2
661
+ RuntimeDirectory=forgezero-update
662
+ RuntimeDirectoryMode=0750
663
+ UMask=0007
664
+ LimitCORE=0
665
+ NoNewPrivileges=true
666
+ PrivateTmp=true
667
+ ProtectSystem=strict
668
+ ProtectHome=true
669
+ ProtectKernelTunables=true
670
+ ProtectKernelModules=true
671
+ ProtectControlGroups=true
672
+ RestrictSUIDSGID=true
673
+ RestrictRealtime=true
674
+ LockPersonality=true
675
+ ReadWritePaths=${DEFAULT_AGENT_RELEASE_ROOT} /var/lib/forgezero
676
+ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
677
+
678
+ [Install]
679
+ WantedBy=multi-user.target
680
+ `;
681
+ }
682
+ function agentSocketUnit(options) {
683
+ const socket = systemdPath(options.socketPath, "agent socket");
684
+ return `[Unit]
685
+ Description=ForgeZero application Vault socket
686
+ Documentation=https://www.forgezero.net/docs/agent
687
+
688
+ [Socket]
689
+ ListenStream=${socket}
690
+ SocketUser=root
691
+ SocketGroup=${VAULT_GROUP}
692
+ SocketMode=0660
693
+ DirectoryMode=0750
694
+ RemoveOnStop=true
695
+ Service=forgezero-agent.service
696
+
697
+ [Install]
698
+ WantedBy=sockets.target
699
+ `;
700
+ }
53
701
  var systemdPath = (value, label) => {
54
702
  if (!value || !/^\/[A-Za-z0-9._@/-]+$/.test(value))
55
703
  throw new Error(`invalid ${label} path`);
@@ -310,12 +958,14 @@ function agentUnit(options) {
310
958
  options.repository && options.branch ? `FZ_DEPLOY_KEY=${options.project ?? "platform"}:${options.environment ?? "production"}` : null,
311
959
  deploymentEnabled ? `FZ_DEPLOY_ROOT=${deployRoot}` : null,
312
960
  deploymentEnabled ? `FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}` : null,
961
+ deploymentEnabled ? `FZ_SOFTWARE_HELPER_SOCKET=${DEFAULT_SOFTWARE_HELPER_SOCKET}` : null,
313
962
  Object.keys(deploymentCredentials).length > 0 ? `FZ_DEPLOY_SYSTEMD_SECRETS=${Object.keys(deploymentCredentials).join(",")}` : null,
314
963
  Object.keys(deploymentEnvironment).length > 0 ? `FZ_DEPLOY_ENV_NAMES=${Object.keys(deploymentEnvironment).join(",")}` : null,
315
964
  ...Object.entries(deploymentEnvironment).map(([name, value]) => `${name}=${value}`),
316
965
  options.publicApiUrl ? `FZ_PUBLIC_API_URL=${options.publicApiUrl}` : null,
317
966
  options.pullDeployments ? "FZ_DEPLOY_PULL=true" : null,
318
967
  options.pullMigrations ? "FZ_MIGRATION_PULL=true" : null,
968
+ `FZ_AGENT_UPDATE_SOCKET=${DEFAULT_AGENT_UPDATE_SOCKET}`,
319
969
  options.pullMigrations ? `FZ_LIFECYCLE_HELPER_SOCKET=${lifecycleHelperSocketPath}` : null
320
970
  ].filter((line) => line !== null);
321
971
  if (deploymentEnabled) {
@@ -327,19 +977,25 @@ function agentUnit(options) {
327
977
  `);
328
978
  const deploymentWrites = deploymentEnabled ? `ReadWritePaths=${deployRoot}/releases ${deployRoot}/agent-home ${deployRoot}/cache` : "";
329
979
  const supplementaryGroups = [
980
+ AGENT_UPDATE_GROUP,
330
981
  deploymentEnabled ? DEPLOYMENT_GROUP : null,
982
+ deploymentEnabled ? SOFTWARE_HELPER_GROUP : null,
331
983
  lifecycleEnabled ? LIFECYCLE_GROUP : null
332
984
  ].filter((value) => value !== null);
333
985
  const deploymentGroup = supplementaryGroups.length > 0 ? `SupplementaryGroups=${supplementaryGroups.join(" ")}` : "";
334
986
  const after = [
335
987
  "network-online.target",
988
+ "forgezero-agent-update-helper.service",
336
989
  deploymentEnabled ? "forgezero-deploy-runner.service" : null,
990
+ deploymentEnabled ? "forgezero-software-helper.service" : null,
337
991
  lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
338
992
  warpEnabled ? "warp-svc.service" : null,
339
993
  enrolmentEnabled ? "forgezero-agent-enrol.service" : null
340
994
  ].filter((value) => value !== null);
341
995
  const requires = [
996
+ "forgezero-agent-update-helper.service",
342
997
  deploymentEnabled ? "forgezero-deploy-runner.service" : null,
998
+ deploymentEnabled ? "forgezero-software-helper.service" : null,
343
999
  lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
344
1000
  warpEnabled ? "warp-svc.service" : null,
345
1001
  enrolmentEnabled ? "forgezero-agent-enrol.service" : null
@@ -365,6 +1021,7 @@ Type=simple
365
1021
  User=${user}
366
1022
  Group=${VAULT_GROUP}
367
1023
  ${deploymentGroup}
1024
+ Sockets=forgezero-agent.socket
368
1025
  LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
369
1026
  ${gitCredential}${projectCredentials}${projectCredentials ? `
370
1027
  ` : ""}${snpPrepare}ExecStart=${bin}
@@ -383,6 +1040,7 @@ LimitCORE=0
383
1040
  # than wherever the process happened to have write access.
384
1041
  RuntimeDirectory=forgezero
385
1042
  RuntimeDirectoryMode=0750
1043
+ RuntimeDirectoryPreserve=yes
386
1044
  UMask=0007
387
1045
 
388
1046
  # Tenant-controlled commands execute in forgezero-deploy-runner.service. This
@@ -450,8 +1108,11 @@ function planProvision(options) {
450
1108
  unitPath: UNIT_PATH,
451
1109
  unit: agentUnit({ ...options, mode, lifecycleProfilePath, lifecycleHelperSocketPath }),
452
1110
  auxiliaryUnits: [
1111
+ { path: AGENT_SOCKET_UNIT_PATH, unit: agentSocketUnit(options) },
1112
+ { path: AGENT_UPDATE_HELPER_UNIT_PATH, unit: agentUpdateHelperUnit(options) },
453
1113
  ...deploymentEnabled ? [
454
- { path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) }
1114
+ { path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) },
1115
+ { path: SOFTWARE_HELPER_UNIT_PATH, unit: softwareHelperUnit(options) }
455
1116
  ] : [],
456
1117
  ...enrolmentEnabled ? [
457
1118
  { path: ENROLMENT_UNIT_PATH, unit: agentEnrolmentUnit(options) }
@@ -479,9 +1140,13 @@ function planProvision(options) {
479
1140
  label: "vault socket access group",
480
1141
  command: `groupadd --system ${VAULT_GROUP} || true`
481
1142
  },
1143
+ {
1144
+ label: "Agent update helper access group",
1145
+ command: `groupadd --system ${AGENT_UPDATE_GROUP} || true`
1146
+ },
482
1147
  ...sourceBinPath && binPath ? [{
483
1148
  label: "root-owned agent runtime",
484
- command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")}; ` + `install -o root -g root -m 0755 ${sourceBinPath} ${binPath}`
1149
+ 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}`
485
1150
  }] : [],
486
1151
  ...warpEnabled ? [{
487
1152
  label: "Cloudflare One client for Ubuntu 26.04",
@@ -489,7 +1154,7 @@ function planProvision(options) {
489
1154
  }] : [],
490
1155
  ...deploymentEnabled ? [{
491
1156
  label: "deployment isolation group",
492
- command: `groupadd --system ${DEPLOYMENT_GROUP} || true`
1157
+ command: `groupadd --system ${DEPLOYMENT_GROUP} || true; groupadd --system ${SOFTWARE_HELPER_GROUP} || true`
493
1158
  }] : [],
494
1159
  ...lifecycleEnabled ? [{
495
1160
  label: "lifecycle helper access group",
@@ -503,13 +1168,17 @@ function planProvision(options) {
503
1168
  label: "bind service account to vault group",
504
1169
  command: `usermod -g ${VAULT_GROUP} ${user}`
505
1170
  },
1171
+ {
1172
+ label: "grant verified Agent update access",
1173
+ command: `usermod -a -G ${AGENT_UPDATE_GROUP} ${user}`
1174
+ },
506
1175
  ...lifecycleEnabled ? [{
507
1176
  label: "grant lifecycle helper socket access",
508
1177
  command: `usermod -a -G ${LIFECYCLE_GROUP} ${user}`
509
1178
  }] : [],
510
1179
  ...deploymentEnabled ? [{
511
1180
  label: "credential-free deployment account",
512
- command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP} ${user}`
1181
+ command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP},${SOFTWARE_HELPER_GROUP} ${user}`
513
1182
  }] : [],
514
1183
  {
515
1184
  label: "credential directory",
@@ -551,7 +1220,9 @@ function planProvision(options) {
551
1220
  {
552
1221
  label: "enable and start",
553
1222
  command: `systemctl enable --now ${[
554
- ...deploymentEnabled ? ["forgezero-deploy-runner.service"] : [],
1223
+ "forgezero-agent.socket",
1224
+ "forgezero-agent-update-helper.service",
1225
+ ...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
555
1226
  ...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
556
1227
  ...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
557
1228
  ...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : [],
@@ -564,9 +1235,13 @@ function planProvision(options) {
564
1235
  }] : [],
565
1236
  { label: "prove it is running", command: "systemctl is-active forgezero-agent.service" },
566
1237
  { label: "prove the vault socket exists", command: `test -S ${options.socketPath}` },
1238
+ { label: "prove the Agent update helper exists", command: `test -S ${DEFAULT_AGENT_UPDATE_SOCKET}` },
567
1239
  ...deploymentEnabled ? [{
568
1240
  label: "prove the deployment runner socket exists",
569
1241
  command: `test -S ${DEPLOYMENT_RUNNER_SOCKET}`
1242
+ }, {
1243
+ label: "prove the software strategy helper socket exists",
1244
+ command: `test -S ${DEFAULT_SOFTWARE_HELPER_SOCKET}`
570
1245
  }] : [],
571
1246
  ...lifecycleEnabled ? [{
572
1247
  label: "prove the lifecycle helper socket exists",
@@ -586,13 +1261,16 @@ function planProvision(options) {
586
1261
  export {
587
1262
  warpServiceDropIn,
588
1263
  warpConfigUnit,
1264
+ softwareHelperUnit,
589
1265
  reasonFor,
590
1266
  planProvision,
591
1267
  modeFor,
592
1268
  lifecycleHelperUnit,
593
1269
  deploymentRunnerUnit,
594
1270
  atLeast,
1271
+ agentUpdateHelperUnit,
595
1272
  agentUnit,
1273
+ agentSocketUnit,
596
1274
  agentEnrolmentUnit,
597
1275
  WARP_SERVICE_DROP_IN_PATH,
598
1276
  WARP_CONFIG_UNIT_PATH,
@@ -606,5 +1284,6 @@ export {
606
1284
  DEPLOYMENT_RUNNER_UNIT_PATH,
607
1285
  DEPLOYMENT_RUNNER_SOCKET,
608
1286
  DEPLOYMENT_GROUP,
609
- CAPABILITY_CHECKS
1287
+ CAPABILITY_CHECKS,
1288
+ AGENT_SOCKET_UNIT_PATH
610
1289
  };