@forgezero/agent 0.1.27 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.28";
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 = "";
@@ -6564,16 +6577,36 @@ async function removeMetalGuest(profile, claim, exec) {
6564
6577
  throw new MetalProvisionError("create claim cannot remove a guest");
6565
6578
  const name = claim.spec.guestName ?? guestNameFor(claim.computeKey);
6566
6579
  const manifestPath = join2(profile.stateDir, `${name}.json`);
6580
+ const service = `forgezero-guest@${name}.service`;
6581
+ const unitPath = join2(profile.unitDir, service);
6582
+ const lv = `/dev/${profile.volumeGroup}/${name}`;
6567
6583
  const manifest = existsSync5(manifestPath) ? JSON.parse(readFileSync4(manifestPath, "utf8")) : legacyPlatformManifest(profile, claim, name);
6568
- if (!manifest)
6584
+ if (!manifest) {
6585
+ const seedBase = join2(profile.seedDir, name);
6586
+ const localArtifacts = [
6587
+ unitPath,
6588
+ `${seedBase}-seed.iso`,
6589
+ `${seedBase}-user-data`,
6590
+ `${seedBase}-meta-data`,
6591
+ `${seedBase}-network-config`
6592
+ ];
6593
+ if (localArtifacts.some(existsSync5)) {
6594
+ throw new MetalProvisionError("guest artifacts exist without owned inventory");
6595
+ }
6596
+ const [volume, active] = await Promise.all([
6597
+ exec(["lvs", "--noheadings", lv]),
6598
+ exec(["systemctl", "is-active", service])
6599
+ ]);
6600
+ if (volume.exitCode === 0 || active.exitCode === 0) {
6601
+ throw new MetalProvisionError("guest runtime exists without owned inventory");
6602
+ }
6569
6603
  return {};
6604
+ }
6570
6605
  const currentIdentity = manifest.computeKey === claim.computeKey && manifest.reference === claim.spec.reference && manifest.name === name;
6571
6606
  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
6607
  if (!currentIdentity && !legacyPlatformIdentity) {
6573
6608
  throw new MetalProvisionError("compute identity conflicts with host inventory");
6574
6609
  }
6575
- const service = `forgezero-guest@${name}.service`;
6576
- const unitPath = join2(profile.unitDir, service);
6577
6610
  if (existsSync5(unitPath))
6578
6611
  await checked(exec, ["systemctl", "disable", "--now", service]);
6579
6612
  else if (legacyPlatformIdentity)
@@ -6581,7 +6614,6 @@ async function removeMetalGuest(profile, claim, exec) {
6581
6614
  else if ((await exec(["systemctl", "is-active", service])).exitCode === 0) {
6582
6615
  throw new MetalProvisionError("guest unit is active but its owned unit file is missing");
6583
6616
  }
6584
- const lv = `/dev/${profile.volumeGroup}/${name}`;
6585
6617
  if ((await exec(["lvs", "--noheadings", lv])).exitCode === 0)
6586
6618
  await checked(exec, ["lvremove", "-fy", lv]);
6587
6619
  for (const path of [
@@ -7844,7 +7876,7 @@ function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, t
7844
7876
  import { readFileSync as readFileSync7 } from "fs";
7845
7877
 
7846
7878
  // src/version.ts
7847
- var VERSION2 = "0.1.27";
7879
+ var VERSION2 = "0.1.28";
7848
7880
 
7849
7881
  // src/agent-heartbeat.ts
7850
7882
  var unquote = (value) => value.replace(/^['"]|['"]$/g, "");
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.28";
4890
4890
 
4891
4891
  // src/software.ts
4892
4892
  var BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
@@ -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 = "";
@@ -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 = "";
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.28";
558
558
 
559
559
  // src/provision.ts
560
560
  function atLeast(version, floor) {
@@ -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.28";
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.28",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "check": "tsc --noEmit",