@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.
@@ -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 = "";
package/dist/index.d.ts CHANGED
@@ -27,6 +27,15 @@ export { DEFAULT_METAL_HELPER_SOCKET, requestMetalProvision, startMetalHelper }
27
27
  export { DEFAULT_LIFECYCLE_HELPER_SOCKET, executeLifecycleAction, loadLifecycleProfile, requestLifecycleAction, startLifecycleHelper, validateLifecycleProfile } from './lifecycle-helper';
28
28
  export type { LifecycleCommandResult, LifecycleExec, LifecycleProfile } from './lifecycle-helper';
29
29
  export { materializeWarpMdm, renderWarpMdm } from './warp-config';
30
+ export { compareVersions, restoreAgentRelease, selectAgentRelease, stageAgentRelease, validateAgentRelease, DEFAULT_AGENT_RELEASE_ROOT, DEFAULT_AGENT_UPDATE_SOCKET, MAX_AGENT_TARBALL_BYTES } from './agent-update';
31
+ export type { AgentRelease, StagedAgentRelease, UpdateCommand, UpdateCommandResult } from './agent-update';
32
+ export { activateAgentRelease, probeAgentSocket, requestAgentUpdate, startAgentUpdateHelper, AGENT_UPDATE_GROUP, AGENT_UPDATE_HELPER_UNIT_PATH, AGENT_UPDATE_RECEIPT } from './agent-update-helper';
33
+ export type { AgentUpdateRequest, AgentUpdateResponse } from './agent-update-helper';
34
+ export { DEFAULT_SOFTWARE_HELPER_SOCKET, requestSoftware, startSoftwareHelper, SOFTWARE_HELPER_GROUP, SOFTWARE_HELPER_UNIT_PATH } from './software-helper';
35
+ export { ensureSoftwareRequirements, observeSoftwareHost, validateSoftwareRequirements } from './software';
36
+ export type { SoftwareCommandResult, SoftwareExec, SoftwareObservation, SoftwareRequirement } from './software';
37
+ export { heartbeatAgentOnce, observeAgentHost, startAgentHeartbeat } from './agent-heartbeat';
38
+ export type { AgentHeartbeatOptions, AgentHeartbeatResponse, AgentObservation } from './agent-heartbeat';
30
39
  export { DEFAULT_DEPLOYMENT_RUNNER_SOCKET, requestDeploymentCommand, startDeploymentRunner } from './deployment-runner';
31
40
  export { createSnpAttestationSource } from './snp-attestation';
32
41
  export type { SnpAttestationOptions } from './snp-attestation';
@@ -45,6 +54,8 @@ export { assertSupportedGuestImage, SUPPORTED_GUEST_IMAGE } from './ubuntu';
45
54
  export declare function loadOrCreateSeed(path: string): Uint8Array;
46
55
  export interface AgentConfig {
47
56
  socketPath?: string;
57
+ /** systemd-owned listener fd; preserves the application endpoint across replacement. */
58
+ listenFd?: number;
48
59
  seedPath?: string;
49
60
  /** Prefer this already-unsealed systemd credential over a persistent file. */
50
61
  seedCredential?: string;
@@ -82,6 +93,8 @@ export declare const DEFAULT_SOCKET_PATH = "/run/forgezero/vault.sock";
82
93
  export declare const DEFAULT_SEED_PATH = "/var/lib/forgezero/node.seed";
83
94
  export declare const DEFAULT_SEED_CREDENTIAL = "agent-seed";
84
95
  export declare const DEFAULT_ENROLMENT_STATE_PATH = "/var/lib/forgezero/enrolment.json";
96
+ /** Resolve the one descriptor systemd passes for the application Vault socket. */
97
+ export declare function systemdListenFd(environment?: Record<string, string | undefined>, pid?: number): number | undefined;
85
98
  /**
86
99
  * Read a systemd credential made available only to this unit.
87
100
  *
@@ -552,16 +552,36 @@ async function removeMetalGuest(profile, claim, exec) {
552
552
  throw new MetalProvisionError("create claim cannot remove a guest");
553
553
  const name = claim.spec.guestName ?? guestNameFor(claim.computeKey);
554
554
  const manifestPath = join(profile.stateDir, `${name}.json`);
555
+ const service = `forgezero-guest@${name}.service`;
556
+ const unitPath = join(profile.unitDir, service);
557
+ const lv = `/dev/${profile.volumeGroup}/${name}`;
555
558
  const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : legacyPlatformManifest(profile, claim, name);
556
- if (!manifest)
559
+ if (!manifest) {
560
+ const seedBase = join(profile.seedDir, name);
561
+ const localArtifacts = [
562
+ unitPath,
563
+ `${seedBase}-seed.iso`,
564
+ `${seedBase}-user-data`,
565
+ `${seedBase}-meta-data`,
566
+ `${seedBase}-network-config`
567
+ ];
568
+ if (localArtifacts.some(existsSync)) {
569
+ throw new MetalProvisionError("guest artifacts exist without owned inventory");
570
+ }
571
+ const [volume, active] = await Promise.all([
572
+ exec(["lvs", "--noheadings", lv]),
573
+ exec(["systemctl", "is-active", service])
574
+ ]);
575
+ if (volume.exitCode === 0 || active.exitCode === 0) {
576
+ throw new MetalProvisionError("guest runtime exists without owned inventory");
577
+ }
557
578
  return {};
579
+ }
558
580
  const currentIdentity = manifest.computeKey === claim.computeKey && manifest.reference === claim.spec.reference && manifest.name === name;
559
581
  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
582
  if (!currentIdentity && !legacyPlatformIdentity) {
561
583
  throw new MetalProvisionError("compute identity conflicts with host inventory");
562
584
  }
563
- const service = `forgezero-guest@${name}.service`;
564
- const unitPath = join(profile.unitDir, service);
565
585
  if (existsSync(unitPath))
566
586
  await checked(exec, ["systemctl", "disable", "--now", service]);
567
587
  else if (legacyPlatformIdentity)
@@ -569,7 +589,6 @@ async function removeMetalGuest(profile, claim, exec) {
569
589
  else if ((await exec(["systemctl", "is-active", service])).exitCode === 0) {
570
590
  throw new MetalProvisionError("guest unit is active but its owned unit file is missing");
571
591
  }
572
- const lv = `/dev/${profile.volumeGroup}/${name}`;
573
592
  if ((await exec(["lvs", "--noheadings", lv])).exitCode === 0)
574
593
  await checked(exec, ["lvremove", "-fy", lv]);
575
594
  for (const path of [
@@ -552,16 +552,36 @@ async function removeMetalGuest(profile, claim, exec) {
552
552
  throw new MetalProvisionError("create claim cannot remove a guest");
553
553
  const name = claim.spec.guestName ?? guestNameFor(claim.computeKey);
554
554
  const manifestPath = join(profile.stateDir, `${name}.json`);
555
+ const service = `forgezero-guest@${name}.service`;
556
+ const unitPath = join(profile.unitDir, service);
557
+ const lv = `/dev/${profile.volumeGroup}/${name}`;
555
558
  const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : legacyPlatformManifest(profile, claim, name);
556
- if (!manifest)
559
+ if (!manifest) {
560
+ const seedBase = join(profile.seedDir, name);
561
+ const localArtifacts = [
562
+ unitPath,
563
+ `${seedBase}-seed.iso`,
564
+ `${seedBase}-user-data`,
565
+ `${seedBase}-meta-data`,
566
+ `${seedBase}-network-config`
567
+ ];
568
+ if (localArtifacts.some(existsSync)) {
569
+ throw new MetalProvisionError("guest artifacts exist without owned inventory");
570
+ }
571
+ const [volume, active] = await Promise.all([
572
+ exec(["lvs", "--noheadings", lv]),
573
+ exec(["systemctl", "is-active", service])
574
+ ]);
575
+ if (volume.exitCode === 0 || active.exitCode === 0) {
576
+ throw new MetalProvisionError("guest runtime exists without owned inventory");
577
+ }
557
578
  return {};
579
+ }
558
580
  const currentIdentity = manifest.computeKey === claim.computeKey && manifest.reference === claim.spec.reference && manifest.name === name;
559
581
  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
582
  if (!currentIdentity && !legacyPlatformIdentity) {
561
583
  throw new MetalProvisionError("compute identity conflicts with host inventory");
562
584
  }
563
- const service = `forgezero-guest@${name}.service`;
564
- const unitPath = join(profile.unitDir, service);
565
585
  if (existsSync(unitPath))
566
586
  await checked(exec, ["systemctl", "disable", "--now", service]);
567
587
  else if (legacyPlatformIdentity)
@@ -569,7 +589,6 @@ async function removeMetalGuest(profile, claim, exec) {
569
589
  else if ((await exec(["systemctl", "is-active", service])).exitCode === 0) {
570
590
  throw new MetalProvisionError("guest unit is active but its owned unit file is missing");
571
591
  }
572
- const lv = `/dev/${profile.volumeGroup}/${name}`;
573
592
  if ((await exec(["lvs", "--noheadings", lv])).exitCode === 0)
574
593
  await checked(exec, ["lvremove", "-fy", lv]);
575
594
  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 = "";
@@ -127,12 +127,25 @@ export declare const DEPLOYMENT_GROUP = "forgezero-deploy";
127
127
  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
+ export declare const AGENT_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-agent.socket";
130
131
  export declare const DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
131
132
  export declare const ENROLMENT_UNIT_PATH = "/etc/systemd/system/forgezero-agent-enrol.service";
132
133
  export declare const LIFECYCLE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-lifecycle-helper.service";
133
134
  export declare const LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
134
135
  export declare const WARP_CONFIG_UNIT_PATH = "/etc/systemd/system/forgezero-warp-config.service";
135
136
  export declare const WARP_SERVICE_DROP_IN_PATH = "/etc/systemd/system/warp-svc.service.d/forgezero.conf";
137
+ /** Root may execute only package-owned OS strategies selected by id + version. */
138
+ export declare function softwareHelperUnit(options: Pick<UnitOptions, 'binPath'>): string;
139
+ /** Fixed root boundary for verified, health-gated Agent replacement. */
140
+ export declare function agentUpdateHelperUnit(options: Pick<UnitOptions, 'binPath'>): string;
141
+ /**
142
+ * PID 1 owns the stable application endpoint.
143
+ *
144
+ * New connections wait in the kernel while the credential-bearing Agent drains
145
+ * and a health-gated replacement starts. This is supervision, not a background
146
+ * terminal, and it works identically on platform and tenant computes.
147
+ */
148
+ export declare function agentSocketUnit(options: Pick<UnitOptions, 'socketPath'>): string;
136
149
  export declare function warpConfigUnit(options: Pick<UnitOptions, 'binPath' | 'warpOrganization' | 'warpClientIdCredentialPath' | 'warpClientSecretCredentialPath'>): string;
137
150
  export declare function warpServiceDropIn(): string;
138
151
  export declare function lifecycleHelperUnit(options: Pick<UnitOptions, 'binPath' | 'lifecycleProfilePath' | 'lifecycleHelperSocketPath'>): string;