@forgezero/agent 0.1.67 → 0.1.69

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
@@ -532,22 +532,26 @@ await readBootstrapBundle(built.bundlePath, built.manifestPath);
532
532
  <a id="forgezero-agent-operator-bootstrap"></a>
533
533
  ## @forgezero/agent/operator-bootstrap
534
534
 
535
- Pinned-host operator transport that stages owner-only handoffs and invokes the same typed platform bootstrap through the caller-approved SSH agent. This entry exposes 15 named value exports and 15 named type exports. The generated import block lists one name per line for scanning and copying; keep only the names used by your file.
535
+ Pinned-host operator transport that stages owner-only handoffs and invokes the same typed platform bootstrap through the caller-approved SSH agent. This entry exposes 19 named value exports and 17 named type exports. The generated import block lists one name per line for scanning and copying; keep only the names used by your file.
536
536
 
537
537
  ```text
538
538
  import {
539
539
  applyOperatorMetalBootstrap,
540
540
  applyOperatorPlatformBootstrap,
541
541
  applyOperatorPlatformFleetBootstrap,
542
+ collectOperatorGuestHostKeys,
542
543
  createOperatorSshHop,
543
544
  operatorPlatformFleetCoordinates,
544
545
  planOperatorMetalBootstrap,
545
546
  planOperatorPlatformBootstrap,
546
547
  planOperatorPlatformFleetBootstrap,
548
+ readOperatorGuestHostKeyEvidence,
547
549
  readOperatorMetalBootstrapRequest,
548
550
  readOperatorPlatformBootstrapFleetRequest,
549
551
  readOperatorPlatformBootstrapRequest,
550
552
  verifyOperatorPlatformFleet,
553
+ withOperatorGuestTransport,
554
+ writeOperatorGuestHostKeyEvidence,
551
555
  writeOperatorMetalBootstrapRequest,
552
556
  writeOperatorPlatformBootstrapFleetRequest,
553
557
  writeOperatorPlatformBootstrapRequest,
@@ -556,6 +560,8 @@ import {
556
560
  import type {
557
561
  OperatorCommand,
558
562
  OperatorCommandResult,
563
+ OperatorGuestHostKeyEvidence,
564
+ OperatorGuestTransport,
559
565
  OperatorMetalBootstrapMode,
560
566
  OperatorMetalBootstrapPlan,
561
567
  OperatorMetalBootstrapRequest,
@@ -749,7 +749,7 @@ async function postSignedNode(options, path, body) {
749
749
  }
750
750
 
751
751
  // src/version.ts
752
- var VERSION3 = "0.1.67";
752
+ var VERSION3 = "0.1.69";
753
753
 
754
754
  // src/agent-heartbeat.ts
755
755
  var unquote = (value) => value.replace(/^['"]|['"]$/g, "");
@@ -0,0 +1,22 @@
1
+ import { finalizeCloudflareBootstrapAcceptance, runAttendedCloudflareBootstrap, type AttendedCloudflareBootstrapRequest, type CloudflareBootstrapAcceptanceEvidence, type CloudflareBootstrapEvidence } from './cloudflare-bootstrap';
2
+ import { applyOperatorPlatformFleetBootstrap, type OperatorPlatformBootstrapFleetRequest, type OperatorPlatformBootstrapFleetResult } from './operator-bootstrap';
3
+ import type { PlatformBootstrapSecrets } from './bootstrap';
4
+ export interface AttendedPlatformBootstrapResult {
5
+ kind: 'forgezero-attended-platform-bootstrap';
6
+ ok: boolean;
7
+ edge: CloudflareBootstrapEvidence;
8
+ fleet: OperatorPlatformBootstrapFleetResult;
9
+ acceptance?: CloudflareBootstrapAcceptanceEvidence;
10
+ inviteUrl?: string;
11
+ }
12
+ export interface AttendedPlatformBootstrapDependencies {
13
+ cloudflare?: typeof runAttendedCloudflareBootstrap;
14
+ fleet?: typeof applyOperatorPlatformFleetBootstrap;
15
+ finalize?: typeof finalizeCloudflareBootstrapAcceptance;
16
+ }
17
+ /**
18
+ * The sole attended genesis composition boundary. All non-secret coordinates
19
+ * are checked before the first Cloudflare or SSH mutation. Secret values exist
20
+ * only in this call and the authenticated SSH stdin used by the fleet runner.
21
+ */
22
+ export declare function applyAttendedPlatformBootstrap(fleetRequest: OperatorPlatformBootstrapFleetRequest, cloudflareRequest: AttendedCloudflareBootstrapRequest, acceptancePath: string, secrets: PlatformBootstrapSecrets, dependencies?: AttendedPlatformBootstrapDependencies): Promise<AttendedPlatformBootstrapResult>;
package/dist/bootstrap.js CHANGED
@@ -1396,7 +1396,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
1396
1396
  var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
1397
1397
 
1398
1398
  // src/version.ts
1399
- var VERSION = "0.1.67";
1399
+ var VERSION = "0.1.69";
1400
1400
 
1401
1401
  // src/software.ts
1402
1402
  var PINNED_BUN_VERSION = "1.3.14";
@@ -4380,6 +4380,7 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
4380
4380
  }
4381
4381
  bindBootstrapIntent(host, config);
4382
4382
  const plan = planBootstrap(config, host.exists(STATE_PATH));
4383
+ await checked3(host, ["hostnamectl", "set-hostname", "--static", config.nodeHostname], "compute hostname");
4383
4384
  const alreadyEnrolled = host.exists("/var/lib/forgezero/enrolment.json");
4384
4385
  host.mkdir(CREDS, 448);
4385
4386
  host.mkdir("/var/lib/forgezero", 448);
package/dist/fz-agent.js CHANGED
@@ -8588,7 +8588,7 @@ import { join as join3 } from "path";
8588
8588
  import { fileURLToPath } from "url";
8589
8589
 
8590
8590
  // src/version.ts
8591
- var VERSION2 = "0.1.67";
8591
+ var VERSION2 = "0.1.69";
8592
8592
 
8593
8593
  // src/ssh-bootstrap.ts
8594
8594
  class SshBootstrapError extends Error {
package/dist/fz.js CHANGED
@@ -4829,7 +4829,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
4829
4829
  var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
4830
4830
 
4831
4831
  // src/version.ts
4832
- var VERSION2 = "0.1.67";
4832
+ var VERSION2 = "0.1.69";
4833
4833
 
4834
4834
  // src/software.ts
4835
4835
  var PINNED_BUN_VERSION = "1.3.14";
@@ -14399,6 +14399,7 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
14399
14399
  }
14400
14400
  bindBootstrapIntent(host, config);
14401
14401
  const plan = planBootstrap(config, host.exists(STATE_PATH));
14402
+ await checked3(host, ["hostnamectl", "set-hostname", "--static", config.nodeHostname], "compute hostname");
14402
14403
  const alreadyEnrolled = host.exists("/var/lib/forgezero/enrolment.json");
14403
14404
  host.mkdir(CREDS, 448);
14404
14405
  host.mkdir("/var/lib/forgezero", 448);
@@ -15076,6 +15077,14 @@ async function runCloudflareBootstrapFinalizeCommand(configPath, dependencies =
15076
15077
  return evidence;
15077
15078
  }
15078
15079
 
15080
+ // src/operator-bootstrap.ts
15081
+ import { createHash as createHash6, randomBytes as randomBytes9 } from "crypto";
15082
+ import { chmodSync as chmodSync6, lstatSync as lstatSync7, mkdirSync as mkdirSync12, mkdtempSync, readFileSync as readFileSync12, rmSync as rmSync7, writeFileSync as writeFileSync12 } from "fs";
15083
+ import { isIP as isIP6 } from "net";
15084
+ import { tmpdir } from "os";
15085
+ import { basename as basename2, dirname as dirname13, isAbsolute as isAbsolute5, join as join10, resolve as resolve9 } from "path";
15086
+ import { fileURLToPath as fileURLToPath2 } from "url";
15087
+
15079
15088
  // src/metal-bootstrap.ts
15080
15089
  import { createHash as createHash5, randomBytes as randomBytes8 } from "crypto";
15081
15090
  import {
@@ -15986,12 +15995,6 @@ async function metalBootstrapStatus(exec = defaultExec2) {
15986
15995
  }
15987
15996
 
15988
15997
  // src/operator-bootstrap.ts
15989
- import { createHash as createHash6, randomBytes as randomBytes9 } from "crypto";
15990
- import { chmodSync as chmodSync6, lstatSync as lstatSync7, mkdirSync as mkdirSync12, mkdtempSync, readFileSync as readFileSync12, rmSync as rmSync7, writeFileSync as writeFileSync12 } from "fs";
15991
- import { isIP as isIP6 } from "net";
15992
- import { tmpdir } from "os";
15993
- import { basename as basename2, dirname as dirname13, isAbsolute as isAbsolute5, join as join10, resolve as resolve9 } from "path";
15994
- import { fileURLToPath as fileURLToPath2 } from "url";
15995
15998
  var REQUEST_LIMIT = 64 * 1024;
15996
15999
  var SECRET_LIMIT = 256 * 1024;
15997
16000
  var REMOTE_STAGE = "/run/forgezero-operator-bootstrap";
@@ -16129,7 +16132,12 @@ function writeOperatorPlatformBootstrapRequest(path, request) {
16129
16132
  }
16130
16133
  function readOperatorPlatformBootstrapFleetRequest(path) {
16131
16134
  const value = JSON.parse(new TextDecoder().decode(ownerFile(path, REQUEST_LIMIT, "operator fleet request")));
16132
- const root = exactKeys5(value, ["kind", "requests"], "operator fleet request");
16135
+ const root = exactKeys5(value, [
16136
+ "kind",
16137
+ "requests",
16138
+ "cloudflareConfigFile",
16139
+ "cloudflareAcceptanceFile"
16140
+ ], "operator fleet request");
16133
16141
  if (root.kind !== "platform-fleet-remote")
16134
16142
  throw new Error("operator fleet kind must be platform-fleet-remote");
16135
16143
  if (!Array.isArray(root.requests) || root.requests.length !== 3 || root.requests.some((entry) => typeof entry !== "string")) {
@@ -16140,10 +16148,25 @@ function readOperatorPlatformBootstrapFleetRequest(path) {
16140
16148
  throw new Error("operator fleet request files must be unique");
16141
16149
  for (const request of requests)
16142
16150
  readOperatorPlatformBootstrapRequest(request);
16143
- return { kind: "platform-fleet-remote", requests };
16151
+ if (typeof root.cloudflareConfigFile !== "string" || typeof root.cloudflareAcceptanceFile !== "string") {
16152
+ throw new Error("operator fleet requires Cloudflare coordinates and an acceptance evidence path");
16153
+ }
16154
+ const cloudflareConfigFile = canonicalOutputPath(root.cloudflareConfigFile, "operator fleet Cloudflare config");
16155
+ const cloudflareAcceptanceFile = canonicalOutputPath(root.cloudflareAcceptanceFile, "operator fleet Cloudflare acceptance evidence");
16156
+ return {
16157
+ kind: "platform-fleet-remote",
16158
+ requests,
16159
+ cloudflareConfigFile,
16160
+ cloudflareAcceptanceFile
16161
+ };
16144
16162
  }
16145
- function writeOperatorPlatformBootstrapFleetRequest(path, requestFiles) {
16146
- const request = { kind: "platform-fleet-remote", requests: requestFiles };
16163
+ function writeOperatorPlatformBootstrapFleetRequest(path, requestFiles, cloudflare) {
16164
+ const request = {
16165
+ kind: "platform-fleet-remote",
16166
+ requests: requestFiles,
16167
+ cloudflareConfigFile: canonicalOutputPath(cloudflare.configFile, "operator fleet Cloudflare config"),
16168
+ cloudflareAcceptanceFile: canonicalOutputPath(cloudflare.acceptanceFile, "operator fleet Cloudflare acceptance evidence")
16169
+ };
16147
16170
  if (new Set(requestFiles).size !== 3)
16148
16171
  throw new Error("operator fleet request files must be unique");
16149
16172
  for (const file of requestFiles)
@@ -16237,6 +16260,48 @@ function writeOperatorMetalBootstrapRequest(path, request) {
16237
16260
  validateMetalRequestValue(request);
16238
16261
  return writeOwnerJson2(path, request, "operator metal request");
16239
16262
  }
16263
+ var validateGuestHostKeyEvidenceValue = (value, request) => {
16264
+ const root = exactKeys5(value, ["format", "kind", "metalHostKeySha256", "nodes"], "operator guest host-key evidence");
16265
+ if (root.format !== 1 || root.kind !== "forgezero-operator-guest-host-keys" || typeof root.metalHostKeySha256 !== "string" || !Array.isArray(root.nodes)) {
16266
+ throw new Error("operator guest host-key evidence is malformed");
16267
+ }
16268
+ const nodes = root.nodes.map((value2, index) => {
16269
+ const row2 = exactKeys5(value2, ["name", "address", "hostKey", "hostKeySha256"], `operator guest host key ${index + 1}`);
16270
+ if (typeof row2.name !== "string" || typeof row2.address !== "string") {
16271
+ throw new Error("operator guest host-key identity is malformed");
16272
+ }
16273
+ const hop = validateHop({
16274
+ address: row2.address,
16275
+ port: 22,
16276
+ user: "forgezero",
16277
+ hostKey: row2.hostKey,
16278
+ hostKeySha256: row2.hostKeySha256
16279
+ }, `operator guest host key ${row2.name}`);
16280
+ return { name: row2.name, address: hop.address, hostKey: hop.hostKey, hostKeySha256: hop.hostKeySha256 };
16281
+ });
16282
+ if (new Set(nodes.map(({ name }) => name)).size !== nodes.length || new Set(nodes.map(({ address }) => address)).size !== nodes.length) {
16283
+ throw new Error("operator guest host-key identities must be unique");
16284
+ }
16285
+ if (request) {
16286
+ const expected = nodes.length === request.genesis.nodes.length ? [...request.genesis.nodes] : [...request.genesis.nodes, ...request.genesis.rehearsalNode ? [request.genesis.rehearsalNode] : []];
16287
+ if (root.metalHostKeySha256 !== request.target.hostKeySha256 || ![request.genesis.nodes.length, request.genesis.nodes.length + (request.genesis.rehearsalNode ? 1 : 0)].includes(nodes.length) || nodes.length !== expected.length || expected.some((guest, index) => nodes[index]?.name !== guest.name || nodes[index]?.address !== guest.address)) {
16288
+ throw new Error("operator guest host-key evidence does not match the reviewed Metal request");
16289
+ }
16290
+ }
16291
+ return {
16292
+ format: 1,
16293
+ kind: "forgezero-operator-guest-host-keys",
16294
+ metalHostKeySha256: root.metalHostKeySha256,
16295
+ nodes
16296
+ };
16297
+ };
16298
+ function readOperatorGuestHostKeyEvidence(path, request) {
16299
+ const value = JSON.parse(new TextDecoder().decode(ownerFile(path, REQUEST_LIMIT, "operator guest host-key evidence")));
16300
+ return validateGuestHostKeyEvidenceValue(value, request);
16301
+ }
16302
+ function writeOperatorGuestHostKeyEvidence(path, evidence, request) {
16303
+ return writeOwnerJson2(path, validateGuestHostKeyEvidenceValue(evidence, request), "operator guest host-key evidence");
16304
+ }
16240
16305
  function planOperatorMetalBootstrap(request, mode) {
16241
16306
  const config = readMetalBootstrapConfig(request.metalConfigFile);
16242
16307
  const keys = request.genesis.sshPublicKeyFiles.map((path) => publicIdentity(path));
@@ -16244,19 +16309,20 @@ function planOperatorMetalBootstrap(request, mode) {
16244
16309
  throw new Error("the reviewed metal request has no rehearsal node");
16245
16310
  }
16246
16311
  const claims = mode === "rehearsal" || mode === "rehearsal-cleanup" ? mode === "rehearsal-cleanup" ? platformCommunityRehearsalDeletionClaims(request.genesis.nodes, request.genesis.rehearsalNode) : platformCommunityRehearsalClaims(request.genesis.nodes, request.genesis.rehearsalNode, keys) : platformGenesisClaims(request.genesis.nodes, keys);
16312
+ const genesisNodes = mode === "host-keys" ? request.genesis.nodes.map(({ name }) => name) : mode === "rehearsal-host-keys" ? guestRows(request).map(({ name }) => name) : claims.map((claim) => claim.spec.guestName).filter((name) => Boolean(name));
16247
16313
  return {
16248
16314
  kind: "metal-remote",
16249
16315
  mode,
16250
- mutation: mode !== "status",
16316
+ mutation: !["status", "host-keys", "rehearsal-host-keys"].includes(mode),
16251
16317
  target: { address: request.target.address, port: request.target.port, user: request.target.user },
16252
- steps: mode === "status" ? ["verify pinned SSH transport", "run typed fz bootstrap status"] : mode === "genesis" || mode === "rehearsal" || mode === "rehearsal-cleanup" ? ["verify pinned SSH transport", mode === "rehearsal" ? "send the three reviewed seed claims plus the reviewed fourth Community rehearsal claim to the constrained Metal helper" : mode === "rehearsal-cleanup" ? "delete the exact four reviewed disposable guests and their owned disks, units, seed data and inventory" : "send the three reviewed platform-genesis claims to the constrained Metal helper"] : [
16318
+ steps: mode === "status" ? ["verify pinned SSH transport", "run typed fz bootstrap status"] : mode === "host-keys" || mode === "rehearsal-host-keys" ? ["verify pinned SSH transport", "collect exact Ed25519 host keys for the reviewed guest addresses"] : mode === "genesis" || mode === "rehearsal" || mode === "rehearsal-cleanup" ? ["verify pinned SSH transport", mode === "rehearsal" ? "send the three reviewed seed claims plus the reviewed fourth Community rehearsal claim to the constrained Metal helper" : mode === "rehearsal-cleanup" ? "delete the exact four reviewed disposable guests and their owned disks, units, seed data and inventory" : "send the three reviewed platform-genesis claims to the constrained Metal helper"] : [
16253
16319
  "verify pinned SSH transport and caller-approved SSH agent",
16254
16320
  "copy pinned Bun and packaged fz artifacts to private staging",
16255
16321
  "stage the strict owner-only metal config and optional seed handoff",
16256
16322
  "run typed fz bootstrap metal --apply",
16257
16323
  "remove transient local and remote staging data"
16258
16324
  ],
16259
- genesisNodes: claims.map((claim) => claim.spec.guestName).filter((name) => Boolean(name)),
16325
+ genesisNodes,
16260
16326
  secretInputs: config.agentSeedFile ? ["metal-agent-seed"] : []
16261
16327
  };
16262
16328
  }
@@ -16353,6 +16419,20 @@ function validatedPlatformFleet(fleet) {
16353
16419
  }
16354
16420
  return { requests, configs, environment };
16355
16421
  }
16422
+ function operatorPlatformFleetCoordinates(fleet) {
16423
+ const { requests, configs, environment } = validatedPlatformFleet(fleet);
16424
+ return {
16425
+ environment,
16426
+ appOrigin: configs[0].runtime.environment.appOrigin,
16427
+ apiOrigin: configs[0].runtime.environment.apiOrigin,
16428
+ nodes: configs.map((config, index) => ({
16429
+ computeReference: config.computeReference,
16430
+ address: requests[index].target.address,
16431
+ nodeHostname: config.nodeHostname,
16432
+ databaseRole: config.database.role
16433
+ }))
16434
+ };
16435
+ }
16356
16436
  function planOperatorPlatformFleetBootstrap(fleet, mode) {
16357
16437
  const { requests, configs, environment } = validatedPlatformFleet(fleet);
16358
16438
  const secretInputs = mode === "apply" ? attendedSecretNames(configs[0]) : [];
@@ -16479,6 +16559,53 @@ var copy = async (exec, request, knownHosts, local, remotePath, secret = false)
16479
16559
  `${destination2(request.target)}:${remotePath}`
16480
16560
  ], "secure copy", { secret });
16481
16561
  };
16562
+ var guestRows = (request) => [
16563
+ ...request.genesis.nodes,
16564
+ ...request.genesis.rehearsalNode ? [request.genesis.rehearsalNode] : []
16565
+ ];
16566
+ var parsedScannedHostKey = (output, address) => {
16567
+ const keys = output.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith(`${address} ssh-ed25519 `)).map((line) => line.split(/\s+/).slice(1, 3).join(" "));
16568
+ const unique = [...new Set(keys)];
16569
+ if (unique.length !== 1 || !/^ssh-ed25519 [A-Za-z0-9+/]+={0,3}$/.test(unique[0])) {
16570
+ throw new Error(`guest ${address} did not return one exact Ed25519 host key`);
16571
+ }
16572
+ return unique[0];
16573
+ };
16574
+ async function collectOperatorGuestHostKeys(request, options = {}, includeRehearsal = false) {
16575
+ publicIdentity(request.target.identityPublicKeyFile);
16576
+ socketPath(request.target.agentSocket);
16577
+ const exec = options.exec ?? defaultExec3;
16578
+ const directory = mkdtempSync(join10(tmpdir(), "forgezero-operator-host-keys-"));
16579
+ try {
16580
+ const knownHosts = writeKnownHosts(request, directory);
16581
+ const nodes = [];
16582
+ for (const guest of includeRehearsal ? guestRows(request) : request.genesis.nodes) {
16583
+ const output = await remote(exec, request, knownHosts, [
16584
+ "/usr/bin/ssh-keyscan",
16585
+ "-T",
16586
+ "10",
16587
+ "-t",
16588
+ "ed25519",
16589
+ guest.address
16590
+ ], `collect ${guest.name} SSH host key`);
16591
+ const hostKey = parsedScannedHostKey(output, guest.address);
16592
+ nodes.push({
16593
+ name: guest.name,
16594
+ address: guest.address,
16595
+ hostKey,
16596
+ hostKeySha256: fingerprint(hostKey)
16597
+ });
16598
+ }
16599
+ return validateGuestHostKeyEvidenceValue({
16600
+ format: 1,
16601
+ kind: "forgezero-operator-guest-host-keys",
16602
+ metalHostKeySha256: request.target.hostKeySha256,
16603
+ nodes
16604
+ }, request);
16605
+ } finally {
16606
+ rmSync7(directory, { recursive: true, force: true });
16607
+ }
16608
+ }
16482
16609
  async function stageConfig(config, directory) {
16483
16610
  const rewritten = structuredClone(config);
16484
16611
  const staged = [];
@@ -16658,6 +16785,12 @@ async function applyOperatorMetalBootstrap(request, mode, options = {}) {
16658
16785
  const output2 = await remote(exec, request, knownHosts, ["/usr/bin/sudo", "-n", "/usr/local/bin/fz", "bootstrap", "status"], "remote metal status");
16659
16786
  return { plan, output: output2 };
16660
16787
  }
16788
+ if (mode === "host-keys" || mode === "rehearsal-host-keys") {
16789
+ return {
16790
+ plan,
16791
+ output: JSON.stringify(await collectOperatorGuestHostKeys(request, { ...options, exec }, mode === "rehearsal-host-keys"))
16792
+ };
16793
+ }
16661
16794
  if (mode === "genesis" || mode === "rehearsal" || mode === "rehearsal-cleanup") {
16662
16795
  const keys = request.genesis.sshPublicKeyFiles.map((path) => publicIdentity(path));
16663
16796
  if ((mode === "rehearsal" || mode === "rehearsal-cleanup") && !request.genesis.rehearsalNode) {
@@ -16701,6 +16834,47 @@ async function applyOperatorMetalBootstrap(request, mode, options = {}) {
16701
16834
  }
16702
16835
  }
16703
16836
 
16837
+ // src/attended-platform-bootstrap.ts
16838
+ var invitationFrom = (fleet, master) => {
16839
+ const output = fleet.outcomes.find(({ computeReference }) => computeReference === master)?.output;
16840
+ try {
16841
+ const parsed = JSON.parse(output ?? "");
16842
+ if (typeof parsed.launch?.inviteUrl === "string")
16843
+ return parsed.launch.inviteUrl;
16844
+ } catch {}
16845
+ throw new Error("platform bootstrap completed without the initial custodian invitation");
16846
+ };
16847
+ async function applyAttendedPlatformBootstrap(fleetRequest, cloudflareRequest, acceptancePath, secrets, dependencies = {}) {
16848
+ if (cloudflareRequest.mode !== "apply")
16849
+ throw new Error("attended platform bootstrap requires Cloudflare apply mode");
16850
+ if (!secrets.cloudflareTunnelToken || !secrets.cloudflareApiToken) {
16851
+ throw new Error("attended platform bootstrap requires both Cloudflare tokens");
16852
+ }
16853
+ const fleetCoordinates = operatorPlatformFleetCoordinates(fleetRequest);
16854
+ if (cloudflareRequest.coordinates.nodes.length !== fleetCoordinates.nodes.length || fleetCoordinates.nodes.some((node, index) => {
16855
+ const edge2 = cloudflareRequest.coordinates.nodes[index];
16856
+ return edge2?.nodeName !== node.computeReference || edge2.hostname !== node.nodeHostname || edge2.service !== "http://127.0.0.1:3000";
16857
+ })) {
16858
+ throw new Error("Cloudflare coordinates do not match the exact reviewed platform fleet");
16859
+ }
16860
+ const edge = await (dependencies.cloudflare ?? runAttendedCloudflareBootstrap)({
16861
+ ...cloudflareRequest,
16862
+ tokens: {
16863
+ tunnelToken: secrets.cloudflareTunnelToken,
16864
+ apiToken: secrets.cloudflareApiToken
16865
+ }
16866
+ });
16867
+ const fleet = await (dependencies.fleet ?? applyOperatorPlatformFleetBootstrap)(fleetRequest, "apply", { secrets });
16868
+ if (!fleet.ok)
16869
+ return { kind: "forgezero-attended-platform-bootstrap", ok: false, edge, fleet };
16870
+ const inviteUrl = invitationFrom(fleet, fleetCoordinates.nodes[0].computeReference);
16871
+ const acceptance = await (dependencies.finalize ?? finalizeCloudflareBootstrapAcceptance)({
16872
+ checkpointPath: edge.checkpointFile,
16873
+ acceptancePath
16874
+ });
16875
+ return { kind: "forgezero-attended-platform-bootstrap", ok: true, edge, fleet, acceptance, inviteUrl };
16876
+ }
16877
+
16704
16878
  // src/platform-genesis-config.ts
16705
16879
  function platformGenesisBootstrapConfigs(template, guests, nodes, metalHostname, region) {
16706
16880
  const fleet = validatePlatformGenesisGuests(guests);
@@ -18002,9 +18176,12 @@ function writePlatformGenesisFleet(directory) {
18002
18176
  const output = genesisOutputDirectory(directory);
18003
18177
  const metalRequestFile = bootstrapAnswer("Owner-only metal remote request containing the reviewed genesis guests");
18004
18178
  const metalRequest = readOperatorMetalBootstrapRequest(metalRequestFile);
18179
+ const guestHostKeysFile = bootstrapAnswer("Owner-only guest host-key evidence file");
18180
+ const guestHostKeys = readOperatorGuestHostKeyEvidence(guestHostKeysFile, metalRequest);
18005
18181
  const fleet = metalRequest.genesis.nodes;
18006
18182
  const metalHostname = readMetalBootstrapConfig(metalRequest.metalConfigFile).metalHostname;
18007
- const template = interactiveBootstrap("platform", fleet);
18183
+ const checkpointPath = join12(output, "cloudflare-handoff.json");
18184
+ const template = interactiveBootstrap("platform", fleet, checkpointPath);
18008
18185
  const region = {
18009
18186
  label: bootstrapAnswer(`Region ${template.runtime.environment.nodeRegion} display label`),
18010
18187
  country: bootstrapAnswer("Region ISO 3166-1 alpha-2 country code").toUpperCase(),
@@ -18012,32 +18189,65 @@ function writePlatformGenesisFleet(directory) {
18012
18189
  };
18013
18190
  const nodes = fleet.map((guest, index) => index === 0 ? {
18014
18191
  nodeHostname: template.nodeHostname,
18015
- cloudflareHandoffFile: template.cloudflareHandoff.handoffFile
18192
+ cloudflareHandoffFile: cloudflareHostHandoffPath(checkpointPath, guest.name)
18016
18193
  } : {
18017
18194
  nodeHostname: bootstrapAnswer(`${guest.name} public node hostname`),
18018
- cloudflareHandoffFile: bootstrapAnswer(`${guest.name} node-specific Cloudflare host handoff`)
18195
+ cloudflareHandoffFile: cloudflareHostHandoffPath(checkpointPath, guest.name)
18019
18196
  });
18197
+ const realtimeEnabled = bootstrapBoolean("Configure existing Worker realtime fan-out?", "yes");
18198
+ const cloudflareConfig = writeBootstrapConfig(join12(output, "cloudflare.json"), createCloudflareBootstrapCommandConfig({
18199
+ checkpointPath,
18200
+ coordinates: {
18201
+ accountId: bootstrapAnswer("Cloudflare account id"),
18202
+ zoneId: bootstrapAnswer("Cloudflare zone id"),
18203
+ kvNamespaceId: bootstrapAnswer("Existing Worker-bound KV namespace id"),
18204
+ ...realtimeEnabled ? { realtime: {
18205
+ workerScriptName: bootstrapAnswer("Existing Worker script name"),
18206
+ endpoint: bootstrapAnswer("Stable public Worker HTTPS endpoint"),
18207
+ producer: bootstrapAnswer("Realtime producer identity", "platform-api")
18208
+ } } : {},
18209
+ nodes: fleet.map((guest, index) => ({
18210
+ nodeName: guest.name,
18211
+ hostname: nodes[index].nodeHostname,
18212
+ service: "http://127.0.0.1:3000",
18213
+ tunnelName: bootstrapAnswer(`${guest.name} Tunnel name`, guest.name)
18214
+ }))
18215
+ }
18216
+ }));
18217
+ const cloudflareAcceptance = join12(output, "cloudflare-acceptance.json");
18020
18218
  const configs = platformGenesisBootstrapConfigs(template, fleet, nodes, metalHostname, region).map((config) => {
18021
18219
  const path = `${output}/${config.computeReference}-platform.json`;
18022
18220
  writeBootstrapConfig(path, config);
18023
18221
  return path;
18024
18222
  });
18025
- const identity = operatorIdentityCoordinates();
18026
- const useJump = bootstrapBoolean("Reach genesis computes through the Metal SSH jump?", "yes");
18027
- const jump = useJump ? interactiveOperatorHop("Metal jump", { user: "root" }) : undefined;
18028
18223
  const requests = configs.map((platformConfigFile, index) => {
18029
18224
  const guest = fleet[index];
18030
- const target = interactiveOperatorHop(`${guest.name} compute`, { address: guest.address, user: "forgezero" });
18225
+ const host = guestHostKeys.nodes.find(({ name }) => name === guest.name);
18031
18226
  return writeOperatorPlatformBootstrapRequest(join12(output, `${guest.name}-remote.json`), {
18032
18227
  kind: "platform-remote",
18033
18228
  platformConfigFile,
18034
- target: { ...target, ...identity, ...jump ? { jump } : {} }
18229
+ target: {
18230
+ address: guest.address,
18231
+ port: 22,
18232
+ user: "forgezero",
18233
+ hostKey: host.hostKey,
18234
+ hostKeySha256: host.hostKeySha256,
18235
+ identityPublicKeyFile: metalRequest.target.identityPublicKeyFile,
18236
+ agentSocket: metalRequest.target.agentSocket,
18237
+ jump: {
18238
+ address: metalRequest.target.address,
18239
+ port: metalRequest.target.port,
18240
+ user: metalRequest.target.user,
18241
+ hostKey: metalRequest.target.hostKey,
18242
+ hostKeySha256: metalRequest.target.hostKeySha256
18243
+ }
18244
+ }
18035
18245
  });
18036
18246
  });
18037
- const fleetRequest = writeOperatorPlatformBootstrapFleetRequest(join12(output, "platform-fleet-remote.json"), requests);
18038
- return { configs, requests, fleetRequest };
18247
+ const fleetRequest = writeOperatorPlatformBootstrapFleetRequest(join12(output, "platform-fleet-remote.json"), requests, { configFile: cloudflareConfig, acceptanceFile: cloudflareAcceptance });
18248
+ return { configs, requests, fleetRequest, cloudflareConfig, cloudflareAcceptance };
18039
18249
  }
18040
- function interactiveBootstrap(kind, genesisGuests) {
18250
+ function interactiveBootstrap(kind, genesisGuests, genesisCloudflareCheckpoint) {
18041
18251
  if (!process.stdin.isTTY)
18042
18252
  throw new Error("non-interactive bootstrap requires --bootstrap-config <private-json-file>");
18043
18253
  const environment = bootstrapAnswer("Environment (production/development)", "production");
@@ -18077,7 +18287,7 @@ function interactiveBootstrap(kind, genesisGuests) {
18077
18287
  const backupRegion = backupEndpoint ? bootstrapAnswer("Backup S3 region") : undefined;
18078
18288
  const backupBucket = backupEndpoint ? bootstrapAnswer("Backup S3 bucket") : undefined;
18079
18289
  const backupAccessKeyId = backupEndpoint ? bootstrapAnswer("Backup S3 access-key id") : undefined;
18080
- const cloudflareHandoffFile = bootstrapAnswer(genesis ? `${computeReference} node-specific Cloudflare host handoff` : "Node-specific Cloudflare host handoff (blank for no public edge)", genesis ? undefined : "");
18290
+ const cloudflareHandoffFile = genesis ? cloudflareHostHandoffPath(genesisCloudflareCheckpoint, computeReference) : bootstrapAnswer("Node-specific Cloudflare host handoff (blank for no public edge)", "");
18081
18291
  const cloudflareNodeName = cloudflareHandoffFile ? bootstrapAnswer("Cloudflare handoff node name", computeReference) : undefined;
18082
18292
  const databaseNetworkMode = "private-lan";
18083
18293
  const enrolmentSource = genesis ? "genesis-derived" : bootstrapAnswer("Enrolment source (genesis-derived/api-token)", "genesis-derived");
@@ -18183,19 +18393,49 @@ async function cmdBootstrap(options, args) {
18183
18393
  throw new Error("remote platform fleet bootstrap requires --bootstrap-config");
18184
18394
  const fleet = readOperatorPlatformBootstrapFleetRequest(options.bootstrapConfigPath);
18185
18395
  const plan2 = planOperatorPlatformFleetBootstrap(fleet, mode);
18396
+ const cloudflare = readCloudflareBootstrapCommandConfig(fleet.cloudflareConfigFile, options.apply && mode === "apply" ? "apply" : "plan");
18397
+ const coordinates = operatorPlatformFleetCoordinates(fleet);
18398
+ if (cloudflare.coordinates.nodes.length !== coordinates.nodes.length || coordinates.nodes.some((node, index) => {
18399
+ const edge = cloudflare.coordinates.nodes[index];
18400
+ return edge?.nodeName !== node.computeReference || edge.hostname !== node.nodeHostname || edge.service !== "http://127.0.0.1:3000";
18401
+ })) {
18402
+ throw new Error("Cloudflare coordinates do not match the exact reviewed platform fleet");
18403
+ }
18186
18404
  if (!options.apply) {
18187
- out.line(JSON.stringify(plan2, null, 2));
18188
- out.step("Review all three pinned targets and shared secret names, then repeat with --apply.");
18405
+ out.line(JSON.stringify({
18406
+ kind: "forgezero-attended-platform-bootstrap-plan",
18407
+ mutation: true,
18408
+ cloudflare: {
18409
+ checkpointFile: cloudflare.checkpointPath,
18410
+ nodes: cloudflare.coordinates.nodes.map(({ nodeName, hostname: hostname2 }) => ({ nodeName, hostname: hostname2 }))
18411
+ },
18412
+ fleet: plan2,
18413
+ acceptanceFile: fleet.cloudflareAcceptanceFile,
18414
+ completion: ["public ingress verified", "initial custodian invitation printed"]
18415
+ }, null, 2));
18416
+ out.step("Review the complete Tunnel, hostname, bundle, credential and three-node plan, then repeat with --apply.");
18189
18417
  return 0;
18190
18418
  }
18191
18419
  const first = readOperatorPlatformBootstrapRequest(fleet.requests[0]);
18192
18420
  const firstConfig = readBootstrapConfig(first.platformConfigFile);
18193
18421
  if (firstConfig.kind !== "platform")
18194
18422
  throw new Error("remote platform fleet requires platform configs");
18423
+ if (mode === "status") {
18424
+ const result3 = await applyOperatorPlatformFleetBootstrap(fleet, mode);
18425
+ out.line(JSON.stringify(result3, null, 2));
18426
+ return result3.ok ? 0 : 1;
18427
+ }
18195
18428
  const secrets2 = mode === "apply" ? await promptPlatformBootstrapSecrets(firstConfig) : undefined;
18196
- const result2 = await applyOperatorPlatformFleetBootstrap(fleet, mode, { secrets: secrets2 });
18429
+ if (!secrets2?.cloudflareTunnelToken || !secrets2.cloudflareApiToken) {
18430
+ throw new Error("attended platform bootstrap requires both Cloudflare tokens");
18431
+ }
18432
+ const result2 = await applyAttendedPlatformBootstrap(fleet, cloudflare, fleet.cloudflareAcceptanceFile, secrets2);
18197
18433
  out.line(JSON.stringify(result2, null, 2));
18198
- return result2.ok ? 0 : 1;
18434
+ if (!result2.ok)
18435
+ return 1;
18436
+ out.line();
18437
+ out.step(`Initial custodian invitation: ${result2.inviteUrl}`);
18438
+ return 0;
18199
18439
  }
18200
18440
  if (operation === "platform" && args[1] === "remote") {
18201
18441
  const mode = args[2];
@@ -18232,11 +18472,14 @@ async function cmdBootstrap(options, args) {
18232
18472
  }
18233
18473
  if (operation === "metal" && args[1] === "remote") {
18234
18474
  const mode = args[2];
18235
- if (!mode || !["apply", "genesis", "rehearsal", "rehearsal-cleanup", "status"].includes(mode) || args[3] !== undefined) {
18236
- throw new Error("Usage: fz bootstrap metal remote <apply|genesis|rehearsal|rehearsal-cleanup|status> --bootstrap-config <owner-only-request.json> [--apply]");
18475
+ if (!mode || !["apply", "genesis", "rehearsal", "rehearsal-cleanup", "host-keys", "rehearsal-host-keys", "status"].includes(mode) || args[3] !== undefined) {
18476
+ throw new Error("Usage: fz bootstrap metal remote <apply|genesis|rehearsal|rehearsal-cleanup|host-keys|rehearsal-host-keys|status> --bootstrap-config <owner-only-request.json> [--output <owner-only-evidence.json>] [--apply]");
18237
18477
  }
18238
18478
  if (!options.bootstrapConfigPath)
18239
18479
  throw new Error("remote metal bootstrap requires --bootstrap-config");
18480
+ if ((mode === "host-keys" || mode === "rehearsal-host-keys") && !options.outputPath) {
18481
+ throw new Error(`${mode} requires --output <absolute-owner-only-evidence.json>`);
18482
+ }
18240
18483
  const request = readOperatorMetalBootstrapRequest(options.bootstrapConfigPath);
18241
18484
  const plan2 = planOperatorMetalBootstrap(request, mode);
18242
18485
  if (!options.apply) {
@@ -18244,7 +18487,15 @@ async function cmdBootstrap(options, args) {
18244
18487
  out.step("Review the pinned metal target and exact genesis nodes, then repeat with --apply to contact the host.");
18245
18488
  return 0;
18246
18489
  }
18247
- out.line(JSON.stringify(await applyOperatorMetalBootstrap(request, mode), null, 2));
18490
+ const result2 = await applyOperatorMetalBootstrap(request, mode);
18491
+ if (mode === "host-keys" || mode === "rehearsal-host-keys") {
18492
+ const evidence = JSON.parse(result2.output);
18493
+ const path = writeOperatorGuestHostKeyEvidence(options.outputPath, evidence, request);
18494
+ readOperatorGuestHostKeyEvidence(path, request);
18495
+ out.line(JSON.stringify({ plan: result2.plan, evidencePath: path, mode: "0600" }, null, 2));
18496
+ return 0;
18497
+ }
18498
+ out.line(JSON.stringify(result2, null, 2));
18248
18499
  return 0;
18249
18500
  }
18250
18501
  if (operation === "platform" && args[1] === "cloudflare") {
@@ -18332,7 +18583,7 @@ async function cmdBootstrap(options, args) {
18332
18583
  return 0;
18333
18584
  }
18334
18585
  if (!["platform", "repair"].includes(operation)) {
18335
- throw new Error("Usage: fz bootstrap config <metal|platform|cloudflare>|platform [bundle|fleet <apply|status>|remote <apply|status>|cloudflare [verify|finalize]]|metal [remote <apply|genesis|rehearsal|rehearsal-cleanup|status>]|status|repair [--bootstrap-config <path>] [--apply]");
18586
+ throw new Error("Usage: fz bootstrap config <metal|platform|cloudflare>|platform [bundle|fleet <apply|status>|remote <apply|status>|cloudflare [verify|finalize]]|metal [remote <apply|genesis|rehearsal|rehearsal-cleanup|host-keys|rehearsal-host-keys|status>]|status|repair [--bootstrap-config <path>] [--apply]");
18336
18587
  }
18337
18588
  const config = options.bootstrapConfigPath ? readBootstrapConfig(options.bootstrapConfigPath) : operation === "repair" ? (() => {
18338
18589
  throw new Error("repair requires --bootstrap-config so immutable coordinates are revalidated");
@@ -18911,14 +19162,16 @@ function usage() {
18911
19162
  Run typed bootstrap from the operator laptop through
18912
19163
  a pinned host; apply copies and verifies that bundle
18913
19164
  fz bootstrap platform fleet <apply|status>
18914
- Validate the exact three-node genesis identity, prompt
18915
- shared secrets once and run all pinned nodes concurrently
19165
+ One attended genesis operation: reconcile public
19166
+ hostnames/Tunnels, seal credentials, deploy the bundle,
19167
+ run all three nodes, verify ingress and print the invite
18916
19168
  fz bootstrap platform Install/repair a typed elastic platform compute
18917
19169
  fz bootstrap config <metal|platform|cloudflare>
18918
19170
  Write validated owner-only metal config+remote request,
18919
- Cloudflare file, or complete three-node platform request set
19171
+ low-level Cloudflare file, or the complete three-node
19172
+ platform + Cloudflare request set
18920
19173
  fz bootstrap platform cloudflare
18921
- Plan/apply attended Tunnel and DNS reconciliation
19174
+ Low-level resume/diagnostic Tunnel and DNS reconciliation
18922
19175
  using hidden management and KV/Worker runtime token prompts
18923
19176
  fz bootstrap platform cloudflare verify
18924
19177
  From the operator laptop, prove every ordinary public
@@ -18927,7 +19180,7 @@ function usage() {
18927
19180
  Persist secret-free public-node acceptance, then remove
18928
19181
  the completed non-secret checkpoint and node handoffs
18929
19182
  fz bootstrap metal Plan/install an identity-only physical provisioner
18930
- fz bootstrap metal remote <apply|genesis|rehearsal|rehearsal-cleanup|status>
19183
+ fz bootstrap metal remote <apply|genesis|rehearsal|rehearsal-cleanup|host-keys|rehearsal-host-keys|status>
18931
19184
  Install/bootstrap one pinned blank metal target, then
18932
19185
  provision the exact selected three-node genesis seed
18933
19186
  fz bootstrap status Verify persisted profile and supervised units
@@ -354,7 +354,7 @@ function systemdAgentEgressDirectives(loopbackTcpPorts = []) {
354
354
  }
355
355
 
356
356
  // src/version.ts
357
- var VERSION = "0.1.67";
357
+ var VERSION = "0.1.69";
358
358
 
359
359
  // src/otel-collector.ts
360
360
  var FORGEZERO_OTEL_COLLECTOR_UNIT = "forgezero-otel-collector.service";
@@ -26,9 +26,24 @@ export interface OperatorMetalBootstrapRequest {
26
26
  sshPublicKeyFiles: string[];
27
27
  };
28
28
  }
29
+ export interface OperatorGuestHostKeyEvidence {
30
+ format: 1;
31
+ kind: 'forgezero-operator-guest-host-keys';
32
+ metalHostKeySha256: string;
33
+ nodes: readonly {
34
+ name: string;
35
+ address: string;
36
+ hostKey: string;
37
+ hostKeySha256: string;
38
+ }[];
39
+ }
29
40
  export interface OperatorPlatformBootstrapFleetRequest {
30
41
  kind: 'platform-fleet-remote';
31
42
  requests: readonly [string, string, string];
43
+ /** Reviewed non-secret coordinates for the attended Tunnel/DNS/KV/DO phase. */
44
+ cloudflareConfigFile: string;
45
+ /** Secret-free evidence retained after checkpoint and host handoffs are consumed. */
46
+ cloudflareAcceptanceFile: string;
32
47
  }
33
48
  export interface OperatorPlatformBootstrapFleetPlan {
34
49
  kind: 'platform-fleet-remote';
@@ -66,7 +81,7 @@ export interface OperatorPlatformFleetCoordinates {
66
81
  }[];
67
82
  }
68
83
  export type OperatorPlatformBootstrapMode = 'apply' | 'status';
69
- export type OperatorMetalBootstrapMode = 'apply' | 'genesis' | 'rehearsal' | 'rehearsal-cleanup' | 'status';
84
+ export type OperatorMetalBootstrapMode = 'apply' | 'genesis' | 'rehearsal' | 'rehearsal-cleanup' | 'host-keys' | 'rehearsal-host-keys' | 'status';
70
85
  export interface OperatorPlatformBootstrapPlan {
71
86
  kind: 'platform-remote';
72
87
  mode: OperatorPlatformBootstrapMode;
@@ -112,14 +127,33 @@ export declare function createOperatorSshHop(input: Omit<OperatorSshHop, 'hostKe
112
127
  export declare function readOperatorPlatformBootstrapRequest(path: string): OperatorPlatformBootstrapRequest;
113
128
  export declare function writeOperatorPlatformBootstrapRequest(path: string, request: OperatorPlatformBootstrapRequest): string;
114
129
  export declare function readOperatorPlatformBootstrapFleetRequest(path: string): OperatorPlatformBootstrapFleetRequest;
115
- export declare function writeOperatorPlatformBootstrapFleetRequest(path: string, requestFiles: readonly [string, string, string]): string;
130
+ export declare function writeOperatorPlatformBootstrapFleetRequest(path: string, requestFiles: readonly [string, string, string], cloudflare: {
131
+ configFile: string;
132
+ acceptanceFile: string;
133
+ }): string;
116
134
  export declare function readOperatorMetalBootstrapRequest(path: string): OperatorMetalBootstrapRequest;
117
135
  export declare function writeOperatorMetalBootstrapRequest(path: string, request: OperatorMetalBootstrapRequest): string;
136
+ export declare function readOperatorGuestHostKeyEvidence(path: string, request?: OperatorMetalBootstrapRequest): OperatorGuestHostKeyEvidence;
137
+ export declare function writeOperatorGuestHostKeyEvidence(path: string, evidence: OperatorGuestHostKeyEvidence, request?: OperatorMetalBootstrapRequest): string;
118
138
  export declare function planOperatorMetalBootstrap(request: OperatorMetalBootstrapRequest, mode: OperatorMetalBootstrapMode): OperatorMetalBootstrapPlan;
119
139
  export declare function planOperatorPlatformBootstrap(request: OperatorPlatformBootstrapRequest, mode: OperatorPlatformBootstrapMode): OperatorPlatformBootstrapPlan;
120
140
  /** Exact non-secret coordinates derived from the same reviewed files bootstrap applies. */
121
141
  export declare function operatorPlatformFleetCoordinates(fleet: OperatorPlatformBootstrapFleetRequest): OperatorPlatformFleetCoordinates;
122
142
  export declare function planOperatorPlatformFleetBootstrap(fleet: OperatorPlatformBootstrapFleetRequest, mode: OperatorPlatformBootstrapMode): OperatorPlatformBootstrapFleetPlan;
143
+ export declare function collectOperatorGuestHostKeys(request: OperatorMetalBootstrapRequest, options?: OperatorPlatformBootstrapOptions, includeRehearsal?: boolean): Promise<OperatorGuestHostKeyEvidence>;
144
+ export interface OperatorGuestTransport {
145
+ run(nodeName: string, argv: readonly string[], options?: {
146
+ stdin?: string;
147
+ secret?: boolean;
148
+ allowFailure?: boolean;
149
+ }): Promise<OperatorCommandResult>;
150
+ copy(nodeName: string, localPath: string, remotePath: string): Promise<void>;
151
+ }
152
+ /**
153
+ * Execute a bounded callback through the same pinned Metal jump, operator
154
+ * public identity selector and reviewed guest host keys used by bootstrap.
155
+ */
156
+ export declare function withOperatorGuestTransport<T>(request: OperatorMetalBootstrapRequest, evidence: OperatorGuestHostKeyEvidence, callback: (transport: OperatorGuestTransport) => Promise<T>, options?: OperatorPlatformBootstrapOptions): Promise<T>;
123
157
  /**
124
158
  * Run one reviewed operator action. No private SSH key, credential value or
125
159
  * management token is placed in argv; OpenSSH selects the matching private key
@@ -1396,7 +1396,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
1396
1396
  var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
1397
1397
 
1398
1398
  // src/version.ts
1399
- var VERSION = "0.1.67";
1399
+ var VERSION = "0.1.69";
1400
1400
 
1401
1401
  // src/software.ts
1402
1402
  var PINNED_BUN_VERSION = "1.3.14";
@@ -4380,6 +4380,7 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
4380
4380
  }
4381
4381
  bindBootstrapIntent(host, config);
4382
4382
  const plan = planBootstrap(config, host.exists(STATE_PATH));
4383
+ await checked3(host, ["hostnamectl", "set-hostname", "--static", config.nodeHostname], "compute hostname");
4383
4384
  const alreadyEnrolled = host.exists("/var/lib/forgezero/enrolment.json");
4384
4385
  host.mkdir(CREDS, 448);
4385
4386
  host.mkdir("/var/lib/forgezero", 448);
@@ -5971,7 +5972,12 @@ function writeOperatorPlatformBootstrapRequest(path, request) {
5971
5972
  }
5972
5973
  function readOperatorPlatformBootstrapFleetRequest(path) {
5973
5974
  const value = JSON.parse(new TextDecoder().decode(ownerFile(path, REQUEST_LIMIT, "operator fleet request")));
5974
- const root = exactKeys3(value, ["kind", "requests"], "operator fleet request");
5975
+ const root = exactKeys3(value, [
5976
+ "kind",
5977
+ "requests",
5978
+ "cloudflareConfigFile",
5979
+ "cloudflareAcceptanceFile"
5980
+ ], "operator fleet request");
5975
5981
  if (root.kind !== "platform-fleet-remote")
5976
5982
  throw new Error("operator fleet kind must be platform-fleet-remote");
5977
5983
  if (!Array.isArray(root.requests) || root.requests.length !== 3 || root.requests.some((entry) => typeof entry !== "string")) {
@@ -5982,10 +5988,25 @@ function readOperatorPlatformBootstrapFleetRequest(path) {
5982
5988
  throw new Error("operator fleet request files must be unique");
5983
5989
  for (const request of requests)
5984
5990
  readOperatorPlatformBootstrapRequest(request);
5985
- return { kind: "platform-fleet-remote", requests };
5991
+ if (typeof root.cloudflareConfigFile !== "string" || typeof root.cloudflareAcceptanceFile !== "string") {
5992
+ throw new Error("operator fleet requires Cloudflare coordinates and an acceptance evidence path");
5993
+ }
5994
+ const cloudflareConfigFile = canonicalOutputPath(root.cloudflareConfigFile, "operator fleet Cloudflare config");
5995
+ const cloudflareAcceptanceFile = canonicalOutputPath(root.cloudflareAcceptanceFile, "operator fleet Cloudflare acceptance evidence");
5996
+ return {
5997
+ kind: "platform-fleet-remote",
5998
+ requests,
5999
+ cloudflareConfigFile,
6000
+ cloudflareAcceptanceFile
6001
+ };
5986
6002
  }
5987
- function writeOperatorPlatformBootstrapFleetRequest(path, requestFiles) {
5988
- const request = { kind: "platform-fleet-remote", requests: requestFiles };
6003
+ function writeOperatorPlatformBootstrapFleetRequest(path, requestFiles, cloudflare) {
6004
+ const request = {
6005
+ kind: "platform-fleet-remote",
6006
+ requests: requestFiles,
6007
+ cloudflareConfigFile: canonicalOutputPath(cloudflare.configFile, "operator fleet Cloudflare config"),
6008
+ cloudflareAcceptanceFile: canonicalOutputPath(cloudflare.acceptanceFile, "operator fleet Cloudflare acceptance evidence")
6009
+ };
5989
6010
  if (new Set(requestFiles).size !== 3)
5990
6011
  throw new Error("operator fleet request files must be unique");
5991
6012
  for (const file of requestFiles)
@@ -6079,6 +6100,48 @@ function writeOperatorMetalBootstrapRequest(path, request) {
6079
6100
  validateMetalRequestValue(request);
6080
6101
  return writeOwnerJson2(path, request, "operator metal request");
6081
6102
  }
6103
+ var validateGuestHostKeyEvidenceValue = (value, request) => {
6104
+ const root = exactKeys3(value, ["format", "kind", "metalHostKeySha256", "nodes"], "operator guest host-key evidence");
6105
+ if (root.format !== 1 || root.kind !== "forgezero-operator-guest-host-keys" || typeof root.metalHostKeySha256 !== "string" || !Array.isArray(root.nodes)) {
6106
+ throw new Error("operator guest host-key evidence is malformed");
6107
+ }
6108
+ const nodes = root.nodes.map((value2, index) => {
6109
+ const row = exactKeys3(value2, ["name", "address", "hostKey", "hostKeySha256"], `operator guest host key ${index + 1}`);
6110
+ if (typeof row.name !== "string" || typeof row.address !== "string") {
6111
+ throw new Error("operator guest host-key identity is malformed");
6112
+ }
6113
+ const hop = validateHop({
6114
+ address: row.address,
6115
+ port: 22,
6116
+ user: "forgezero",
6117
+ hostKey: row.hostKey,
6118
+ hostKeySha256: row.hostKeySha256
6119
+ }, `operator guest host key ${row.name}`);
6120
+ return { name: row.name, address: hop.address, hostKey: hop.hostKey, hostKeySha256: hop.hostKeySha256 };
6121
+ });
6122
+ if (new Set(nodes.map(({ name }) => name)).size !== nodes.length || new Set(nodes.map(({ address }) => address)).size !== nodes.length) {
6123
+ throw new Error("operator guest host-key identities must be unique");
6124
+ }
6125
+ if (request) {
6126
+ const expected = nodes.length === request.genesis.nodes.length ? [...request.genesis.nodes] : [...request.genesis.nodes, ...request.genesis.rehearsalNode ? [request.genesis.rehearsalNode] : []];
6127
+ if (root.metalHostKeySha256 !== request.target.hostKeySha256 || ![request.genesis.nodes.length, request.genesis.nodes.length + (request.genesis.rehearsalNode ? 1 : 0)].includes(nodes.length) || nodes.length !== expected.length || expected.some((guest, index) => nodes[index]?.name !== guest.name || nodes[index]?.address !== guest.address)) {
6128
+ throw new Error("operator guest host-key evidence does not match the reviewed Metal request");
6129
+ }
6130
+ }
6131
+ return {
6132
+ format: 1,
6133
+ kind: "forgezero-operator-guest-host-keys",
6134
+ metalHostKeySha256: root.metalHostKeySha256,
6135
+ nodes
6136
+ };
6137
+ };
6138
+ function readOperatorGuestHostKeyEvidence(path, request) {
6139
+ const value = JSON.parse(new TextDecoder().decode(ownerFile(path, REQUEST_LIMIT, "operator guest host-key evidence")));
6140
+ return validateGuestHostKeyEvidenceValue(value, request);
6141
+ }
6142
+ function writeOperatorGuestHostKeyEvidence(path, evidence, request) {
6143
+ return writeOwnerJson2(path, validateGuestHostKeyEvidenceValue(evidence, request), "operator guest host-key evidence");
6144
+ }
6082
6145
  function planOperatorMetalBootstrap(request, mode) {
6083
6146
  const config = readMetalBootstrapConfig(request.metalConfigFile);
6084
6147
  const keys = request.genesis.sshPublicKeyFiles.map((path) => publicIdentity(path));
@@ -6086,19 +6149,20 @@ function planOperatorMetalBootstrap(request, mode) {
6086
6149
  throw new Error("the reviewed metal request has no rehearsal node");
6087
6150
  }
6088
6151
  const claims = mode === "rehearsal" || mode === "rehearsal-cleanup" ? mode === "rehearsal-cleanup" ? platformCommunityRehearsalDeletionClaims(request.genesis.nodes, request.genesis.rehearsalNode) : platformCommunityRehearsalClaims(request.genesis.nodes, request.genesis.rehearsalNode, keys) : platformGenesisClaims(request.genesis.nodes, keys);
6152
+ const genesisNodes = mode === "host-keys" ? request.genesis.nodes.map(({ name }) => name) : mode === "rehearsal-host-keys" ? guestRows(request).map(({ name }) => name) : claims.map((claim) => claim.spec.guestName).filter((name) => Boolean(name));
6089
6153
  return {
6090
6154
  kind: "metal-remote",
6091
6155
  mode,
6092
- mutation: mode !== "status",
6156
+ mutation: !["status", "host-keys", "rehearsal-host-keys"].includes(mode),
6093
6157
  target: { address: request.target.address, port: request.target.port, user: request.target.user },
6094
- steps: mode === "status" ? ["verify pinned SSH transport", "run typed fz bootstrap status"] : mode === "genesis" || mode === "rehearsal" || mode === "rehearsal-cleanup" ? ["verify pinned SSH transport", mode === "rehearsal" ? "send the three reviewed seed claims plus the reviewed fourth Community rehearsal claim to the constrained Metal helper" : mode === "rehearsal-cleanup" ? "delete the exact four reviewed disposable guests and their owned disks, units, seed data and inventory" : "send the three reviewed platform-genesis claims to the constrained Metal helper"] : [
6158
+ steps: mode === "status" ? ["verify pinned SSH transport", "run typed fz bootstrap status"] : mode === "host-keys" || mode === "rehearsal-host-keys" ? ["verify pinned SSH transport", "collect exact Ed25519 host keys for the reviewed guest addresses"] : mode === "genesis" || mode === "rehearsal" || mode === "rehearsal-cleanup" ? ["verify pinned SSH transport", mode === "rehearsal" ? "send the three reviewed seed claims plus the reviewed fourth Community rehearsal claim to the constrained Metal helper" : mode === "rehearsal-cleanup" ? "delete the exact four reviewed disposable guests and their owned disks, units, seed data and inventory" : "send the three reviewed platform-genesis claims to the constrained Metal helper"] : [
6095
6159
  "verify pinned SSH transport and caller-approved SSH agent",
6096
6160
  "copy pinned Bun and packaged fz artifacts to private staging",
6097
6161
  "stage the strict owner-only metal config and optional seed handoff",
6098
6162
  "run typed fz bootstrap metal --apply",
6099
6163
  "remove transient local and remote staging data"
6100
6164
  ],
6101
- genesisNodes: claims.map((claim) => claim.spec.guestName).filter((name) => Boolean(name)),
6165
+ genesisNodes,
6102
6166
  secretInputs: config.agentSeedFile ? ["metal-agent-seed"] : []
6103
6167
  };
6104
6168
  }
@@ -6335,6 +6399,116 @@ var copy = async (exec, request, knownHosts, local, remotePath, secret = false)
6335
6399
  `${destination2(request.target)}:${remotePath}`
6336
6400
  ], "secure copy", { secret });
6337
6401
  };
6402
+ var guestRows = (request) => [
6403
+ ...request.genesis.nodes,
6404
+ ...request.genesis.rehearsalNode ? [request.genesis.rehearsalNode] : []
6405
+ ];
6406
+ var parsedScannedHostKey = (output, address) => {
6407
+ const keys = output.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith(`${address} ssh-ed25519 `)).map((line) => line.split(/\s+/).slice(1, 3).join(" "));
6408
+ const unique = [...new Set(keys)];
6409
+ if (unique.length !== 1 || !/^ssh-ed25519 [A-Za-z0-9+/]+={0,3}$/.test(unique[0])) {
6410
+ throw new Error(`guest ${address} did not return one exact Ed25519 host key`);
6411
+ }
6412
+ return unique[0];
6413
+ };
6414
+ async function collectOperatorGuestHostKeys(request, options = {}, includeRehearsal = false) {
6415
+ publicIdentity(request.target.identityPublicKeyFile);
6416
+ socketPath(request.target.agentSocket);
6417
+ const exec = options.exec ?? defaultExec3;
6418
+ const directory = mkdtempSync(join6(tmpdir(), "forgezero-operator-host-keys-"));
6419
+ try {
6420
+ const knownHosts = writeKnownHosts(request, directory);
6421
+ const nodes = [];
6422
+ for (const guest of includeRehearsal ? guestRows(request) : request.genesis.nodes) {
6423
+ const output = await remote(exec, request, knownHosts, [
6424
+ "/usr/bin/ssh-keyscan",
6425
+ "-T",
6426
+ "10",
6427
+ "-t",
6428
+ "ed25519",
6429
+ guest.address
6430
+ ], `collect ${guest.name} SSH host key`);
6431
+ const hostKey = parsedScannedHostKey(output, guest.address);
6432
+ nodes.push({
6433
+ name: guest.name,
6434
+ address: guest.address,
6435
+ hostKey,
6436
+ hostKeySha256: fingerprint(hostKey)
6437
+ });
6438
+ }
6439
+ return validateGuestHostKeyEvidenceValue({
6440
+ format: 1,
6441
+ kind: "forgezero-operator-guest-host-keys",
6442
+ metalHostKeySha256: request.target.hostKeySha256,
6443
+ nodes
6444
+ }, request);
6445
+ } finally {
6446
+ rmSync6(directory, { recursive: true, force: true });
6447
+ }
6448
+ }
6449
+ async function withOperatorGuestTransport(request, evidence, callback, options = {}) {
6450
+ const reviewed = validateGuestHostKeyEvidenceValue(evidence, request);
6451
+ publicIdentity(request.target.identityPublicKeyFile);
6452
+ socketPath(request.target.agentSocket);
6453
+ const exec = options.exec ?? defaultExec3;
6454
+ const directory = mkdtempSync(join6(tmpdir(), "forgezero-operator-guest-transport-"));
6455
+ try {
6456
+ const byName = new Map(reviewed.nodes.map((node) => [node.name, node]));
6457
+ const requests = new Map;
6458
+ const knownHosts = new Map;
6459
+ for (const node of reviewed.nodes) {
6460
+ const nodeDirectory = join6(directory, node.name);
6461
+ mkdirSync8(nodeDirectory, { mode: 448 });
6462
+ const guestRequest = {
6463
+ target: {
6464
+ address: node.address,
6465
+ port: 22,
6466
+ user: "forgezero",
6467
+ hostKey: node.hostKey,
6468
+ hostKeySha256: node.hostKeySha256,
6469
+ identityPublicKeyFile: request.target.identityPublicKeyFile,
6470
+ agentSocket: request.target.agentSocket,
6471
+ jump: {
6472
+ address: request.target.address,
6473
+ port: request.target.port,
6474
+ user: request.target.user,
6475
+ hostKey: request.target.hostKey,
6476
+ hostKeySha256: request.target.hostKeySha256
6477
+ }
6478
+ }
6479
+ };
6480
+ requests.set(node.name, guestRequest);
6481
+ knownHosts.set(node.name, writeKnownHosts(guestRequest, nodeDirectory));
6482
+ }
6483
+ const transport = {
6484
+ async run(nodeName, argv, runOptions = {}) {
6485
+ if (!byName.has(nodeName))
6486
+ throw new Error(`guest transport node ${nodeName} is not reviewed`);
6487
+ argv.forEach(safeRemoteArg);
6488
+ const nodeRequest = requests.get(nodeName);
6489
+ const result = await exec([
6490
+ "ssh",
6491
+ ...sshOptions(nodeRequest, knownHosts.get(nodeName)),
6492
+ destination2(nodeRequest.target),
6493
+ "--",
6494
+ ...argv
6495
+ ], { stdin: runOptions.stdin, secret: runOptions.secret });
6496
+ if (result.exitCode !== 0 && !runOptions.allowFailure) {
6497
+ throw new Error(`${nodeName} remote operation failed${result.output ? `: ${result.output.trim()}` : ""}`);
6498
+ }
6499
+ return result;
6500
+ },
6501
+ async copy(nodeName, localPath, remotePath) {
6502
+ if (!byName.has(nodeName))
6503
+ throw new Error(`guest transport node ${nodeName} is not reviewed`);
6504
+ await copy(exec, requests.get(nodeName), knownHosts.get(nodeName), localPath, remotePath);
6505
+ }
6506
+ };
6507
+ return await callback(transport);
6508
+ } finally {
6509
+ rmSync6(directory, { recursive: true, force: true });
6510
+ }
6511
+ }
6338
6512
  async function stageConfig(config, directory) {
6339
6513
  const rewritten = structuredClone(config);
6340
6514
  const staged = [];
@@ -6546,6 +6720,12 @@ async function applyOperatorMetalBootstrap(request, mode, options = {}) {
6546
6720
  const output2 = await remote(exec, request, knownHosts, ["/usr/bin/sudo", "-n", "/usr/local/bin/fz", "bootstrap", "status"], "remote metal status");
6547
6721
  return { plan, output: output2 };
6548
6722
  }
6723
+ if (mode === "host-keys" || mode === "rehearsal-host-keys") {
6724
+ return {
6725
+ plan,
6726
+ output: JSON.stringify(await collectOperatorGuestHostKeys(request, { ...options, exec }, mode === "rehearsal-host-keys"))
6727
+ };
6728
+ }
6549
6729
  if (mode === "genesis" || mode === "rehearsal" || mode === "rehearsal-cleanup") {
6550
6730
  const keys = request.genesis.sshPublicKeyFiles.map((path) => publicIdentity(path));
6551
6731
  if ((mode === "rehearsal" || mode === "rehearsal-cleanup") && !request.genesis.rehearsalNode) {
@@ -6592,15 +6772,19 @@ export {
6592
6772
  writeOperatorPlatformBootstrapRequest,
6593
6773
  writeOperatorPlatformBootstrapFleetRequest,
6594
6774
  writeOperatorMetalBootstrapRequest,
6775
+ writeOperatorGuestHostKeyEvidence,
6776
+ withOperatorGuestTransport,
6595
6777
  verifyOperatorPlatformFleet,
6596
6778
  readOperatorPlatformBootstrapRequest,
6597
6779
  readOperatorPlatformBootstrapFleetRequest,
6598
6780
  readOperatorMetalBootstrapRequest,
6781
+ readOperatorGuestHostKeyEvidence,
6599
6782
  planOperatorPlatformFleetBootstrap,
6600
6783
  planOperatorPlatformBootstrap,
6601
6784
  planOperatorMetalBootstrap,
6602
6785
  operatorPlatformFleetCoordinates,
6603
6786
  createOperatorSshHop,
6787
+ collectOperatorGuestHostKeys,
6604
6788
  applyOperatorPlatformFleetBootstrap,
6605
6789
  applyOperatorPlatformBootstrap,
6606
6790
  applyOperatorMetalBootstrap
@@ -2125,7 +2125,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
2125
2125
  }
2126
2126
 
2127
2127
  // src/version.ts
2128
- var VERSION3 = "0.1.67";
2128
+ var VERSION3 = "0.1.69";
2129
2129
 
2130
2130
  // src/egress-policy.ts
2131
2131
  import { realpathSync as realpathSync3 } from "node:fs";
package/dist/provision.js CHANGED
@@ -2125,7 +2125,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
2125
2125
  }
2126
2126
 
2127
2127
  // src/version.ts
2128
- var VERSION3 = "0.1.67";
2128
+ var VERSION3 = "0.1.69";
2129
2129
 
2130
2130
  // src/egress-policy.ts
2131
2131
  import { realpathSync as realpathSync3 } from "node:fs";
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.67";
2
+ export declare const VERSION = "0.1.69";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgezero/agent",
3
- "version": "0.1.67",
3
+ "version": "0.1.69",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "check": "tsc --noEmit",