@forgezero/agent 0.1.41 → 0.1.42

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.
Files changed (64) hide show
  1. package/README.md +299 -86
  2. package/dist/agent-heartbeat.js +6 -3
  3. package/dist/agent-update-helper.js +5 -2
  4. package/dist/agent-update.js +5 -2
  5. package/dist/bootstrap.d.ts +17 -8
  6. package/dist/bootstrap.js +1504 -512
  7. package/dist/cli/agent-install.d.ts +6 -5
  8. package/dist/cli/cloudflare-bootstrap.d.ts +12 -1
  9. package/dist/cli/maintenance.d.ts +23 -0
  10. package/dist/cli/run.d.ts +3 -1
  11. package/dist/cli/session-store.d.ts +5 -0
  12. package/dist/cloudflare-bootstrap.d.ts +73 -35
  13. package/dist/cloudflare-bootstrap.js +587 -90
  14. package/dist/cloudflare-edge.d.ts +64 -12
  15. package/dist/cloudflare-edge.js +103 -8
  16. package/dist/community-rehearsal-host.d.ts +51 -0
  17. package/dist/community-rehearsal-host.js +272 -0
  18. package/dist/credential-schema.d.ts +54 -0
  19. package/dist/credential-schema.js +47 -0
  20. package/dist/definition.d.ts +31 -5
  21. package/dist/definition.js +271 -44
  22. package/dist/deploy-file.js +294 -68
  23. package/dist/deployment-runner.js +18 -5
  24. package/dist/deployment.d.ts +13 -1
  25. package/dist/fz-agent.js +3932 -582
  26. package/dist/fz-git-ssh.js +122 -0
  27. package/dist/fz.js +3634 -1266
  28. package/dist/git-ssh.d.ts +5 -0
  29. package/dist/guest-enrolment.d.ts +2 -0
  30. package/dist/guest-enrolment.js +1 -0
  31. package/dist/host-maintenance.d.ts +39 -0
  32. package/dist/host-maintenance.js +135 -0
  33. package/dist/index.d.ts +4 -2
  34. package/dist/mesh-connector.d.ts +16 -0
  35. package/dist/mesh-connector.js +46 -0
  36. package/dist/metal-bootstrap.js +145 -7
  37. package/dist/metal-helper-socket.js +61 -31
  38. package/dist/metal-provision.d.ts +2 -2
  39. package/dist/metal-provision.js +62 -32
  40. package/dist/operator-bootstrap.d.ts +90 -0
  41. package/dist/operator-bootstrap.js +5704 -0
  42. package/dist/otel-collector.d.ts +18 -0
  43. package/dist/pipeline.d.ts +3 -2
  44. package/dist/pipeline.js +1 -1
  45. package/dist/platform-bootstrap-runtime.d.ts +39 -21
  46. package/dist/platform-bootstrap-runtime.js +182 -59
  47. package/dist/platform-fleet-verification.d.ts +19 -0
  48. package/dist/platform-fleet-verification.js +3873 -0
  49. package/dist/platform-genesis-config.d.ts +7 -0
  50. package/dist/platform-genesis.d.ts +17 -0
  51. package/dist/provision.d.ts +76 -3
  52. package/dist/provision.js +1061 -229
  53. package/dist/recovery-host.d.ts +7 -0
  54. package/dist/recovery-host.js +124 -0
  55. package/dist/service-supervisor.d.ts +42 -0
  56. package/dist/software-helper.d.ts +4 -0
  57. package/dist/software-helper.js +865 -63
  58. package/dist/software.d.ts +14 -3
  59. package/dist/software.js +163 -37
  60. package/dist/ssh-bootstrap.d.ts +97 -0
  61. package/dist/supervised-app.d.ts +2 -0
  62. package/dist/version.d.ts +1 -1
  63. package/package.json +175 -164
  64. package/schema/{deploy-v2.json → deploy-v3.json} +53 -6
@@ -1,7 +1,22 @@
1
1
  // src/software.ts
2
- import { readFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import {
4
+ accessSync,
5
+ chmodSync,
6
+ copyFileSync,
7
+ mkdtempSync,
8
+ mkdirSync,
9
+ readFileSync,
10
+ renameSync,
11
+ rmSync,
12
+ symlinkSync,
13
+ unlinkSync,
14
+ writeFileSync
15
+ } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
3
18
  var PINNED_BUN_VERSION = "1.3.14";
4
- var BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
19
+ var BUN_RELEASE_SHA256 = "951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f";
5
20
  var ARANGO_SHA256 = "b5a9197b4343f2ed554e1ebc1ef8e6529c7c39cde0035cdc311a4747a3355066";
6
21
  var CLOUDFLARED_SHA256 = "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17";
7
22
  var OS_CATALOG = [
@@ -12,41 +27,151 @@ var SOFTWARE_CATALOG = [
12
27
  { id: "nginx", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
13
28
  { id: "arangodb", version: "3.11.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
14
29
  { id: "cloudflared", version: "2026.7.3", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
30
+ { id: "cloudflare-warp", version: "2026.6.822.0-min", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
15
31
  { id: "ufw", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
16
32
  { id: "openssh-client", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" }
17
33
  ];
18
34
  var UBUNTU_2604_X64 = [
19
- {
20
- requirement: { id: "bun", version: "1.3.14" },
21
- check: 'test "$(/usr/local/bin/bun --version 2>/dev/null)" = 1.3.14',
22
- 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`
23
- },
24
- {
25
- requirement: { id: "nginx", version: "ubuntu-26.04" },
26
- check: "command -v nginx >/dev/null && systemctl is-active --quiet nginx",
27
- install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y nginx && systemctl enable --now nginx"
28
- },
29
- {
30
- requirement: { id: "arangodb", version: "3.11.14" },
31
- check: `arangod --version 2>/dev/null | head -1 | grep -q '3.11.14' && ` + `! systemctl is-active --quiet arangodb3.service && ` + `! systemctl is-enabled --quiet arangodb3.service`,
32
- 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; ` + `systemctl disable --now arangodb3.service`
33
- },
34
- {
35
- requirement: { id: "cloudflared", version: "2026.7.3" },
36
- check: `cloudflared --version 2>/dev/null | grep -q '2026.7.3'`,
37
- 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`
38
- },
39
- {
40
- requirement: { id: "ufw", version: "ubuntu-26.04" },
41
- check: "command -v ufw >/dev/null",
42
- install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y ufw"
43
- },
44
- {
45
- requirement: { id: "openssh-client", version: "ubuntu-26.04" },
46
- check: "command -v ssh >/dev/null && command -v scp >/dev/null && command -v ssh-keyscan >/dev/null && command -v ssh-keygen >/dev/null",
47
- install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y openssh-client"
48
- }
35
+ { requirement: { id: "bun", version: "1.3.14" } },
36
+ { requirement: { id: "nginx", version: "ubuntu-26.04" } },
37
+ { requirement: { id: "arangodb", version: "3.11.14" } },
38
+ { requirement: { id: "cloudflared", version: "2026.7.3" } },
39
+ { requirement: { id: "cloudflare-warp", version: "2026.6.822.0-min" } },
40
+ { requirement: { id: "ufw", version: "ubuntu-26.04" } },
41
+ { requirement: { id: "openssh-client", version: "ubuntu-26.04" } }
49
42
  ];
43
+ var path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
44
+ var run = async (argv, env = {}) => {
45
+ const child = Bun.spawn([...argv], { stdout: "pipe", stderr: "pipe", env: { PATH: path, LANG: "C", LC_ALL: "C", ...env } });
46
+ const [stdout, stderr, exitCode] = await Promise.all([
47
+ new Response(child.stdout).text(),
48
+ new Response(child.stderr).text(),
49
+ child.exited
50
+ ]);
51
+ return { exitCode, output: `${stdout}${stderr}` };
52
+ };
53
+ var download = async (url, destination, sha256) => {
54
+ const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120000) });
55
+ if (!response.ok)
56
+ throw new Error(`download failed with HTTP ${response.status}`);
57
+ const bytes = new Uint8Array(await response.arrayBuffer());
58
+ if (createHash("sha256").update(bytes).digest("hex") !== sha256)
59
+ throw new Error("download checksum mismatch");
60
+ writeFileSync(destination, bytes, { mode: 384, flag: "wx" });
61
+ };
62
+ var successful = (result, pattern) => result.exitCode === 0 && (!pattern || pattern.test(result.output));
63
+ var aptInstall = async (name) => {
64
+ const environment = { DEBIAN_FRONTEND: "noninteractive" };
65
+ const update = await run(["/usr/bin/apt-get", "update", "-qq"], environment);
66
+ return update.exitCode === 0 ? run(["/usr/bin/apt-get", "install", "-y", name], environment) : update;
67
+ };
68
+ async function executeSoftwareOperation(operation) {
69
+ const { software, version } = operation;
70
+ if (!UBUNTU_2604_X64.some(({ requirement }) => requirement.id === software && requirement.version === version)) {
71
+ return { exitCode: 2, output: "unsupported software operation" };
72
+ }
73
+ if (operation.kind === "check") {
74
+ if (software === "bun")
75
+ return run(["/usr/local/bin/bun", "--version"]).then((r) => ({ ...r, exitCode: successful(r, /^1\.3\.14\s*$/m) ? 0 : 1 }));
76
+ if (software === "nginx") {
77
+ const binary = await run(["/usr/sbin/nginx", "-v"]);
78
+ return binary.exitCode === 0 ? run(["/usr/bin/systemctl", "is-active", "--quiet", "nginx.service"]) : binary;
79
+ }
80
+ if (software === "arangodb") {
81
+ const binary = await run(["/usr/bin/arangod", "--version"]);
82
+ if (!successful(binary, /3\.11\.14/))
83
+ return { ...binary, exitCode: 1 };
84
+ const [active, enabled] = await Promise.all([
85
+ run(["/usr/bin/systemctl", "is-active", "--quiet", "arangodb3.service"]),
86
+ run(["/usr/bin/systemctl", "is-enabled", "--quiet", "arangodb3.service"])
87
+ ]);
88
+ return active.exitCode !== 0 && enabled.exitCode !== 0 ? { exitCode: 0, output: binary.output } : { exitCode: 1, output: "vendor standalone unit remains active or enabled" };
89
+ }
90
+ if (software === "cloudflared")
91
+ return run(["/usr/local/bin/cloudflared", "--version"]).then((r) => ({ ...r, exitCode: successful(r, /2026\.7\.3/) ? 0 : 1 }));
92
+ if (software === "cloudflare-warp")
93
+ return run(["/usr/bin/warp-cli", "--version"]).then((result) => {
94
+ const match = result.output.match(/(\d{4})\.(\d+)\.(\d+)\.(\d+)/);
95
+ const observed = match?.slice(1).map(Number);
96
+ const minimum = [2026, 6, 822, 0];
97
+ const supported = observed && observed.some((part, index) => part > minimum[index] && observed.slice(0, index).every((prior, priorIndex) => prior === minimum[priorIndex])) || observed?.every((part, index) => part === minimum[index]);
98
+ return { ...result, exitCode: result.exitCode === 0 && supported ? 0 : 1 };
99
+ });
100
+ const binaries = software === "ufw" ? ["/usr/sbin/ufw"] : ["/usr/bin/ssh", "/usr/bin/scp", "/usr/bin/ssh-keyscan", "/usr/bin/ssh-keygen"];
101
+ try {
102
+ binaries.forEach((binary) => accessSync(binary));
103
+ return { exitCode: 0, output: "" };
104
+ } catch (cause) {
105
+ return { exitCode: 1, output: cause instanceof Error ? cause.message : String(cause) };
106
+ }
107
+ }
108
+ if (software === "nginx" || software === "ufw" || software === "openssh-client") {
109
+ const installed = await aptInstall(software === "openssh-client" ? "openssh-client" : software);
110
+ if (installed.exitCode !== 0 || software !== "nginx")
111
+ return installed;
112
+ return run(["/usr/bin/systemctl", "enable", "--now", "nginx.service"]);
113
+ }
114
+ const directory = mkdtempSync(join(tmpdir(), "forgezero-software-"));
115
+ try {
116
+ if (software === "cloudflare-warp") {
117
+ const key = join(directory, "cloudflare-warp-key.gpg");
118
+ await download("https://pkg.cloudflareclient.com/pubkey.gpg", key, "0f37fc298c98e88ee3c0ee68c95b69f1dba9eb477abe3167e13982105911264d");
119
+ mkdirSync("/usr/share/keyrings", { recursive: true, mode: 493 });
120
+ const dearmored = await run([
121
+ "/usr/bin/gpg",
122
+ "--batch",
123
+ "--yes",
124
+ "--dearmor",
125
+ "-o",
126
+ "/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg",
127
+ key
128
+ ]);
129
+ if (dearmored.exitCode !== 0)
130
+ return dearmored;
131
+ mkdirSync("/etc/apt/sources.list.d", { recursive: true, mode: 493 });
132
+ writeFileSync("/etc/apt/sources.list.d/cloudflare-client.list", `deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ resolute main
133
+ `, { mode: 420 });
134
+ return aptInstall("cloudflare-warp");
135
+ }
136
+ if (software === "bun") {
137
+ const archive = join(directory, "bun.zip");
138
+ await download("https://github.com/oven-sh/bun/releases/download/bun-v1.3.14/bun-linux-x64.zip", archive, BUN_RELEASE_SHA256);
139
+ const unpacked = join(directory, "unpacked");
140
+ mkdirSync(unpacked, { mode: 448 });
141
+ const unzipped = await run(["/usr/bin/unzip", "-q", archive, "-d", unpacked]);
142
+ if (unzipped.exitCode !== 0)
143
+ return unzipped;
144
+ mkdirSync("/usr/local/lib/forgezero/runtime", { recursive: true, mode: 493 });
145
+ copyFileSync(join(unpacked, "bun-linux-x64", "bun"), "/usr/local/lib/forgezero/runtime/bun.next");
146
+ chmodSync("/usr/local/lib/forgezero/runtime/bun.next", 493);
147
+ renameSync("/usr/local/lib/forgezero/runtime/bun.next", "/usr/local/lib/forgezero/runtime/bun");
148
+ try {
149
+ unlinkSync("/usr/local/bin/bun");
150
+ } catch {}
151
+ symlinkSync("/usr/local/lib/forgezero/runtime/bun", "/usr/local/bin/bun");
152
+ return { exitCode: 0, output: "" };
153
+ }
154
+ if (software === "cloudflared") {
155
+ const binary = join(directory, "cloudflared");
156
+ await download("https://github.com/cloudflare/cloudflared/releases/download/2026.7.3/cloudflared-linux-amd64", binary, CLOUDFLARED_SHA256);
157
+ chmodSync(binary, 493);
158
+ copyFileSync(binary, "/usr/local/bin/cloudflared");
159
+ chmodSync("/usr/local/bin/cloudflared", 493);
160
+ return { exitCode: 0, output: "" };
161
+ }
162
+ const deb = join(directory, "arangodb.deb");
163
+ await download("https://download.arangodb.com/arangodb311/DEBIAN/amd64/arangodb3_3.11.14-1_amd64.deb", deb, ARANGO_SHA256);
164
+ let installed = await run(["/usr/bin/dpkg", "-i", deb], { DEBIAN_FRONTEND: "noninteractive" });
165
+ if (installed.exitCode !== 0)
166
+ installed = await run(["/usr/bin/apt-get", "-y", "-f", "install"], { DEBIAN_FRONTEND: "noninteractive" });
167
+ if (installed.exitCode !== 0)
168
+ return installed;
169
+ await run(["/usr/bin/systemctl", "disable", "--now", "arangodb3.service"]);
170
+ return { exitCode: 0, output: "" };
171
+ } finally {
172
+ rmSync(directory, { recursive: true, force: true });
173
+ }
174
+ }
50
175
  function observeSoftwareHost(osRelease = readFileSync("/etc/os-release", "utf8"), architecture = process.arch) {
51
176
  const values = Object.fromEntries(osRelease.split(`
52
177
  `).flatMap((line) => {
@@ -69,7 +194,7 @@ function validateSoftwareRequirements(value, _options = {}) {
69
194
  if (Object.keys(row).some((key) => key !== "id" && key !== "version")) {
70
195
  throw new Error("software requirement contains an unknown field");
71
196
  }
72
- if (!["bun", "nginx", "arangodb", "cloudflared", "ufw", "openssh-client"].includes(String(row.id)) || typeof row.version !== "string" || !/^[A-Za-z0-9][A-Za-z0-9.-]{0,31}$/.test(row.version)) {
197
+ if (!["bun", "nginx", "arangodb", "cloudflared", "cloudflare-warp", "ufw", "openssh-client"].includes(String(row.id)) || typeof row.version !== "string" || !/^[A-Za-z0-9][A-Za-z0-9.-]{0,31}$/.test(row.version)) {
73
198
  throw new Error("software requirement coordinate is invalid");
74
199
  }
75
200
  const requirement = { id: row.id, version: row.version };
@@ -95,15 +220,15 @@ async function ensureSoftwareRequirements(requirementsInput, options) {
95
220
  const strategy = UBUNTU_2604_X64.find(({ requirement: candidate }) => candidate.id === requirement.id && candidate.version === requirement.version);
96
221
  if (!strategy)
97
222
  throw new Error(`unsupported software requirement: ${requirement.id}@${requirement.version}`);
98
- const before = await options.exec(strategy.check);
223
+ const before = await options.exec({ kind: "check", software: requirement.id, version: requirement.version });
99
224
  if (before.exitCode === 0) {
100
225
  results.push({ ...requirement, changed: false });
101
226
  continue;
102
227
  }
103
- const installed = await options.exec(strategy.install);
228
+ const installed = await options.exec({ kind: "install", software: requirement.id, version: requirement.version });
104
229
  if (installed.exitCode !== 0)
105
230
  throw new Error(`could not install ${requirement.id}@${requirement.version}: ${installed.output.trim()}`);
106
- const after = await options.exec(strategy.check);
231
+ const after = await options.exec({ kind: "check", software: requirement.id, version: requirement.version });
107
232
  if (after.exitCode !== 0)
108
233
  throw new Error(`${requirement.id}@${requirement.version} did not pass its post-install check`);
109
234
  results.push({ ...requirement, changed: true });
@@ -227,8 +352,8 @@ async function calibrateHttpConcurrency(options, fetcher = fetch) {
227
352
  }
228
353
 
229
354
  // src/definition.ts
230
- var PIPELINE_VERSION = 2;
231
- var DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v2.json";
355
+ var PIPELINE_VERSION = 3;
356
+ var DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v3.json";
232
357
 
233
358
  class DefinitionError extends Error {
234
359
  constructor(message) {
@@ -248,6 +373,29 @@ var text = (value, where) => {
248
373
  }
249
374
  return value;
250
375
  };
376
+ var argv = (value, where) => {
377
+ if (!Array.isArray(value) || value.length === 0 || value.length > 256) {
378
+ throw new DefinitionError(`${where} must contain from 1 to 256 arguments.`);
379
+ }
380
+ let bytes = 0;
381
+ const parsed = value.map((argument, index) => {
382
+ if (typeof argument !== "string" || argument.length === 0 || argument.length > 16384 || argument.includes("\x00")) {
383
+ throw new DefinitionError(`${where}[${index}] must be a non-empty bounded string without NUL.`);
384
+ }
385
+ if (argument.includes("${") && !/^\$\{FZ_[A-Z0-9_]+\}$/.test(argument)) {
386
+ throw new DefinitionError(`${where}[${index}] contains unsupported interpolation; only one exact FZ coordinate is allowed.`);
387
+ }
388
+ bytes += Buffer.byteLength(argument);
389
+ if (bytes > 64 * 1024)
390
+ throw new DefinitionError(`${where} is larger than 64 KiB.`);
391
+ return argument;
392
+ });
393
+ const executable = parsed[0].split("/").at(-1).toLowerCase();
394
+ if (new Set(["sh", "bash", "dash", "zsh", "ksh", "fish", "busybox", "env"]).has(executable)) {
395
+ throw new DefinitionError(`${where}[0] may not invoke a shell or command dispatcher.`);
396
+ }
397
+ return parsed;
398
+ };
251
399
  var exactKeys = (value, allowed, where) => {
252
400
  const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
253
401
  if (unknown.length > 0)
@@ -313,6 +461,76 @@ function capacityCalibration(value, where) {
313
461
  }
314
462
  return parsed;
315
463
  }
464
+ function validateDeploymentService(value, where = "service") {
465
+ const service = record(value, where);
466
+ exactKeys(service, [
467
+ "strategy",
468
+ "publicPort",
469
+ "applicationPorts",
470
+ "command",
471
+ "healthPath",
472
+ "maxConnections",
473
+ "websocket",
474
+ "drainMs"
475
+ ], where);
476
+ if (service.strategy !== "direct" && service.strategy !== "blue-green") {
477
+ throw new DefinitionError(`${where}.strategy must be direct or blue-green.`);
478
+ }
479
+ const publicPort = Number(service.publicPort);
480
+ if (!Number.isSafeInteger(publicPort) || publicPort < 1024 || publicPort > 65535) {
481
+ throw new DefinitionError(`${where}.publicPort must be an unprivileged TCP port.`);
482
+ }
483
+ const allocation = record(service.applicationPorts, `${where}.applicationPorts`);
484
+ let applicationPorts;
485
+ const required = service.strategy === "blue-green" ? 2 : 1;
486
+ if (allocation.mode === "fixed") {
487
+ exactKeys(allocation, ["mode", "ports"], `${where}.applicationPorts`);
488
+ if (!Array.isArray(allocation.ports) || allocation.ports.length !== required || allocation.ports.some((port) => !Number.isSafeInteger(port) || Number(port) < 1024 || Number(port) > 65535) || new Set(allocation.ports).size !== allocation.ports.length || allocation.ports.includes(publicPort)) {
489
+ throw new DefinitionError(`${where}.applicationPorts needs ${required} distinct unprivileged port(s), disjoint from publicPort.`);
490
+ }
491
+ applicationPorts = { mode: "fixed", ports: allocation.ports };
492
+ } else if (allocation.mode === "dynamic") {
493
+ exactKeys(allocation, ["mode", "from", "to"], `${where}.applicationPorts`);
494
+ const from = Number(allocation.from);
495
+ const to = Number(allocation.to);
496
+ if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from < 1024 || to > 65535 || to - from + 1 < required || publicPort >= from && publicPort <= to) {
497
+ throw new DefinitionError(`${where}.applicationPorts dynamic range is invalid or includes publicPort.`);
498
+ }
499
+ applicationPorts = { mode: "dynamic", from, to };
500
+ } else
501
+ throw new DefinitionError(`${where}.applicationPorts.mode must be fixed or dynamic.`);
502
+ const command = argv(service.command, `${where}.command`);
503
+ for (const argument of command) {
504
+ const coordinate = argument.match(/^\$\{(FZ_[A-Z0-9_]+)\}$/)?.[1];
505
+ if (coordinate && !["FZ_APP_PORT", "FZ_RELEASE"].includes(coordinate)) {
506
+ throw new DefinitionError(`${where}.command uses unsupported coordinate ${coordinate}.`);
507
+ }
508
+ }
509
+ if (typeof service.healthPath !== "string" || !/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{0,255}$/.test(service.healthPath) || service.healthPath.includes("..") || service.healthPath.includes("//")) {
510
+ throw new DefinitionError(`${where}.healthPath must be one bounded absolute path.`);
511
+ }
512
+ if (service.websocket !== undefined && typeof service.websocket !== "boolean") {
513
+ throw new DefinitionError(`${where}.websocket must be a boolean.`);
514
+ }
515
+ const maxConnections = service.maxConnections === undefined ? undefined : Number(service.maxConnections);
516
+ if (maxConnections !== undefined && (!Number.isSafeInteger(maxConnections) || maxConnections < 1 || maxConnections > 1e6)) {
517
+ throw new DefinitionError(`${where}.maxConnections must be from 1 to 1000000.`);
518
+ }
519
+ const drainMs = service.drainMs === undefined ? 30000 : Number(service.drainMs);
520
+ if (!Number.isSafeInteger(drainMs) || drainMs < 0 || drainMs > 300000) {
521
+ throw new DefinitionError(`${where}.drainMs must be from 0 to 300000.`);
522
+ }
523
+ return {
524
+ strategy: service.strategy,
525
+ publicPort,
526
+ applicationPorts,
527
+ command,
528
+ healthPath: service.healthPath,
529
+ ...maxConnections === undefined ? {} : { maxConnections },
530
+ websocket: service.websocket === true,
531
+ drainMs
532
+ };
533
+ }
316
534
  function parseDeployDefinition(value, options = {}) {
317
535
  const root = record(value, "pipeline");
318
536
  exactKeys(root, ["$schema", "version", "name", "requireAttestation", "profiles", "steps"], "pipeline");
@@ -330,20 +548,28 @@ function parseDeployDefinition(value, options = {}) {
330
548
  if (profileEntries.length === 0 || profileEntries.length > 32) {
331
549
  throw new DefinitionError("pipeline.profiles must contain from 1 to 32 named profiles.");
332
550
  }
333
- if (!Array.isArray(root.steps) || root.steps.length === 0) {
334
- throw new DefinitionError("pipeline.steps must contain at least one step.");
551
+ if (!Array.isArray(root.steps) || root.steps.length === 0 || root.steps.length > 256) {
552
+ throw new DefinitionError("pipeline.steps must contain from 1 to 256 steps.");
335
553
  }
336
554
  const profiles = {};
337
555
  for (const [name2, raw] of profileEntries) {
338
556
  if (!NAME.test(name2))
339
557
  throw new DefinitionError(`pipeline profile name is invalid: ${name2}.`);
340
558
  const profile = record(raw, `profiles.${name2}`);
341
- exactKeys(profile, ["software", "capacityCalibration"], `profiles.${name2}`);
559
+ exactKeys(profile, ["software", "service", "capacityCalibration"], `profiles.${name2}`);
342
560
  if (!Array.isArray(profile.software)) {
343
561
  throw new DefinitionError(`profiles.${name2}.software must be an array.`);
344
562
  }
563
+ const software = validateSoftwareRequirements(profile.software, options);
564
+ const service = profile.service === undefined ? undefined : validateDeploymentService(profile.service, `profiles.${name2}.service`);
565
+ if (service && !software.some(({ id }) => id === "nginx")) {
566
+ throw new DefinitionError(`profiles.${name2}.service requires the reviewed nginx software strategy.`);
567
+ }
345
568
  profiles[name2] = {
346
- software: validateSoftwareRequirements(profile.software, options),
569
+ software,
570
+ ...profile.service === undefined ? {} : {
571
+ service
572
+ },
347
573
  ...profile.capacityCalibration === undefined ? {} : {
348
574
  capacityCalibration: capacityCalibration(profile.capacityCalibration, `profiles.${name2}.capacityCalibration`)
349
575
  }
@@ -352,7 +578,7 @@ function parseDeployDefinition(value, options = {}) {
352
578
  const phases = new Set(["build", "release", "migrate", "health"]);
353
579
  const steps = root.steps.map((raw, index) => {
354
580
  const step = record(raw, `steps[${index}]`);
355
- exactKeys(step, ["name", "run", "phase", "scope", "profiles", "secrets", "always", "timeoutMs", "when"], `steps[${index}]`);
581
+ exactKeys(step, ["name", "exec", "phase", "scope", "profiles", "secrets", "always", "timeoutMs", "when"], `steps[${index}]`);
356
582
  const phase = text(step.phase, `steps[${index}].phase`);
357
583
  if (!phases.has(phase))
358
584
  throw new DefinitionError(`steps[${index}].phase is not supported.`);
@@ -400,7 +626,7 @@ function parseDeployDefinition(value, options = {}) {
400
626
  }
401
627
  return {
402
628
  name: text(step.name, `steps[${index}].name`),
403
- run: text(step.run, `steps[${index}].run`),
629
+ exec: argv(step.exec, `steps[${index}].exec`),
404
630
  phase,
405
631
  scope: step.scope,
406
632
  profiles: selectedProfiles,
@@ -441,9 +667,9 @@ function phasePipeline(definition, phase, profile, executeRelease = false) {
441
667
  }
442
668
 
443
669
  // src/deploy-file.ts
444
- import { createHash } from "node:crypto";
445
- import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
446
- import { basename, join } from "node:path";
670
+ import { createHash as createHash2 } from "node:crypto";
671
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
672
+ import { basename, join as join2 } from "node:path";
447
673
  var DEPLOY_FILE = ".fz/deploy.json";
448
674
  var DEPLOY_TODO_PREFIX = "ForgeZero pipeline TODO:";
449
675
  var stable = (value) => {
@@ -455,28 +681,28 @@ var stable = (value) => {
455
681
  return JSON.stringify(value);
456
682
  };
457
683
  function deployDefinitionDigest(definition) {
458
- return `sha256:${createHash("sha256").update(stable(definition)).digest("hex")}`;
684
+ return `sha256:${createHash2("sha256").update(stable(definition)).digest("hex")}`;
459
685
  }
460
686
  var safeName = (value) => {
461
687
  const normalized = value.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
462
688
  return /^[a-z]/.test(normalized) ? normalized : `app-${normalized || "service"}`.slice(0, 63);
463
689
  };
464
690
  function packageHints(root) {
465
- const packagePath = join(root, "package.json");
691
+ const packagePath = join2(root, "package.json");
466
692
  if (!existsSync(packagePath))
467
- return { bun: existsSync(join(root, "bun.lock")) };
693
+ return { bun: existsSync(join2(root, "bun.lock")) };
468
694
  try {
469
695
  const manifest = JSON.parse(readFileSync2(packagePath, "utf8"));
470
696
  return {
471
697
  name: typeof manifest.name === "string" ? manifest.name : undefined,
472
- build: typeof manifest.scripts?.build === "string" ? "bun run build" : undefined,
473
- bun: existsSync(join(root, "bun.lock")) || existsSync(join(root, "bun.lockb"))
698
+ build: typeof manifest.scripts?.build === "string" ? ["bun", "run", "build"] : undefined,
699
+ bun: existsSync(join2(root, "bun.lock")) || existsSync(join2(root, "bun.lockb"))
474
700
  };
475
701
  } catch {
476
- return { bun: existsSync(join(root, "bun.lock")) };
702
+ return { bun: existsSync(join2(root, "bun.lock")) };
477
703
  }
478
704
  }
479
- var blocker = (instruction) => `printf '%s\\n' '${DEPLOY_TODO_PREFIX} ${instruction}' >&2; exit 78`;
705
+ var blocker = (instruction) => ["fz-agent", "pipeline-todo", `${DEPLOY_TODO_PREFIX} ${instruction}`];
480
706
  function defaultDeployFile(root, options = {}) {
481
707
  const hints = packageHints(root);
482
708
  const software = options.software ?? (hints.bun ? SOFTWARE_CATALOG.filter((entry) => entry.id === "bun" && entry.status === "active").map(({ id, version }) => ({ id, version })) : []);
@@ -485,42 +711,42 @@ function defaultDeployFile(root, options = {}) {
485
711
  const build = hints.build ?? blocker("replace the build step with the project build command");
486
712
  return {
487
713
  $schema: DEPLOY_SCHEMA_URL,
488
- version: 2,
714
+ version: 3,
489
715
  name: safeName(options.name ?? hints.name ?? basename(root)),
490
716
  ...options.requireAttestation ? { requireAttestation: true } : {},
491
717
  profiles: { [profile]: { software } },
492
718
  steps: [
493
- { name: "build", phase: "build", scope: "target", run: build, timeoutMs: 600000 },
719
+ { name: "build", phase: "build", scope: "target", exec: build, timeoutMs: 600000 },
494
720
  {
495
721
  name: "promote release",
496
722
  phase: "release",
497
723
  scope: "target",
498
- run: blocker("replace the release step with an atomic promotion command"),
724
+ exec: blocker("replace the release step with an atomic promotion command"),
499
725
  timeoutMs: 120000
500
726
  },
501
727
  {
502
728
  name: "health check",
503
729
  phase: "health",
504
730
  scope: "target",
505
- run: blocker("replace the health step with a bounded local health check"),
731
+ exec: blocker("replace the health step with a bounded local health check"),
506
732
  timeoutMs: 30000
507
733
  }
508
734
  ]
509
735
  };
510
736
  }
511
737
  function inspectDeployFile(root, options = {}) {
512
- const path = join(root, DEPLOY_FILE);
513
- if (!existsSync(path))
738
+ const path2 = join2(root, DEPLOY_FILE);
739
+ if (!existsSync(path2))
514
740
  throw new Error(`${DEPLOY_FILE} does not exist; run \`fz deploy init\`.`);
515
- const raw = JSON.parse(readFileSync2(path, "utf8"));
741
+ const raw = JSON.parse(readFileSync2(path2, "utf8"));
516
742
  const definition = parseDeployDefinition(raw, options);
517
- const problems = definition.steps.filter((step) => step.run.includes(DEPLOY_TODO_PREFIX)).map((step) => `${step.name} still contains the safe initialization blocker`);
743
+ const problems = definition.steps.filter((step) => step.exec.some((argument) => argument.includes(DEPLOY_TODO_PREFIX))).map((step) => `${step.name} still contains the safe initialization blocker`);
518
744
  const profiles = Object.keys(definition.profiles).sort();
519
745
  return {
520
- path,
746
+ path: path2,
521
747
  definition,
522
748
  summary: {
523
- path,
749
+ path: path2,
524
750
  digest: deployDefinitionDigest(definition),
525
751
  version: definition.version,
526
752
  name: definition.name,
@@ -535,14 +761,14 @@ function inspectDeployFile(root, options = {}) {
535
761
  };
536
762
  }
537
763
  function initializeDeployFile(root, options = {}) {
538
- const path = join(root, DEPLOY_FILE);
539
- if (existsSync(path) && !options.force) {
764
+ const path2 = join2(root, DEPLOY_FILE);
765
+ if (existsSync(path2) && !options.force) {
540
766
  throw new Error(`${DEPLOY_FILE} already exists; use --force only when replacing it deliberately.`);
541
767
  }
542
768
  const raw = defaultDeployFile(root, options);
543
769
  parseDeployDefinition(raw, { channel: options.channel });
544
- mkdirSync(join(root, ".fz"), { recursive: true });
545
- writeFileSync(path, `${JSON.stringify(raw, null, 2)}
770
+ mkdirSync2(join2(root, ".fz"), { recursive: true });
771
+ writeFileSync2(path2, `${JSON.stringify(raw, null, 2)}
546
772
  `, { mode: 420 });
547
773
  return inspectDeployFile(root, { channel: options.channel });
548
774
  }
@@ -12,8 +12,21 @@ function within(root, candidate) {
12
12
  return candidate === root || candidate.startsWith(`${root}${sep}`);
13
13
  }
14
14
  function validate(root, input) {
15
- if (!input || typeof input.command !== "string" || input.command.length < 1 || input.command.length > 64 * 1024) {
16
- throw new Error("invalid deployment command");
15
+ if (!input || !Array.isArray(input.argv) || input.argv.length < 1 || input.argv.length > 256) {
16
+ throw new Error("invalid deployment argv");
17
+ }
18
+ let argvBytes = 0;
19
+ const argv = input.argv.map((argument) => {
20
+ if (typeof argument !== "string" || argument.length < 1 || argument.length > 16384 || argument.includes("\x00")) {
21
+ throw new Error("invalid deployment argument");
22
+ }
23
+ argvBytes += Buffer.byteLength(argument);
24
+ if (argvBytes > 64 * 1024)
25
+ throw new Error("deployment argv is too large");
26
+ return argument;
27
+ });
28
+ if (argv[0].includes("/../") || argv[0].endsWith("/..")) {
29
+ throw new Error("deployment executable path is invalid");
17
30
  }
18
31
  if (!input.cwd || !isAbsolute(input.cwd))
19
32
  throw new Error("deployment command needs an absolute working directory");
@@ -35,7 +48,7 @@ function validate(root, input) {
35
48
  throw new Error("deployment environment is too large");
36
49
  env[name] = value;
37
50
  }
38
- return { command: input.command, cwd: realCwd, env, timeoutMs };
51
+ return { argv, cwd: realCwd, env, timeoutMs };
39
52
  }
40
53
  async function limited(stream) {
41
54
  const reader = stream.getReader();
@@ -57,7 +70,7 @@ async function limited(stream) {
57
70
  [output truncated at ${MAX_OUTPUT_BYTES} bytes]` : output;
58
71
  }
59
72
  async function execute(input, home) {
60
- const child = Bun.spawn(["bash", "-Eeuo", "pipefail", "-c", input.command], {
73
+ const child = Bun.spawn([...input.argv], {
61
74
  cwd: input.cwd,
62
75
  detached: true,
63
76
  stdout: "pipe",
@@ -96,7 +109,7 @@ async function execute(input, home) {
96
109
  return {
97
110
  exitCode: timedOut ? 124 : exitCode,
98
111
  output: `${stdout}${stderr}${timedOut ? `
99
- command exceeded ${input.timeoutMs}ms` : ""}`
112
+ process exceeded ${input.timeoutMs}ms` : ""}`
100
113
  };
101
114
  }
102
115
  function startDeploymentRunner(options) {
@@ -1,12 +1,14 @@
1
1
  import { type DrainReport, type QueueTask } from '@forgezero/runtime/queue';
2
2
  import { type GitHostResolver } from './git-egress';
3
3
  import type { SoftwareRequirement } from './software';
4
+ import type { DeploymentService } from './definition';
5
+ import type { ServiceActivationState } from './service-supervisor';
4
6
  import { type RunResult } from './pipeline';
5
7
  import type { SecretCache } from './cache';
6
8
  import type { AttestationSource } from './socket';
7
9
  import { type CapacityCalibration, type CapacityCalibrationOptions } from './capacity-calibration';
8
10
  export interface CommandInput {
9
- command: string;
11
+ argv: readonly string[];
10
12
  cwd?: string;
11
13
  env?: Record<string, string>;
12
14
  timeoutMs?: number;
@@ -63,6 +65,8 @@ export interface DeploymentOptions {
63
65
  publicApiUrl?: string;
64
66
  sourceAuth?: GitSourceAuth;
65
67
  gitCredentialPath?: string;
68
+ /** Fixed typed SSH adapter installed beside fz-agent; Git invokes it directly. */
69
+ gitSshPath?: string;
66
70
  knownHostsPath?: string;
67
71
  /** Server-owned, operator-pinned host keys for a dynamically assigned source. */
68
72
  knownHostsContent?: string;
@@ -77,6 +81,14 @@ export interface DeploymentOptions {
77
81
  projectExec?: (input: CommandInput) => Promise<CommandResult>;
78
82
  /** Root-owned fixed strategy helper; repository data contains coordinates only. */
79
83
  ensureSoftware?: (requirements: readonly SoftwareRequirement[]) => Promise<unknown>;
84
+ /** Fixed root helper that owns systemd/nginx promotion; repository code never receives it. */
85
+ activateService?: (request: {
86
+ key: string;
87
+ revision: string;
88
+ release: string;
89
+ root: string;
90
+ service: DeploymentService;
91
+ }) => Promise<ServiceActivationState>;
80
92
  /** Agent-owned directory; definitions cannot choose or overwrite this path. */
81
93
  capacityEvidenceDirectory?: string;
82
94
  /** Test/embedding seam. Production runs the bounded loopback GET calibrator. */