@forgezero/agent 0.1.27 → 0.1.29

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/README.md CHANGED
@@ -180,8 +180,9 @@ compute's systemd-sealed SSH deploy key, or a fine-grained HTTPS token selected
180
180
  by a project-vault secret name. A signed claim contains the mode and secret name
181
181
  only. The agent resolves the value from its scoped memory cache, limits the Git
182
182
  header to the repository origin, never writes the token into a command, and
183
- never exposes it to tenant pipeline steps. Automatic GitHub App token minting
184
- can feed the same short-lived token mode later; it is not implemented today.
183
+ never exposes it to tenant pipeline steps. These provider-neutral modes are the
184
+ complete contract; a GitHub App is not a missing dependency and would be added
185
+ later only as an explicit provider-specific mode with expiry and revocation tests.
185
186
 
186
187
  An operator may also force a deployment and await the complete result over the
187
188
  private control socket:
@@ -376,8 +376,21 @@ class SignedNodeHttpError extends Error {
376
376
  this.name = "SignedNodeHttpError";
377
377
  }
378
378
  }
379
+ function signedNodeApiUrl(value) {
380
+ let url;
381
+ try {
382
+ url = new URL(value);
383
+ } catch {
384
+ throw new Error("Agent API URL must be an absolute HTTPS URL.");
385
+ }
386
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
387
+ if (url.protocol !== "https:" && !(loopback && url.protocol === "http:") || url.username || url.password || url.search || url.hash) {
388
+ throw new Error("Agent API URL must be HTTPS without credentials, query or fragment.");
389
+ }
390
+ return url;
391
+ }
379
392
  async function postSignedNode(options, path, body) {
380
- const url = new URL(options.apiUrl);
393
+ const url = signedNodeApiUrl(options.apiUrl);
381
394
  url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
382
395
  url.search = "";
383
396
  url.hash = "";
@@ -416,7 +429,7 @@ async function postSignedNode(options, path, body) {
416
429
  }
417
430
 
418
431
  // src/version.ts
419
- var VERSION2 = "0.1.27";
432
+ var VERSION2 = "0.1.29";
420
433
 
421
434
  // src/agent-heartbeat.ts
422
435
  var unquote = (value) => value.replace(/^['"]|['"]$/g, "");
package/dist/fz-agent.js CHANGED
@@ -4267,8 +4267,21 @@ class SignedNodeHttpError extends Error {
4267
4267
  this.name = "SignedNodeHttpError";
4268
4268
  }
4269
4269
  }
4270
+ function signedNodeApiUrl(value) {
4271
+ let url;
4272
+ try {
4273
+ url = new URL(value);
4274
+ } catch {
4275
+ throw new Error("Agent API URL must be an absolute HTTPS URL.");
4276
+ }
4277
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
4278
+ if (url.protocol !== "https:" && !(loopback && url.protocol === "http:") || url.username || url.password || url.search || url.hash) {
4279
+ throw new Error("Agent API URL must be HTTPS without credentials, query or fragment.");
4280
+ }
4281
+ return url;
4282
+ }
4270
4283
  async function postSignedNode(options, path, body) {
4271
- const url = new URL(options.apiUrl);
4284
+ const url = signedNodeApiUrl(options.apiUrl);
4272
4285
  url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
4273
4286
  url.search = "";
4274
4287
  url.hash = "";
@@ -4328,7 +4341,7 @@ function projectVaultCoordinate(key) {
4328
4341
  function tenantNodeApiUrl(apiUrl, tenantSlug) {
4329
4342
  if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(tenantSlug))
4330
4343
  throw new Error("tenant slug is malformed");
4331
- const url = new URL(apiUrl);
4344
+ const url = signedNodeApiUrl(apiUrl);
4332
4345
  url.pathname = `/api/t/${encodeURIComponent(tenantSlug)}`;
4333
4346
  url.search = "";
4334
4347
  url.hash = "";
@@ -6364,11 +6377,10 @@ ${attestationSetup}if [[ ! -x /usr/local/bin/bun ]]; then
6364
6377
  install -m 0755 ${agentBun}/bin/bun /usr/local/bin/bun
6365
6378
  rm -f /run/fz-bun-install
6366
6379
  fi
6367
- if [[ ! -x /usr/local/bin/fz-agent ]] || [[ "$(/usr/local/bin/fz-agent --version 2>/dev/null || true)" != "${profile.agentVersion}" ]]; then
6380
+ if [[ ! -x /usr/local/lib/forgezero/agent/fz-agent ]] || [[ "$(/usr/local/lib/forgezero/agent/fz-agent --version 2>/dev/null || true)" != "${profile.agentVersion}" ]]; then
6368
6381
  env BUN_INSTALL=${agentBun} /usr/local/bin/bun add -g --no-cache --force @forgezero/agent@${profile.agentVersion}
6369
- ln -sfn ${agentBun}/bin/fz-agent /usr/local/bin/fz-agent
6370
6382
  fi
6371
- env FZ_API=${profile.apiUrl} FZ_AGENT_BIN=/usr/local/bin/fz-agent FZ_AGENT_USER=forgezero-agent FZ_SOCKET_PATH=/run/forgezero/vault.sock FZ_SEED_CREDENTIAL_PATH=/etc/forgezero/creds/agent-seed.cred FZ_GIT_CREDENTIAL_PATH=/etc/forgezero/creds/git-deploy-key.cred FZ_GIT_PUBLIC_KEY_PATH=/etc/forgezero/git/deploy.pub FZ_DEPLOY_ROOT=/opt/forgezero FZ_DEPLOY_PULL=${hasEnrolment ? "true" : "false"} FZ_NODE_LABEL=${nodeLabel} ${agentBun}/bin/fz agent install --apply${hasEnrolment ? " --enrol" : ""}
6383
+ env FZ_API=${profile.apiUrl} FZ_AGENT_BIN=/usr/local/lib/forgezero/agent/fz-agent FZ_AGENT_USER=forgezero-agent FZ_SOCKET_PATH=/run/forgezero/vault.sock FZ_SEED_CREDENTIAL_PATH=/etc/forgezero/creds/agent-seed.cred FZ_GIT_CREDENTIAL_PATH=/etc/forgezero/creds/git-deploy-key.cred FZ_GIT_PUBLIC_KEY_PATH=/etc/forgezero/git/deploy.pub FZ_DEPLOY_ROOT=/opt/forgezero FZ_DEPLOY_PULL=${hasEnrolment ? "true" : "false"} FZ_NODE_LABEL=${nodeLabel} ${agentBun}/bin/fz agent install --apply${hasEnrolment ? " --enrol" : ""}
6372
6384
  `;
6373
6385
  }
6374
6386
  function cloudInit(profile, claim, manifest) {
@@ -6564,16 +6576,36 @@ async function removeMetalGuest(profile, claim, exec) {
6564
6576
  throw new MetalProvisionError("create claim cannot remove a guest");
6565
6577
  const name = claim.spec.guestName ?? guestNameFor(claim.computeKey);
6566
6578
  const manifestPath = join2(profile.stateDir, `${name}.json`);
6579
+ const service = `forgezero-guest@${name}.service`;
6580
+ const unitPath = join2(profile.unitDir, service);
6581
+ const lv = `/dev/${profile.volumeGroup}/${name}`;
6567
6582
  const manifest = existsSync5(manifestPath) ? JSON.parse(readFileSync4(manifestPath, "utf8")) : legacyPlatformManifest(profile, claim, name);
6568
- if (!manifest)
6583
+ if (!manifest) {
6584
+ const seedBase = join2(profile.seedDir, name);
6585
+ const localArtifacts = [
6586
+ unitPath,
6587
+ `${seedBase}-seed.iso`,
6588
+ `${seedBase}-user-data`,
6589
+ `${seedBase}-meta-data`,
6590
+ `${seedBase}-network-config`
6591
+ ];
6592
+ if (localArtifacts.some(existsSync5)) {
6593
+ throw new MetalProvisionError("guest artifacts exist without owned inventory");
6594
+ }
6595
+ const [volume, active] = await Promise.all([
6596
+ exec(["lvs", "--noheadings", lv]),
6597
+ exec(["systemctl", "is-active", service])
6598
+ ]);
6599
+ if (volume.exitCode === 0 || active.exitCode === 0) {
6600
+ throw new MetalProvisionError("guest runtime exists without owned inventory");
6601
+ }
6569
6602
  return {};
6603
+ }
6570
6604
  const currentIdentity = manifest.computeKey === claim.computeKey && manifest.reference === claim.spec.reference && manifest.name === name;
6571
6605
  const legacyPlatformIdentity = claim.bootstrap?.kind === "platform-genesis" && claim.computeKey === `platform:${name}` && claim.spec.reference === `platform:${name}` && manifest.name === name && manifest.disk === `/dev/${profile.volumeGroup}/${name}` && manifest.address === claim.spec.guestAddress && (manifest.unit === `fz-guest@${name}.service` || manifest.unit === `forgezero-guest@${name}.service`);
6572
6606
  if (!currentIdentity && !legacyPlatformIdentity) {
6573
6607
  throw new MetalProvisionError("compute identity conflicts with host inventory");
6574
6608
  }
6575
- const service = `forgezero-guest@${name}.service`;
6576
- const unitPath = join2(profile.unitDir, service);
6577
6609
  if (existsSync5(unitPath))
6578
6610
  await checked(exec, ["systemctl", "disable", "--now", service]);
6579
6611
  else if (legacyPlatformIdentity)
@@ -6581,7 +6613,6 @@ async function removeMetalGuest(profile, claim, exec) {
6581
6613
  else if ((await exec(["systemctl", "is-active", service])).exitCode === 0) {
6582
6614
  throw new MetalProvisionError("guest unit is active but its owned unit file is missing");
6583
6615
  }
6584
- const lv = `/dev/${profile.volumeGroup}/${name}`;
6585
6616
  if ((await exec(["lvs", "--noheadings", lv])).exitCode === 0)
6586
6617
  await checked(exec, ["lvremove", "-fy", lv]);
6587
6618
  for (const path of [
@@ -7844,7 +7875,7 @@ function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, t
7844
7875
  import { readFileSync as readFileSync7 } from "fs";
7845
7876
 
7846
7877
  // src/version.ts
7847
- var VERSION2 = "0.1.27";
7878
+ var VERSION2 = "0.1.29";
7848
7879
 
7849
7880
  // src/agent-heartbeat.ts
7850
7881
  var unquote = (value) => value.replace(/^['"]|['"]$/g, "");
@@ -8455,7 +8486,6 @@ if (import.meta.main) {
8455
8486
  const attestationSource = existsSync13("/dev/sev-guest") ? createSnpAttestationSource() : undefined;
8456
8487
  const running = runAgent({
8457
8488
  socketPath: process.env.FZ_SOCKET_PATH ?? DEFAULT_SOCKET_PATH,
8458
- listenFd: systemdListenFd(),
8459
8489
  seedCredential: process.env.FZ_SEED_CREDENTIAL,
8460
8490
  seedPath: process.env.FZ_SEED_PATH ?? DEFAULT_SEED_PATH,
8461
8491
  nodeKey: process.env.FZ_NODE_KEY,
package/dist/fz.js CHANGED
@@ -4886,7 +4886,7 @@ var AGENT_UPDATE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-agent-update-
4886
4886
  var MAX_REQUEST_BYTES = 8 * 1024;
4887
4887
 
4888
4888
  // src/version.ts
4889
- var VERSION2 = "0.1.27";
4889
+ var VERSION2 = "0.1.29";
4890
4890
 
4891
4891
  // src/software.ts
4892
4892
  var BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
@@ -4973,6 +4973,7 @@ var VAULT_GROUP = "forgezero-vault";
4973
4973
  var LIFECYCLE_GROUP = "forgezero-lifecycle";
4974
4974
  var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
4975
4975
  var AGENT_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-agent.socket";
4976
+ var AGENT_SOCKET_PROXY_UNIT_PATH = "/etc/systemd/system/forgezero-agent-proxy.service";
4976
4977
  var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
4977
4978
  var ENROLMENT_UNIT_PATH = "/etc/systemd/system/forgezero-agent-enrol.service";
4978
4979
  var LIFECYCLE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-lifecycle-helper.service";
@@ -5062,17 +5063,55 @@ SocketGroup=${VAULT_GROUP}
5062
5063
  SocketMode=0660
5063
5064
  DirectoryMode=0750
5064
5065
  RemoveOnStop=true
5065
- Service=forgezero-agent.service
5066
+ Service=forgezero-agent-proxy.service
5066
5067
 
5067
5068
  [Install]
5068
5069
  WantedBy=sockets.target
5069
5070
  `;
5070
5071
  }
5072
+ function agentSocketProxyUnit(options) {
5073
+ const backend = agentBackendSocketPath(options.socketPath);
5074
+ const user = options.user ?? "forgezero";
5075
+ return `[Unit]
5076
+ Description=ForgeZero application Vault socket proxy
5077
+ Documentation=https://www.forgezero.net/docs/agent
5078
+ Requires=forgezero-agent.service
5079
+ After=forgezero-agent.service
5080
+
5081
+ [Service]
5082
+ User=${user}
5083
+ Group=${VAULT_GROUP}
5084
+ ExecStart=/usr/lib/systemd/systemd-socket-proxyd ${backend}
5085
+ NoNewPrivileges=true
5086
+ PrivateTmp=true
5087
+ ProtectSystem=strict
5088
+ ProtectHome=true
5089
+ ProtectKernelTunables=true
5090
+ ProtectKernelModules=true
5091
+ ProtectControlGroups=true
5092
+ RestrictSUIDSGID=true
5093
+ RestrictRealtime=true
5094
+ MemoryDenyWriteExecute=true
5095
+ LockPersonality=true
5096
+ RestrictAddressFamilies=AF_UNIX
5097
+ `;
5098
+ }
5099
+ function agentBackendSocketPath(publicSocketPath) {
5100
+ const socket = systemdPath(publicSocketPath, "agent socket");
5101
+ const backend = `${socket}.backend`;
5102
+ if (Buffer.byteLength(backend) > 100)
5103
+ throw new Error("agent socket path is too long for a Unix socket");
5104
+ return backend;
5105
+ }
5071
5106
  var systemdPath = (value, label) => {
5072
5107
  if (!value || !/^\/[A-Za-z0-9._@/-]+$/.test(value))
5073
5108
  throw new Error(`invalid ${label} path`);
5074
5109
  return value;
5075
5110
  };
5111
+ var awaitSocketCommand = (path) => {
5112
+ const socket = systemdPath(path, "readiness socket");
5113
+ return `for attempt in $(seq 1 100); do test -S ${socket} && exit 0; sleep 0.1; done; exit 1`;
5114
+ };
5076
5115
  var validNodeHostname = (value) => !value || value.length <= 253 && value === value.toLowerCase() && value.split(".").length >= 3 && value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
5077
5116
  function warpConfigUnit(options) {
5078
5117
  if (!options.warpOrganization || !/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.warpOrganization)) {
@@ -5311,7 +5350,7 @@ function agentUnit(options) {
5311
5350
  }
5312
5351
  }
5313
5352
  const environment = [
5314
- `FZ_SOCKET_PATH=${options.socketPath}`,
5353
+ `FZ_SOCKET_PATH=${agentBackendSocketPath(options.socketPath)}`,
5315
5354
  `FZ_CONTROL_SOCKET=${controlSocketPath}`,
5316
5355
  `FZ_SEED_CREDENTIAL=agent-seed`,
5317
5356
  `FZ_AGENT_MODE=${options.mode}`,
@@ -5391,7 +5430,6 @@ Type=simple
5391
5430
  User=${user}
5392
5431
  Group=${VAULT_GROUP}
5393
5432
  ${deploymentGroup}
5394
- Sockets=forgezero-agent.socket
5395
5433
  LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
5396
5434
  ${gitCredential}${projectCredentials}${projectCredentials ? `
5397
5435
  ` : ""}${snpPrepare}ExecStart=${bin}
@@ -5479,6 +5517,7 @@ function planProvision(options) {
5479
5517
  unit: agentUnit({ ...options, mode, lifecycleProfilePath, lifecycleHelperSocketPath }),
5480
5518
  auxiliaryUnits: [
5481
5519
  { path: AGENT_SOCKET_UNIT_PATH, unit: agentSocketUnit(options) },
5520
+ { path: AGENT_SOCKET_PROXY_UNIT_PATH, unit: agentSocketProxyUnit(options) },
5482
5521
  { path: AGENT_UPDATE_HELPER_UNIT_PATH, unit: agentUpdateHelperUnit(options) },
5483
5522
  ...deploymentEnabled ? [
5484
5523
  { path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) },
@@ -5554,6 +5593,10 @@ function planProvision(options) {
5554
5593
  label: "credential directory",
5555
5594
  command: `install -d -o root -g root -m 0700 ${credentialDir}`
5556
5595
  },
5596
+ {
5597
+ label: "Agent state directory",
5598
+ command: "install -d -o root -g root -m 0750 /var/lib/forgezero"
5599
+ },
5557
5600
  {
5558
5601
  label: "encrypted node identity",
5559
5602
  command: `test -s ${seedCredentialPath} || { ` + `openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\\n' | ` + `systemd-creds encrypt --name=agent-seed - ${seedCredentialPath}; ` + `chmod 0400 ${seedCredentialPath}; }`
@@ -5588,8 +5631,8 @@ function planProvision(options) {
5588
5631
  command: "systemctl disable --now forgezero-deploy-runner.socket 2>/dev/null || true; rm -f /etc/systemd/system/forgezero-deploy-runner.socket; systemctl daemon-reload"
5589
5632
  }] : [],
5590
5633
  {
5591
- label: "enable and start",
5592
- command: `systemctl enable --now ${[
5634
+ label: "enable and converge services",
5635
+ command: `systemctl enable ${[
5593
5636
  "forgezero-agent.socket",
5594
5637
  "forgezero-agent-update-helper.service",
5595
5638
  ...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
@@ -5597,25 +5640,32 @@ function planProvision(options) {
5597
5640
  ...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
5598
5641
  ...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : [],
5599
5642
  "forgezero-agent.service"
5600
- ].join(" ")}`
5643
+ ].join(" ")}; systemctl reset-failed forgezero-agent.service || true; systemctl restart ${[
5644
+ "forgezero-agent-update-helper.service",
5645
+ ...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
5646
+ ...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
5647
+ ...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
5648
+ ...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : []
5649
+ ].join(" ")}; systemctl restart forgezero-agent.socket; systemctl reset-failed forgezero-agent.service || true; systemctl restart forgezero-agent.service`
5601
5650
  },
5602
5651
  ...enrolmentEnabled ? [{
5603
5652
  label: "prove the compute binding is durable",
5604
5653
  command: `test -s ${enrolStatePath}`
5605
5654
  }] : [],
5606
5655
  { label: "prove it is running", command: "systemctl is-active forgezero-agent.service" },
5607
- { label: "prove the vault socket exists", command: `test -S ${options.socketPath}` },
5608
- { label: "prove the Agent update helper exists", command: `test -S ${DEFAULT_AGENT_UPDATE_SOCKET}` },
5656
+ { label: "prove the public Vault socket exists", command: awaitSocketCommand(options.socketPath) },
5657
+ { label: "prove the Agent Vault backend exists", command: awaitSocketCommand(agentBackendSocketPath(options.socketPath)) },
5658
+ { label: "prove the Agent update helper exists", command: awaitSocketCommand(DEFAULT_AGENT_UPDATE_SOCKET) },
5609
5659
  ...deploymentEnabled ? [{
5610
5660
  label: "prove the deployment runner socket exists",
5611
- command: `test -S ${DEPLOYMENT_RUNNER_SOCKET}`
5661
+ command: awaitSocketCommand(DEPLOYMENT_RUNNER_SOCKET)
5612
5662
  }, {
5613
5663
  label: "prove the software strategy helper socket exists",
5614
- command: `test -S ${DEFAULT_SOFTWARE_HELPER_SOCKET}`
5664
+ command: awaitSocketCommand(DEFAULT_SOFTWARE_HELPER_SOCKET)
5615
5665
  }] : [],
5616
5666
  ...lifecycleEnabled ? [{
5617
5667
  label: "prove the lifecycle helper socket exists",
5618
- command: `test -S ${lifecycleHelperSocketPath}`
5668
+ command: awaitSocketCommand(lifecycleHelperSocketPath)
5619
5669
  }] : [],
5620
5670
  ...warpEnabled ? [{
5621
5671
  label: "prove Cloudflare WARP is connected",
@@ -5623,7 +5673,7 @@ function planProvision(options) {
5623
5673
  }] : [],
5624
5674
  ...options.repository ? [{
5625
5675
  label: "prove the deployment control socket exists",
5626
- command: `test -S ${options.controlSocketPath ?? "/run/forgezero/control.sock"}`
5676
+ command: awaitSocketCommand(options.controlSocketPath ?? "/run/forgezero/control.sock")
5627
5677
  }] : []
5628
5678
  ]
5629
5679
  };
@@ -28,8 +28,21 @@ class SignedNodeHttpError extends Error {
28
28
  this.name = "SignedNodeHttpError";
29
29
  }
30
30
  }
31
+ function signedNodeApiUrl(value) {
32
+ let url;
33
+ try {
34
+ url = new URL(value);
35
+ } catch {
36
+ throw new Error("Agent API URL must be an absolute HTTPS URL.");
37
+ }
38
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
39
+ if (url.protocol !== "https:" && !(loopback && url.protocol === "http:") || url.username || url.password || url.search || url.hash) {
40
+ throw new Error("Agent API URL must be HTTPS without credentials, query or fragment.");
41
+ }
42
+ return url;
43
+ }
31
44
  async function postSignedNode(options, path, body) {
32
- const url = new URL(options.apiUrl);
45
+ const url = signedNodeApiUrl(options.apiUrl);
33
46
  url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
34
47
  url.search = "";
35
48
  url.hash = "";
@@ -352,11 +352,10 @@ ${attestationSetup}if [[ ! -x /usr/local/bin/bun ]]; then
352
352
  install -m 0755 ${agentBun}/bin/bun /usr/local/bin/bun
353
353
  rm -f /run/fz-bun-install
354
354
  fi
355
- if [[ ! -x /usr/local/bin/fz-agent ]] || [[ "$(/usr/local/bin/fz-agent --version 2>/dev/null || true)" != "${profile.agentVersion}" ]]; then
355
+ if [[ ! -x /usr/local/lib/forgezero/agent/fz-agent ]] || [[ "$(/usr/local/lib/forgezero/agent/fz-agent --version 2>/dev/null || true)" != "${profile.agentVersion}" ]]; then
356
356
  env BUN_INSTALL=${agentBun} /usr/local/bin/bun add -g --no-cache --force @forgezero/agent@${profile.agentVersion}
357
- ln -sfn ${agentBun}/bin/fz-agent /usr/local/bin/fz-agent
358
357
  fi
359
- env FZ_API=${profile.apiUrl} FZ_AGENT_BIN=/usr/local/bin/fz-agent FZ_AGENT_USER=forgezero-agent FZ_SOCKET_PATH=/run/forgezero/vault.sock FZ_SEED_CREDENTIAL_PATH=/etc/forgezero/creds/agent-seed.cred FZ_GIT_CREDENTIAL_PATH=/etc/forgezero/creds/git-deploy-key.cred FZ_GIT_PUBLIC_KEY_PATH=/etc/forgezero/git/deploy.pub FZ_DEPLOY_ROOT=/opt/forgezero FZ_DEPLOY_PULL=${hasEnrolment ? "true" : "false"} FZ_NODE_LABEL=${nodeLabel} ${agentBun}/bin/fz agent install --apply${hasEnrolment ? " --enrol" : ""}
358
+ env FZ_API=${profile.apiUrl} FZ_AGENT_BIN=/usr/local/lib/forgezero/agent/fz-agent FZ_AGENT_USER=forgezero-agent FZ_SOCKET_PATH=/run/forgezero/vault.sock FZ_SEED_CREDENTIAL_PATH=/etc/forgezero/creds/agent-seed.cred FZ_GIT_CREDENTIAL_PATH=/etc/forgezero/creds/git-deploy-key.cred FZ_GIT_PUBLIC_KEY_PATH=/etc/forgezero/git/deploy.pub FZ_DEPLOY_ROOT=/opt/forgezero FZ_DEPLOY_PULL=${hasEnrolment ? "true" : "false"} FZ_NODE_LABEL=${nodeLabel} ${agentBun}/bin/fz agent install --apply${hasEnrolment ? " --enrol" : ""}
360
359
  `;
361
360
  }
362
361
  function cloudInit(profile, claim, manifest) {
@@ -552,16 +551,36 @@ async function removeMetalGuest(profile, claim, exec) {
552
551
  throw new MetalProvisionError("create claim cannot remove a guest");
553
552
  const name = claim.spec.guestName ?? guestNameFor(claim.computeKey);
554
553
  const manifestPath = join(profile.stateDir, `${name}.json`);
554
+ const service = `forgezero-guest@${name}.service`;
555
+ const unitPath = join(profile.unitDir, service);
556
+ const lv = `/dev/${profile.volumeGroup}/${name}`;
555
557
  const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : legacyPlatformManifest(profile, claim, name);
556
- if (!manifest)
558
+ if (!manifest) {
559
+ const seedBase = join(profile.seedDir, name);
560
+ const localArtifacts = [
561
+ unitPath,
562
+ `${seedBase}-seed.iso`,
563
+ `${seedBase}-user-data`,
564
+ `${seedBase}-meta-data`,
565
+ `${seedBase}-network-config`
566
+ ];
567
+ if (localArtifacts.some(existsSync)) {
568
+ throw new MetalProvisionError("guest artifacts exist without owned inventory");
569
+ }
570
+ const [volume, active] = await Promise.all([
571
+ exec(["lvs", "--noheadings", lv]),
572
+ exec(["systemctl", "is-active", service])
573
+ ]);
574
+ if (volume.exitCode === 0 || active.exitCode === 0) {
575
+ throw new MetalProvisionError("guest runtime exists without owned inventory");
576
+ }
557
577
  return {};
578
+ }
558
579
  const currentIdentity = manifest.computeKey === claim.computeKey && manifest.reference === claim.spec.reference && manifest.name === name;
559
580
  const legacyPlatformIdentity = claim.bootstrap?.kind === "platform-genesis" && claim.computeKey === `platform:${name}` && claim.spec.reference === `platform:${name}` && manifest.name === name && manifest.disk === `/dev/${profile.volumeGroup}/${name}` && manifest.address === claim.spec.guestAddress && (manifest.unit === `fz-guest@${name}.service` || manifest.unit === `forgezero-guest@${name}.service`);
560
581
  if (!currentIdentity && !legacyPlatformIdentity) {
561
582
  throw new MetalProvisionError("compute identity conflicts with host inventory");
562
583
  }
563
- const service = `forgezero-guest@${name}.service`;
564
- const unitPath = join(profile.unitDir, service);
565
584
  if (existsSync(unitPath))
566
585
  await checked(exec, ["systemctl", "disable", "--now", service]);
567
586
  else if (legacyPlatformIdentity)
@@ -569,7 +588,6 @@ async function removeMetalGuest(profile, claim, exec) {
569
588
  else if ((await exec(["systemctl", "is-active", service])).exitCode === 0) {
570
589
  throw new MetalProvisionError("guest unit is active but its owned unit file is missing");
571
590
  }
572
- const lv = `/dev/${profile.volumeGroup}/${name}`;
573
591
  if ((await exec(["lvs", "--noheadings", lv])).exitCode === 0)
574
592
  await checked(exec, ["lvremove", "-fy", lv]);
575
593
  for (const path of [
@@ -352,11 +352,10 @@ ${attestationSetup}if [[ ! -x /usr/local/bin/bun ]]; then
352
352
  install -m 0755 ${agentBun}/bin/bun /usr/local/bin/bun
353
353
  rm -f /run/fz-bun-install
354
354
  fi
355
- if [[ ! -x /usr/local/bin/fz-agent ]] || [[ "$(/usr/local/bin/fz-agent --version 2>/dev/null || true)" != "${profile.agentVersion}" ]]; then
355
+ if [[ ! -x /usr/local/lib/forgezero/agent/fz-agent ]] || [[ "$(/usr/local/lib/forgezero/agent/fz-agent --version 2>/dev/null || true)" != "${profile.agentVersion}" ]]; then
356
356
  env BUN_INSTALL=${agentBun} /usr/local/bin/bun add -g --no-cache --force @forgezero/agent@${profile.agentVersion}
357
- ln -sfn ${agentBun}/bin/fz-agent /usr/local/bin/fz-agent
358
357
  fi
359
- env FZ_API=${profile.apiUrl} FZ_AGENT_BIN=/usr/local/bin/fz-agent FZ_AGENT_USER=forgezero-agent FZ_SOCKET_PATH=/run/forgezero/vault.sock FZ_SEED_CREDENTIAL_PATH=/etc/forgezero/creds/agent-seed.cred FZ_GIT_CREDENTIAL_PATH=/etc/forgezero/creds/git-deploy-key.cred FZ_GIT_PUBLIC_KEY_PATH=/etc/forgezero/git/deploy.pub FZ_DEPLOY_ROOT=/opt/forgezero FZ_DEPLOY_PULL=${hasEnrolment ? "true" : "false"} FZ_NODE_LABEL=${nodeLabel} ${agentBun}/bin/fz agent install --apply${hasEnrolment ? " --enrol" : ""}
358
+ env FZ_API=${profile.apiUrl} FZ_AGENT_BIN=/usr/local/lib/forgezero/agent/fz-agent FZ_AGENT_USER=forgezero-agent FZ_SOCKET_PATH=/run/forgezero/vault.sock FZ_SEED_CREDENTIAL_PATH=/etc/forgezero/creds/agent-seed.cred FZ_GIT_CREDENTIAL_PATH=/etc/forgezero/creds/git-deploy-key.cred FZ_GIT_PUBLIC_KEY_PATH=/etc/forgezero/git/deploy.pub FZ_DEPLOY_ROOT=/opt/forgezero FZ_DEPLOY_PULL=${hasEnrolment ? "true" : "false"} FZ_NODE_LABEL=${nodeLabel} ${agentBun}/bin/fz agent install --apply${hasEnrolment ? " --enrol" : ""}
360
359
  `;
361
360
  }
362
361
  function cloudInit(profile, claim, manifest) {
@@ -552,16 +551,36 @@ async function removeMetalGuest(profile, claim, exec) {
552
551
  throw new MetalProvisionError("create claim cannot remove a guest");
553
552
  const name = claim.spec.guestName ?? guestNameFor(claim.computeKey);
554
553
  const manifestPath = join(profile.stateDir, `${name}.json`);
554
+ const service = `forgezero-guest@${name}.service`;
555
+ const unitPath = join(profile.unitDir, service);
556
+ const lv = `/dev/${profile.volumeGroup}/${name}`;
555
557
  const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : legacyPlatformManifest(profile, claim, name);
556
- if (!manifest)
558
+ if (!manifest) {
559
+ const seedBase = join(profile.seedDir, name);
560
+ const localArtifacts = [
561
+ unitPath,
562
+ `${seedBase}-seed.iso`,
563
+ `${seedBase}-user-data`,
564
+ `${seedBase}-meta-data`,
565
+ `${seedBase}-network-config`
566
+ ];
567
+ if (localArtifacts.some(existsSync)) {
568
+ throw new MetalProvisionError("guest artifacts exist without owned inventory");
569
+ }
570
+ const [volume, active] = await Promise.all([
571
+ exec(["lvs", "--noheadings", lv]),
572
+ exec(["systemctl", "is-active", service])
573
+ ]);
574
+ if (volume.exitCode === 0 || active.exitCode === 0) {
575
+ throw new MetalProvisionError("guest runtime exists without owned inventory");
576
+ }
557
577
  return {};
578
+ }
558
579
  const currentIdentity = manifest.computeKey === claim.computeKey && manifest.reference === claim.spec.reference && manifest.name === name;
559
580
  const legacyPlatformIdentity = claim.bootstrap?.kind === "platform-genesis" && claim.computeKey === `platform:${name}` && claim.spec.reference === `platform:${name}` && manifest.name === name && manifest.disk === `/dev/${profile.volumeGroup}/${name}` && manifest.address === claim.spec.guestAddress && (manifest.unit === `fz-guest@${name}.service` || manifest.unit === `forgezero-guest@${name}.service`);
560
581
  if (!currentIdentity && !legacyPlatformIdentity) {
561
582
  throw new MetalProvisionError("compute identity conflicts with host inventory");
562
583
  }
563
- const service = `forgezero-guest@${name}.service`;
564
- const unitPath = join(profile.unitDir, service);
565
584
  if (existsSync(unitPath))
566
585
  await checked(exec, ["systemctl", "disable", "--now", service]);
567
586
  else if (legacyPlatformIdentity)
@@ -569,7 +588,6 @@ async function removeMetalGuest(profile, claim, exec) {
569
588
  else if ((await exec(["systemctl", "is-active", service])).exitCode === 0) {
570
589
  throw new MetalProvisionError("guest unit is active but its owned unit file is missing");
571
590
  }
572
- const lv = `/dev/${profile.volumeGroup}/${name}`;
573
591
  if ((await exec(["lvs", "--noheadings", lv])).exitCode === 0)
574
592
  await checked(exec, ["lvremove", "-fy", lv]);
575
593
  for (const path of [
@@ -15,8 +15,21 @@ class SignedNodeHttpError extends Error {
15
15
  this.name = "SignedNodeHttpError";
16
16
  }
17
17
  }
18
+ function signedNodeApiUrl(value) {
19
+ let url;
20
+ try {
21
+ url = new URL(value);
22
+ } catch {
23
+ throw new Error("Agent API URL must be an absolute HTTPS URL.");
24
+ }
25
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
26
+ if (url.protocol !== "https:" && !(loopback && url.protocol === "http:") || url.username || url.password || url.search || url.hash) {
27
+ throw new Error("Agent API URL must be HTTPS without credentials, query or fragment.");
28
+ }
29
+ return url;
30
+ }
18
31
  async function postSignedNode(options, path, body) {
19
- const url = new URL(options.apiUrl);
32
+ const url = signedNodeApiUrl(options.apiUrl);
20
33
  url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
21
34
  url.search = "";
22
35
  url.hash = "";
@@ -135,8 +135,21 @@ class SignedNodeHttpError extends Error {
135
135
  this.name = "SignedNodeHttpError";
136
136
  }
137
137
  }
138
+ function signedNodeApiUrl(value) {
139
+ let url;
140
+ try {
141
+ url = new URL(value);
142
+ } catch {
143
+ throw new Error("Agent API URL must be an absolute HTTPS URL.");
144
+ }
145
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
146
+ if (url.protocol !== "https:" && !(loopback && url.protocol === "http:") || url.username || url.password || url.search || url.hash) {
147
+ throw new Error("Agent API URL must be HTTPS without credentials, query or fragment.");
148
+ }
149
+ return url;
150
+ }
138
151
  async function postSignedNode(options, path, body) {
139
- const url = new URL(options.apiUrl);
152
+ const url = signedNodeApiUrl(options.apiUrl);
140
153
  url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
141
154
  url.search = "";
142
155
  url.hash = "";
@@ -196,7 +209,7 @@ function projectVaultCoordinate(key) {
196
209
  function tenantNodeApiUrl(apiUrl, tenantSlug) {
197
210
  if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(tenantSlug))
198
211
  throw new Error("tenant slug is malformed");
199
- const url = new URL(apiUrl);
212
+ const url = signedNodeApiUrl(apiUrl);
200
213
  url.pathname = `/api/t/${encodeURIComponent(tenantSlug)}`;
201
214
  url.search = "";
202
215
  url.hash = "";
@@ -128,6 +128,7 @@ export declare const VAULT_GROUP = "forgezero-vault";
128
128
  export declare const LIFECYCLE_GROUP = "forgezero-lifecycle";
129
129
  export declare const DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
130
130
  export declare const AGENT_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-agent.socket";
131
+ export declare const AGENT_SOCKET_PROXY_UNIT_PATH = "/etc/systemd/system/forgezero-agent-proxy.service";
131
132
  export declare const DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
132
133
  export declare const ENROLMENT_UNIT_PATH = "/etc/systemd/system/forgezero-agent-enrol.service";
133
134
  export declare const LIFECYCLE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-lifecycle-helper.service";
@@ -146,6 +147,13 @@ export declare function agentUpdateHelperUnit(options: Pick<UnitOptions, 'binPat
146
147
  * terminal, and it works identically on platform and tenant computes.
147
148
  */
148
149
  export declare function agentSocketUnit(options: Pick<UnitOptions, 'socketPath'>): string;
150
+ /**
151
+ * Bun does not support inheriting systemd's listening file descriptor. Keep
152
+ * PID 1 as the owner of the stable application socket and let systemd's small,
153
+ * credential-free proxy bridge it to the Agent-owned backend Unix socket.
154
+ */
155
+ export declare function agentSocketProxyUnit(options: Pick<UnitOptions, 'socketPath' | 'user'>): string;
156
+ export declare function agentBackendSocketPath(publicSocketPath: string): string;
149
157
  export declare function warpConfigUnit(options: Pick<UnitOptions, 'binPath' | 'warpOrganization' | 'warpClientIdCredentialPath' | 'warpClientSecretCredentialPath'>): string;
150
158
  export declare function warpServiceDropIn(): string;
151
159
  export declare function lifecycleHelperUnit(options: Pick<UnitOptions, 'binPath' | 'lifecycleProfilePath' | 'lifecycleHelperSocketPath'>): string;
package/dist/provision.js CHANGED
@@ -554,7 +554,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
554
554
  }
555
555
 
556
556
  // src/version.ts
557
- var VERSION2 = "0.1.27";
557
+ var VERSION2 = "0.1.29";
558
558
 
559
559
  // src/provision.ts
560
560
  function atLeast(version, floor) {
@@ -603,6 +603,7 @@ var VAULT_GROUP = "forgezero-vault";
603
603
  var LIFECYCLE_GROUP = "forgezero-lifecycle";
604
604
  var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
605
605
  var AGENT_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-agent.socket";
606
+ var AGENT_SOCKET_PROXY_UNIT_PATH = "/etc/systemd/system/forgezero-agent-proxy.service";
606
607
  var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
607
608
  var ENROLMENT_UNIT_PATH = "/etc/systemd/system/forgezero-agent-enrol.service";
608
609
  var LIFECYCLE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-lifecycle-helper.service";
@@ -692,17 +693,55 @@ SocketGroup=${VAULT_GROUP}
692
693
  SocketMode=0660
693
694
  DirectoryMode=0750
694
695
  RemoveOnStop=true
695
- Service=forgezero-agent.service
696
+ Service=forgezero-agent-proxy.service
696
697
 
697
698
  [Install]
698
699
  WantedBy=sockets.target
699
700
  `;
700
701
  }
702
+ function agentSocketProxyUnit(options) {
703
+ const backend = agentBackendSocketPath(options.socketPath);
704
+ const user = options.user ?? "forgezero";
705
+ return `[Unit]
706
+ Description=ForgeZero application Vault socket proxy
707
+ Documentation=https://www.forgezero.net/docs/agent
708
+ Requires=forgezero-agent.service
709
+ After=forgezero-agent.service
710
+
711
+ [Service]
712
+ User=${user}
713
+ Group=${VAULT_GROUP}
714
+ ExecStart=/usr/lib/systemd/systemd-socket-proxyd ${backend}
715
+ NoNewPrivileges=true
716
+ PrivateTmp=true
717
+ ProtectSystem=strict
718
+ ProtectHome=true
719
+ ProtectKernelTunables=true
720
+ ProtectKernelModules=true
721
+ ProtectControlGroups=true
722
+ RestrictSUIDSGID=true
723
+ RestrictRealtime=true
724
+ MemoryDenyWriteExecute=true
725
+ LockPersonality=true
726
+ RestrictAddressFamilies=AF_UNIX
727
+ `;
728
+ }
729
+ function agentBackendSocketPath(publicSocketPath) {
730
+ const socket = systemdPath(publicSocketPath, "agent socket");
731
+ const backend = `${socket}.backend`;
732
+ if (Buffer.byteLength(backend) > 100)
733
+ throw new Error("agent socket path is too long for a Unix socket");
734
+ return backend;
735
+ }
701
736
  var systemdPath = (value, label) => {
702
737
  if (!value || !/^\/[A-Za-z0-9._@/-]+$/.test(value))
703
738
  throw new Error(`invalid ${label} path`);
704
739
  return value;
705
740
  };
741
+ var awaitSocketCommand = (path) => {
742
+ const socket = systemdPath(path, "readiness socket");
743
+ return `for attempt in $(seq 1 100); do test -S ${socket} && exit 0; sleep 0.1; done; exit 1`;
744
+ };
706
745
  var validNodeHostname = (value) => !value || value.length <= 253 && value === value.toLowerCase() && value.split(".").length >= 3 && value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
707
746
  function warpConfigUnit(options) {
708
747
  if (!options.warpOrganization || !/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.warpOrganization)) {
@@ -941,7 +980,7 @@ function agentUnit(options) {
941
980
  }
942
981
  }
943
982
  const environment = [
944
- `FZ_SOCKET_PATH=${options.socketPath}`,
983
+ `FZ_SOCKET_PATH=${agentBackendSocketPath(options.socketPath)}`,
945
984
  `FZ_CONTROL_SOCKET=${controlSocketPath}`,
946
985
  `FZ_SEED_CREDENTIAL=agent-seed`,
947
986
  `FZ_AGENT_MODE=${options.mode}`,
@@ -1021,7 +1060,6 @@ Type=simple
1021
1060
  User=${user}
1022
1061
  Group=${VAULT_GROUP}
1023
1062
  ${deploymentGroup}
1024
- Sockets=forgezero-agent.socket
1025
1063
  LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
1026
1064
  ${gitCredential}${projectCredentials}${projectCredentials ? `
1027
1065
  ` : ""}${snpPrepare}ExecStart=${bin}
@@ -1109,6 +1147,7 @@ function planProvision(options) {
1109
1147
  unit: agentUnit({ ...options, mode, lifecycleProfilePath, lifecycleHelperSocketPath }),
1110
1148
  auxiliaryUnits: [
1111
1149
  { path: AGENT_SOCKET_UNIT_PATH, unit: agentSocketUnit(options) },
1150
+ { path: AGENT_SOCKET_PROXY_UNIT_PATH, unit: agentSocketProxyUnit(options) },
1112
1151
  { path: AGENT_UPDATE_HELPER_UNIT_PATH, unit: agentUpdateHelperUnit(options) },
1113
1152
  ...deploymentEnabled ? [
1114
1153
  { path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) },
@@ -1184,6 +1223,10 @@ function planProvision(options) {
1184
1223
  label: "credential directory",
1185
1224
  command: `install -d -o root -g root -m 0700 ${credentialDir}`
1186
1225
  },
1226
+ {
1227
+ label: "Agent state directory",
1228
+ command: "install -d -o root -g root -m 0750 /var/lib/forgezero"
1229
+ },
1187
1230
  {
1188
1231
  label: "encrypted node identity",
1189
1232
  command: `test -s ${seedCredentialPath} || { ` + `openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\\n' | ` + `systemd-creds encrypt --name=agent-seed - ${seedCredentialPath}; ` + `chmod 0400 ${seedCredentialPath}; }`
@@ -1218,8 +1261,8 @@ function planProvision(options) {
1218
1261
  command: "systemctl disable --now forgezero-deploy-runner.socket 2>/dev/null || true; rm -f /etc/systemd/system/forgezero-deploy-runner.socket; systemctl daemon-reload"
1219
1262
  }] : [],
1220
1263
  {
1221
- label: "enable and start",
1222
- command: `systemctl enable --now ${[
1264
+ label: "enable and converge services",
1265
+ command: `systemctl enable ${[
1223
1266
  "forgezero-agent.socket",
1224
1267
  "forgezero-agent-update-helper.service",
1225
1268
  ...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
@@ -1227,25 +1270,32 @@ function planProvision(options) {
1227
1270
  ...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
1228
1271
  ...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : [],
1229
1272
  "forgezero-agent.service"
1230
- ].join(" ")}`
1273
+ ].join(" ")}; systemctl reset-failed forgezero-agent.service || true; systemctl restart ${[
1274
+ "forgezero-agent-update-helper.service",
1275
+ ...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
1276
+ ...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
1277
+ ...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
1278
+ ...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : []
1279
+ ].join(" ")}; systemctl restart forgezero-agent.socket; systemctl reset-failed forgezero-agent.service || true; systemctl restart forgezero-agent.service`
1231
1280
  },
1232
1281
  ...enrolmentEnabled ? [{
1233
1282
  label: "prove the compute binding is durable",
1234
1283
  command: `test -s ${enrolStatePath}`
1235
1284
  }] : [],
1236
1285
  { label: "prove it is running", command: "systemctl is-active forgezero-agent.service" },
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}` },
1286
+ { label: "prove the public Vault socket exists", command: awaitSocketCommand(options.socketPath) },
1287
+ { label: "prove the Agent Vault backend exists", command: awaitSocketCommand(agentBackendSocketPath(options.socketPath)) },
1288
+ { label: "prove the Agent update helper exists", command: awaitSocketCommand(DEFAULT_AGENT_UPDATE_SOCKET) },
1239
1289
  ...deploymentEnabled ? [{
1240
1290
  label: "prove the deployment runner socket exists",
1241
- command: `test -S ${DEPLOYMENT_RUNNER_SOCKET}`
1291
+ command: awaitSocketCommand(DEPLOYMENT_RUNNER_SOCKET)
1242
1292
  }, {
1243
1293
  label: "prove the software strategy helper socket exists",
1244
- command: `test -S ${DEFAULT_SOFTWARE_HELPER_SOCKET}`
1294
+ command: awaitSocketCommand(DEFAULT_SOFTWARE_HELPER_SOCKET)
1245
1295
  }] : [],
1246
1296
  ...lifecycleEnabled ? [{
1247
1297
  label: "prove the lifecycle helper socket exists",
1248
- command: `test -S ${lifecycleHelperSocketPath}`
1298
+ command: awaitSocketCommand(lifecycleHelperSocketPath)
1249
1299
  }] : [],
1250
1300
  ...warpEnabled ? [{
1251
1301
  label: "prove Cloudflare WARP is connected",
@@ -1253,7 +1303,7 @@ function planProvision(options) {
1253
1303
  }] : [],
1254
1304
  ...options.repository ? [{
1255
1305
  label: "prove the deployment control socket exists",
1256
- command: `test -S ${options.controlSocketPath ?? "/run/forgezero/control.sock"}`
1306
+ command: awaitSocketCommand(options.controlSocketPath ?? "/run/forgezero/control.sock")
1257
1307
  }] : []
1258
1308
  ]
1259
1309
  };
@@ -1271,7 +1321,9 @@ export {
1271
1321
  agentUpdateHelperUnit,
1272
1322
  agentUnit,
1273
1323
  agentSocketUnit,
1324
+ agentSocketProxyUnit,
1274
1325
  agentEnrolmentUnit,
1326
+ agentBackendSocketPath,
1275
1327
  WARP_SERVICE_DROP_IN_PATH,
1276
1328
  WARP_CONFIG_UNIT_PATH,
1277
1329
  VAULT_GROUP,
@@ -1285,5 +1337,6 @@ export {
1285
1337
  DEPLOYMENT_RUNNER_SOCKET,
1286
1338
  DEPLOYMENT_GROUP,
1287
1339
  CAPABILITY_CHECKS,
1288
- AGENT_SOCKET_UNIT_PATH
1340
+ AGENT_SOCKET_UNIT_PATH,
1341
+ AGENT_SOCKET_PROXY_UNIT_PATH
1289
1342
  };
@@ -15,8 +15,21 @@ class SignedNodeHttpError extends Error {
15
15
  this.name = "SignedNodeHttpError";
16
16
  }
17
17
  }
18
+ function signedNodeApiUrl(value) {
19
+ let url;
20
+ try {
21
+ url = new URL(value);
22
+ } catch {
23
+ throw new Error("Agent API URL must be an absolute HTTPS URL.");
24
+ }
25
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
26
+ if (url.protocol !== "https:" && !(loopback && url.protocol === "http:") || url.username || url.password || url.search || url.hash) {
27
+ throw new Error("Agent API URL must be HTTPS without credentials, query or fragment.");
28
+ }
29
+ return url;
30
+ }
18
31
  async function postSignedNode(options, path, body) {
19
- const url = new URL(options.apiUrl);
32
+ const url = signedNodeApiUrl(options.apiUrl);
20
33
  url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
21
34
  url.search = "";
22
35
  url.hash = "";
@@ -10,5 +10,15 @@ export declare class SignedNodeHttpError extends Error {
10
10
  readonly status: number;
11
11
  constructor(status: number, message: string);
12
12
  }
13
+ /**
14
+ * Validate the one origin every outbound Agent client shares.
15
+ *
16
+ * Hybrid request authentication and a sealed response protect the payload, but
17
+ * they do not hide routing metadata. Accepting plain HTTP for a remote API
18
+ * would still expose node identity, operation timing and ciphertext to an
19
+ * on-path observer. Loopback HTTP remains available for local development and
20
+ * for a supervised same-host API; production traffic must use HTTPS.
21
+ */
22
+ export declare function signedNodeApiUrl(value: string): URL;
13
23
  /** One implementation of the hybrid-signed machine HTTP contract. */
14
24
  export declare function postSignedNode<T>(options: SignedNodeHttpOptions, path: string, body: object): Promise<T>;
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** One package version shared by both public binaries. Pinned to package.json by tests. */
2
- export declare const VERSION = "0.1.27";
2
+ export declare const VERSION = "0.1.29";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
3
3
  "name": "@forgezero/agent",
4
- "version": "0.1.27",
4
+ "version": "0.1.29",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "check": "tsc --noEmit",