@forgezero/agent 0.1.67 → 0.1.68

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.68";
753
753
 
754
754
  // src/agent-heartbeat.ts
755
755
  var unquote = (value) => value.replace(/^['"]|['"]$/g, "");
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.68";
1400
1400
 
1401
1401
  // src/software.ts
1402
1402
  var PINNED_BUN_VERSION = "1.3.14";
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.68";
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.68";
4833
4833
 
4834
4834
  // src/software.ts
4835
4835
  var PINNED_BUN_VERSION = "1.3.14";
@@ -16237,6 +16237,48 @@ function writeOperatorMetalBootstrapRequest(path, request) {
16237
16237
  validateMetalRequestValue(request);
16238
16238
  return writeOwnerJson2(path, request, "operator metal request");
16239
16239
  }
16240
+ var validateGuestHostKeyEvidenceValue = (value, request) => {
16241
+ const root = exactKeys5(value, ["format", "kind", "metalHostKeySha256", "nodes"], "operator guest host-key evidence");
16242
+ if (root.format !== 1 || root.kind !== "forgezero-operator-guest-host-keys" || typeof root.metalHostKeySha256 !== "string" || !Array.isArray(root.nodes)) {
16243
+ throw new Error("operator guest host-key evidence is malformed");
16244
+ }
16245
+ const nodes = root.nodes.map((value2, index) => {
16246
+ const row2 = exactKeys5(value2, ["name", "address", "hostKey", "hostKeySha256"], `operator guest host key ${index + 1}`);
16247
+ if (typeof row2.name !== "string" || typeof row2.address !== "string") {
16248
+ throw new Error("operator guest host-key identity is malformed");
16249
+ }
16250
+ const hop = validateHop({
16251
+ address: row2.address,
16252
+ port: 22,
16253
+ user: "forgezero",
16254
+ hostKey: row2.hostKey,
16255
+ hostKeySha256: row2.hostKeySha256
16256
+ }, `operator guest host key ${row2.name}`);
16257
+ return { name: row2.name, address: hop.address, hostKey: hop.hostKey, hostKeySha256: hop.hostKeySha256 };
16258
+ });
16259
+ if (new Set(nodes.map(({ name }) => name)).size !== nodes.length || new Set(nodes.map(({ address }) => address)).size !== nodes.length) {
16260
+ throw new Error("operator guest host-key identities must be unique");
16261
+ }
16262
+ if (request) {
16263
+ const expected = nodes.length === request.genesis.nodes.length ? [...request.genesis.nodes] : [...request.genesis.nodes, ...request.genesis.rehearsalNode ? [request.genesis.rehearsalNode] : []];
16264
+ 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)) {
16265
+ throw new Error("operator guest host-key evidence does not match the reviewed Metal request");
16266
+ }
16267
+ }
16268
+ return {
16269
+ format: 1,
16270
+ kind: "forgezero-operator-guest-host-keys",
16271
+ metalHostKeySha256: root.metalHostKeySha256,
16272
+ nodes
16273
+ };
16274
+ };
16275
+ function readOperatorGuestHostKeyEvidence(path, request) {
16276
+ const value = JSON.parse(new TextDecoder().decode(ownerFile(path, REQUEST_LIMIT, "operator guest host-key evidence")));
16277
+ return validateGuestHostKeyEvidenceValue(value, request);
16278
+ }
16279
+ function writeOperatorGuestHostKeyEvidence(path, evidence, request) {
16280
+ return writeOwnerJson2(path, validateGuestHostKeyEvidenceValue(evidence, request), "operator guest host-key evidence");
16281
+ }
16240
16282
  function planOperatorMetalBootstrap(request, mode) {
16241
16283
  const config = readMetalBootstrapConfig(request.metalConfigFile);
16242
16284
  const keys = request.genesis.sshPublicKeyFiles.map((path) => publicIdentity(path));
@@ -16244,19 +16286,20 @@ function planOperatorMetalBootstrap(request, mode) {
16244
16286
  throw new Error("the reviewed metal request has no rehearsal node");
16245
16287
  }
16246
16288
  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);
16289
+ 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
16290
  return {
16248
16291
  kind: "metal-remote",
16249
16292
  mode,
16250
- mutation: mode !== "status",
16293
+ mutation: !["status", "host-keys", "rehearsal-host-keys"].includes(mode),
16251
16294
  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"] : [
16295
+ 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
16296
  "verify pinned SSH transport and caller-approved SSH agent",
16254
16297
  "copy pinned Bun and packaged fz artifacts to private staging",
16255
16298
  "stage the strict owner-only metal config and optional seed handoff",
16256
16299
  "run typed fz bootstrap metal --apply",
16257
16300
  "remove transient local and remote staging data"
16258
16301
  ],
16259
- genesisNodes: claims.map((claim) => claim.spec.guestName).filter((name) => Boolean(name)),
16302
+ genesisNodes,
16260
16303
  secretInputs: config.agentSeedFile ? ["metal-agent-seed"] : []
16261
16304
  };
16262
16305
  }
@@ -16479,6 +16522,53 @@ var copy = async (exec, request, knownHosts, local, remotePath, secret = false)
16479
16522
  `${destination2(request.target)}:${remotePath}`
16480
16523
  ], "secure copy", { secret });
16481
16524
  };
16525
+ var guestRows = (request) => [
16526
+ ...request.genesis.nodes,
16527
+ ...request.genesis.rehearsalNode ? [request.genesis.rehearsalNode] : []
16528
+ ];
16529
+ var parsedScannedHostKey = (output, address) => {
16530
+ 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(" "));
16531
+ const unique = [...new Set(keys)];
16532
+ if (unique.length !== 1 || !/^ssh-ed25519 [A-Za-z0-9+/]+={0,3}$/.test(unique[0])) {
16533
+ throw new Error(`guest ${address} did not return one exact Ed25519 host key`);
16534
+ }
16535
+ return unique[0];
16536
+ };
16537
+ async function collectOperatorGuestHostKeys(request, options = {}, includeRehearsal = false) {
16538
+ publicIdentity(request.target.identityPublicKeyFile);
16539
+ socketPath(request.target.agentSocket);
16540
+ const exec = options.exec ?? defaultExec3;
16541
+ const directory = mkdtempSync(join10(tmpdir(), "forgezero-operator-host-keys-"));
16542
+ try {
16543
+ const knownHosts = writeKnownHosts(request, directory);
16544
+ const nodes = [];
16545
+ for (const guest of includeRehearsal ? guestRows(request) : request.genesis.nodes) {
16546
+ const output = await remote(exec, request, knownHosts, [
16547
+ "/usr/bin/ssh-keyscan",
16548
+ "-T",
16549
+ "10",
16550
+ "-t",
16551
+ "ed25519",
16552
+ guest.address
16553
+ ], `collect ${guest.name} SSH host key`);
16554
+ const hostKey = parsedScannedHostKey(output, guest.address);
16555
+ nodes.push({
16556
+ name: guest.name,
16557
+ address: guest.address,
16558
+ hostKey,
16559
+ hostKeySha256: fingerprint(hostKey)
16560
+ });
16561
+ }
16562
+ return validateGuestHostKeyEvidenceValue({
16563
+ format: 1,
16564
+ kind: "forgezero-operator-guest-host-keys",
16565
+ metalHostKeySha256: request.target.hostKeySha256,
16566
+ nodes
16567
+ }, request);
16568
+ } finally {
16569
+ rmSync7(directory, { recursive: true, force: true });
16570
+ }
16571
+ }
16482
16572
  async function stageConfig(config, directory) {
16483
16573
  const rewritten = structuredClone(config);
16484
16574
  const staged = [];
@@ -16658,6 +16748,12 @@ async function applyOperatorMetalBootstrap(request, mode, options = {}) {
16658
16748
  const output2 = await remote(exec, request, knownHosts, ["/usr/bin/sudo", "-n", "/usr/local/bin/fz", "bootstrap", "status"], "remote metal status");
16659
16749
  return { plan, output: output2 };
16660
16750
  }
16751
+ if (mode === "host-keys" || mode === "rehearsal-host-keys") {
16752
+ return {
16753
+ plan,
16754
+ output: JSON.stringify(await collectOperatorGuestHostKeys(request, { ...options, exec }, mode === "rehearsal-host-keys"))
16755
+ };
16756
+ }
16661
16757
  if (mode === "genesis" || mode === "rehearsal" || mode === "rehearsal-cleanup") {
16662
16758
  const keys = request.genesis.sshPublicKeyFiles.map((path) => publicIdentity(path));
16663
16759
  if ((mode === "rehearsal" || mode === "rehearsal-cleanup") && !request.genesis.rehearsalNode) {
@@ -18002,6 +18098,8 @@ function writePlatformGenesisFleet(directory) {
18002
18098
  const output = genesisOutputDirectory(directory);
18003
18099
  const metalRequestFile = bootstrapAnswer("Owner-only metal remote request containing the reviewed genesis guests");
18004
18100
  const metalRequest = readOperatorMetalBootstrapRequest(metalRequestFile);
18101
+ const guestHostKeysFile = bootstrapAnswer("Owner-only guest host-key evidence file");
18102
+ const guestHostKeys = readOperatorGuestHostKeyEvidence(guestHostKeysFile, metalRequest);
18005
18103
  const fleet = metalRequest.genesis.nodes;
18006
18104
  const metalHostname = readMetalBootstrapConfig(metalRequest.metalConfigFile).metalHostname;
18007
18105
  const template = interactiveBootstrap("platform", fleet);
@@ -18022,16 +18120,28 @@ function writePlatformGenesisFleet(directory) {
18022
18120
  writeBootstrapConfig(path, config);
18023
18121
  return path;
18024
18122
  });
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
18123
  const requests = configs.map((platformConfigFile, index) => {
18029
18124
  const guest = fleet[index];
18030
- const target = interactiveOperatorHop(`${guest.name} compute`, { address: guest.address, user: "forgezero" });
18125
+ const host = guestHostKeys.nodes.find(({ name }) => name === guest.name);
18031
18126
  return writeOperatorPlatformBootstrapRequest(join12(output, `${guest.name}-remote.json`), {
18032
18127
  kind: "platform-remote",
18033
18128
  platformConfigFile,
18034
- target: { ...target, ...identity, ...jump ? { jump } : {} }
18129
+ target: {
18130
+ address: guest.address,
18131
+ port: 22,
18132
+ user: "forgezero",
18133
+ hostKey: host.hostKey,
18134
+ hostKeySha256: host.hostKeySha256,
18135
+ identityPublicKeyFile: metalRequest.target.identityPublicKeyFile,
18136
+ agentSocket: metalRequest.target.agentSocket,
18137
+ jump: {
18138
+ address: metalRequest.target.address,
18139
+ port: metalRequest.target.port,
18140
+ user: metalRequest.target.user,
18141
+ hostKey: metalRequest.target.hostKey,
18142
+ hostKeySha256: metalRequest.target.hostKeySha256
18143
+ }
18144
+ }
18035
18145
  });
18036
18146
  });
18037
18147
  const fleetRequest = writeOperatorPlatformBootstrapFleetRequest(join12(output, "platform-fleet-remote.json"), requests);
@@ -18232,11 +18342,14 @@ async function cmdBootstrap(options, args) {
18232
18342
  }
18233
18343
  if (operation === "metal" && args[1] === "remote") {
18234
18344
  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]");
18345
+ if (!mode || !["apply", "genesis", "rehearsal", "rehearsal-cleanup", "host-keys", "rehearsal-host-keys", "status"].includes(mode) || args[3] !== undefined) {
18346
+ 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
18347
  }
18238
18348
  if (!options.bootstrapConfigPath)
18239
18349
  throw new Error("remote metal bootstrap requires --bootstrap-config");
18350
+ if ((mode === "host-keys" || mode === "rehearsal-host-keys") && !options.outputPath) {
18351
+ throw new Error(`${mode} requires --output <absolute-owner-only-evidence.json>`);
18352
+ }
18240
18353
  const request = readOperatorMetalBootstrapRequest(options.bootstrapConfigPath);
18241
18354
  const plan2 = planOperatorMetalBootstrap(request, mode);
18242
18355
  if (!options.apply) {
@@ -18244,7 +18357,15 @@ async function cmdBootstrap(options, args) {
18244
18357
  out.step("Review the pinned metal target and exact genesis nodes, then repeat with --apply to contact the host.");
18245
18358
  return 0;
18246
18359
  }
18247
- out.line(JSON.stringify(await applyOperatorMetalBootstrap(request, mode), null, 2));
18360
+ const result2 = await applyOperatorMetalBootstrap(request, mode);
18361
+ if (mode === "host-keys" || mode === "rehearsal-host-keys") {
18362
+ const evidence = JSON.parse(result2.output);
18363
+ const path = writeOperatorGuestHostKeyEvidence(options.outputPath, evidence, request);
18364
+ readOperatorGuestHostKeyEvidence(path, request);
18365
+ out.line(JSON.stringify({ plan: result2.plan, evidencePath: path, mode: "0600" }, null, 2));
18366
+ return 0;
18367
+ }
18368
+ out.line(JSON.stringify(result2, null, 2));
18248
18369
  return 0;
18249
18370
  }
18250
18371
  if (operation === "platform" && args[1] === "cloudflare") {
@@ -18332,7 +18453,7 @@ async function cmdBootstrap(options, args) {
18332
18453
  return 0;
18333
18454
  }
18334
18455
  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]");
18456
+ 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
18457
  }
18337
18458
  const config = options.bootstrapConfigPath ? readBootstrapConfig(options.bootstrapConfigPath) : operation === "repair" ? (() => {
18338
18459
  throw new Error("repair requires --bootstrap-config so immutable coordinates are revalidated");
@@ -18927,7 +19048,7 @@ function usage() {
18927
19048
  Persist secret-free public-node acceptance, then remove
18928
19049
  the completed non-secret checkpoint and node handoffs
18929
19050
  fz bootstrap metal Plan/install an identity-only physical provisioner
18930
- fz bootstrap metal remote <apply|genesis|rehearsal|rehearsal-cleanup|status>
19051
+ fz bootstrap metal remote <apply|genesis|rehearsal|rehearsal-cleanup|host-keys|rehearsal-host-keys|status>
18931
19052
  Install/bootstrap one pinned blank metal target, then
18932
19053
  provision the exact selected three-node genesis seed
18933
19054
  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.68";
358
358
 
359
359
  // src/otel-collector.ts
360
360
  var FORGEZERO_OTEL_COLLECTOR_UNIT = "forgezero-otel-collector.service";
@@ -26,6 +26,17 @@ 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];
@@ -66,7 +77,7 @@ export interface OperatorPlatformFleetCoordinates {
66
77
  }[];
67
78
  }
68
79
  export type OperatorPlatformBootstrapMode = 'apply' | 'status';
69
- export type OperatorMetalBootstrapMode = 'apply' | 'genesis' | 'rehearsal' | 'rehearsal-cleanup' | 'status';
80
+ export type OperatorMetalBootstrapMode = 'apply' | 'genesis' | 'rehearsal' | 'rehearsal-cleanup' | 'host-keys' | 'rehearsal-host-keys' | 'status';
70
81
  export interface OperatorPlatformBootstrapPlan {
71
82
  kind: 'platform-remote';
72
83
  mode: OperatorPlatformBootstrapMode;
@@ -115,11 +126,27 @@ export declare function readOperatorPlatformBootstrapFleetRequest(path: string):
115
126
  export declare function writeOperatorPlatformBootstrapFleetRequest(path: string, requestFiles: readonly [string, string, string]): string;
116
127
  export declare function readOperatorMetalBootstrapRequest(path: string): OperatorMetalBootstrapRequest;
117
128
  export declare function writeOperatorMetalBootstrapRequest(path: string, request: OperatorMetalBootstrapRequest): string;
129
+ export declare function readOperatorGuestHostKeyEvidence(path: string, request?: OperatorMetalBootstrapRequest): OperatorGuestHostKeyEvidence;
130
+ export declare function writeOperatorGuestHostKeyEvidence(path: string, evidence: OperatorGuestHostKeyEvidence, request?: OperatorMetalBootstrapRequest): string;
118
131
  export declare function planOperatorMetalBootstrap(request: OperatorMetalBootstrapRequest, mode: OperatorMetalBootstrapMode): OperatorMetalBootstrapPlan;
119
132
  export declare function planOperatorPlatformBootstrap(request: OperatorPlatformBootstrapRequest, mode: OperatorPlatformBootstrapMode): OperatorPlatformBootstrapPlan;
120
133
  /** Exact non-secret coordinates derived from the same reviewed files bootstrap applies. */
121
134
  export declare function operatorPlatformFleetCoordinates(fleet: OperatorPlatformBootstrapFleetRequest): OperatorPlatformFleetCoordinates;
122
135
  export declare function planOperatorPlatformFleetBootstrap(fleet: OperatorPlatformBootstrapFleetRequest, mode: OperatorPlatformBootstrapMode): OperatorPlatformBootstrapFleetPlan;
136
+ export declare function collectOperatorGuestHostKeys(request: OperatorMetalBootstrapRequest, options?: OperatorPlatformBootstrapOptions, includeRehearsal?: boolean): Promise<OperatorGuestHostKeyEvidence>;
137
+ export interface OperatorGuestTransport {
138
+ run(nodeName: string, argv: readonly string[], options?: {
139
+ stdin?: string;
140
+ secret?: boolean;
141
+ allowFailure?: boolean;
142
+ }): Promise<OperatorCommandResult>;
143
+ copy(nodeName: string, localPath: string, remotePath: string): Promise<void>;
144
+ }
145
+ /**
146
+ * Execute a bounded callback through the same pinned Metal jump, operator
147
+ * public identity selector and reviewed guest host keys used by bootstrap.
148
+ */
149
+ export declare function withOperatorGuestTransport<T>(request: OperatorMetalBootstrapRequest, evidence: OperatorGuestHostKeyEvidence, callback: (transport: OperatorGuestTransport) => Promise<T>, options?: OperatorPlatformBootstrapOptions): Promise<T>;
123
150
  /**
124
151
  * Run one reviewed operator action. No private SSH key, credential value or
125
152
  * 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.68";
1400
1400
 
1401
1401
  // src/software.ts
1402
1402
  var PINNED_BUN_VERSION = "1.3.14";
@@ -6079,6 +6079,48 @@ function writeOperatorMetalBootstrapRequest(path, request) {
6079
6079
  validateMetalRequestValue(request);
6080
6080
  return writeOwnerJson2(path, request, "operator metal request");
6081
6081
  }
6082
+ var validateGuestHostKeyEvidenceValue = (value, request) => {
6083
+ const root = exactKeys3(value, ["format", "kind", "metalHostKeySha256", "nodes"], "operator guest host-key evidence");
6084
+ if (root.format !== 1 || root.kind !== "forgezero-operator-guest-host-keys" || typeof root.metalHostKeySha256 !== "string" || !Array.isArray(root.nodes)) {
6085
+ throw new Error("operator guest host-key evidence is malformed");
6086
+ }
6087
+ const nodes = root.nodes.map((value2, index) => {
6088
+ const row = exactKeys3(value2, ["name", "address", "hostKey", "hostKeySha256"], `operator guest host key ${index + 1}`);
6089
+ if (typeof row.name !== "string" || typeof row.address !== "string") {
6090
+ throw new Error("operator guest host-key identity is malformed");
6091
+ }
6092
+ const hop = validateHop({
6093
+ address: row.address,
6094
+ port: 22,
6095
+ user: "forgezero",
6096
+ hostKey: row.hostKey,
6097
+ hostKeySha256: row.hostKeySha256
6098
+ }, `operator guest host key ${row.name}`);
6099
+ return { name: row.name, address: hop.address, hostKey: hop.hostKey, hostKeySha256: hop.hostKeySha256 };
6100
+ });
6101
+ if (new Set(nodes.map(({ name }) => name)).size !== nodes.length || new Set(nodes.map(({ address }) => address)).size !== nodes.length) {
6102
+ throw new Error("operator guest host-key identities must be unique");
6103
+ }
6104
+ if (request) {
6105
+ const expected = nodes.length === request.genesis.nodes.length ? [...request.genesis.nodes] : [...request.genesis.nodes, ...request.genesis.rehearsalNode ? [request.genesis.rehearsalNode] : []];
6106
+ 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)) {
6107
+ throw new Error("operator guest host-key evidence does not match the reviewed Metal request");
6108
+ }
6109
+ }
6110
+ return {
6111
+ format: 1,
6112
+ kind: "forgezero-operator-guest-host-keys",
6113
+ metalHostKeySha256: root.metalHostKeySha256,
6114
+ nodes
6115
+ };
6116
+ };
6117
+ function readOperatorGuestHostKeyEvidence(path, request) {
6118
+ const value = JSON.parse(new TextDecoder().decode(ownerFile(path, REQUEST_LIMIT, "operator guest host-key evidence")));
6119
+ return validateGuestHostKeyEvidenceValue(value, request);
6120
+ }
6121
+ function writeOperatorGuestHostKeyEvidence(path, evidence, request) {
6122
+ return writeOwnerJson2(path, validateGuestHostKeyEvidenceValue(evidence, request), "operator guest host-key evidence");
6123
+ }
6082
6124
  function planOperatorMetalBootstrap(request, mode) {
6083
6125
  const config = readMetalBootstrapConfig(request.metalConfigFile);
6084
6126
  const keys = request.genesis.sshPublicKeyFiles.map((path) => publicIdentity(path));
@@ -6086,19 +6128,20 @@ function planOperatorMetalBootstrap(request, mode) {
6086
6128
  throw new Error("the reviewed metal request has no rehearsal node");
6087
6129
  }
6088
6130
  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);
6131
+ 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
6132
  return {
6090
6133
  kind: "metal-remote",
6091
6134
  mode,
6092
- mutation: mode !== "status",
6135
+ mutation: !["status", "host-keys", "rehearsal-host-keys"].includes(mode),
6093
6136
  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"] : [
6137
+ 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
6138
  "verify pinned SSH transport and caller-approved SSH agent",
6096
6139
  "copy pinned Bun and packaged fz artifacts to private staging",
6097
6140
  "stage the strict owner-only metal config and optional seed handoff",
6098
6141
  "run typed fz bootstrap metal --apply",
6099
6142
  "remove transient local and remote staging data"
6100
6143
  ],
6101
- genesisNodes: claims.map((claim) => claim.spec.guestName).filter((name) => Boolean(name)),
6144
+ genesisNodes,
6102
6145
  secretInputs: config.agentSeedFile ? ["metal-agent-seed"] : []
6103
6146
  };
6104
6147
  }
@@ -6335,6 +6378,116 @@ var copy = async (exec, request, knownHosts, local, remotePath, secret = false)
6335
6378
  `${destination2(request.target)}:${remotePath}`
6336
6379
  ], "secure copy", { secret });
6337
6380
  };
6381
+ var guestRows = (request) => [
6382
+ ...request.genesis.nodes,
6383
+ ...request.genesis.rehearsalNode ? [request.genesis.rehearsalNode] : []
6384
+ ];
6385
+ var parsedScannedHostKey = (output, address) => {
6386
+ 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(" "));
6387
+ const unique = [...new Set(keys)];
6388
+ if (unique.length !== 1 || !/^ssh-ed25519 [A-Za-z0-9+/]+={0,3}$/.test(unique[0])) {
6389
+ throw new Error(`guest ${address} did not return one exact Ed25519 host key`);
6390
+ }
6391
+ return unique[0];
6392
+ };
6393
+ async function collectOperatorGuestHostKeys(request, options = {}, includeRehearsal = false) {
6394
+ publicIdentity(request.target.identityPublicKeyFile);
6395
+ socketPath(request.target.agentSocket);
6396
+ const exec = options.exec ?? defaultExec3;
6397
+ const directory = mkdtempSync(join6(tmpdir(), "forgezero-operator-host-keys-"));
6398
+ try {
6399
+ const knownHosts = writeKnownHosts(request, directory);
6400
+ const nodes = [];
6401
+ for (const guest of includeRehearsal ? guestRows(request) : request.genesis.nodes) {
6402
+ const output = await remote(exec, request, knownHosts, [
6403
+ "/usr/bin/ssh-keyscan",
6404
+ "-T",
6405
+ "10",
6406
+ "-t",
6407
+ "ed25519",
6408
+ guest.address
6409
+ ], `collect ${guest.name} SSH host key`);
6410
+ const hostKey = parsedScannedHostKey(output, guest.address);
6411
+ nodes.push({
6412
+ name: guest.name,
6413
+ address: guest.address,
6414
+ hostKey,
6415
+ hostKeySha256: fingerprint(hostKey)
6416
+ });
6417
+ }
6418
+ return validateGuestHostKeyEvidenceValue({
6419
+ format: 1,
6420
+ kind: "forgezero-operator-guest-host-keys",
6421
+ metalHostKeySha256: request.target.hostKeySha256,
6422
+ nodes
6423
+ }, request);
6424
+ } finally {
6425
+ rmSync6(directory, { recursive: true, force: true });
6426
+ }
6427
+ }
6428
+ async function withOperatorGuestTransport(request, evidence, callback, options = {}) {
6429
+ const reviewed = validateGuestHostKeyEvidenceValue(evidence, request);
6430
+ publicIdentity(request.target.identityPublicKeyFile);
6431
+ socketPath(request.target.agentSocket);
6432
+ const exec = options.exec ?? defaultExec3;
6433
+ const directory = mkdtempSync(join6(tmpdir(), "forgezero-operator-guest-transport-"));
6434
+ try {
6435
+ const byName = new Map(reviewed.nodes.map((node) => [node.name, node]));
6436
+ const requests = new Map;
6437
+ const knownHosts = new Map;
6438
+ for (const node of reviewed.nodes) {
6439
+ const nodeDirectory = join6(directory, node.name);
6440
+ mkdirSync8(nodeDirectory, { mode: 448 });
6441
+ const guestRequest = {
6442
+ target: {
6443
+ address: node.address,
6444
+ port: 22,
6445
+ user: "forgezero",
6446
+ hostKey: node.hostKey,
6447
+ hostKeySha256: node.hostKeySha256,
6448
+ identityPublicKeyFile: request.target.identityPublicKeyFile,
6449
+ agentSocket: request.target.agentSocket,
6450
+ jump: {
6451
+ address: request.target.address,
6452
+ port: request.target.port,
6453
+ user: request.target.user,
6454
+ hostKey: request.target.hostKey,
6455
+ hostKeySha256: request.target.hostKeySha256
6456
+ }
6457
+ }
6458
+ };
6459
+ requests.set(node.name, guestRequest);
6460
+ knownHosts.set(node.name, writeKnownHosts(guestRequest, nodeDirectory));
6461
+ }
6462
+ const transport = {
6463
+ async run(nodeName, argv, runOptions = {}) {
6464
+ if (!byName.has(nodeName))
6465
+ throw new Error(`guest transport node ${nodeName} is not reviewed`);
6466
+ argv.forEach(safeRemoteArg);
6467
+ const nodeRequest = requests.get(nodeName);
6468
+ const result = await exec([
6469
+ "ssh",
6470
+ ...sshOptions(nodeRequest, knownHosts.get(nodeName)),
6471
+ destination2(nodeRequest.target),
6472
+ "--",
6473
+ ...argv
6474
+ ], { stdin: runOptions.stdin, secret: runOptions.secret });
6475
+ if (result.exitCode !== 0 && !runOptions.allowFailure) {
6476
+ throw new Error(`${nodeName} remote operation failed${result.output ? `: ${result.output.trim()}` : ""}`);
6477
+ }
6478
+ return result;
6479
+ },
6480
+ async copy(nodeName, localPath, remotePath) {
6481
+ if (!byName.has(nodeName))
6482
+ throw new Error(`guest transport node ${nodeName} is not reviewed`);
6483
+ await copy(exec, requests.get(nodeName), knownHosts.get(nodeName), localPath, remotePath);
6484
+ }
6485
+ };
6486
+ return await callback(transport);
6487
+ } finally {
6488
+ rmSync6(directory, { recursive: true, force: true });
6489
+ }
6490
+ }
6338
6491
  async function stageConfig(config, directory) {
6339
6492
  const rewritten = structuredClone(config);
6340
6493
  const staged = [];
@@ -6546,6 +6699,12 @@ async function applyOperatorMetalBootstrap(request, mode, options = {}) {
6546
6699
  const output2 = await remote(exec, request, knownHosts, ["/usr/bin/sudo", "-n", "/usr/local/bin/fz", "bootstrap", "status"], "remote metal status");
6547
6700
  return { plan, output: output2 };
6548
6701
  }
6702
+ if (mode === "host-keys" || mode === "rehearsal-host-keys") {
6703
+ return {
6704
+ plan,
6705
+ output: JSON.stringify(await collectOperatorGuestHostKeys(request, { ...options, exec }, mode === "rehearsal-host-keys"))
6706
+ };
6707
+ }
6549
6708
  if (mode === "genesis" || mode === "rehearsal" || mode === "rehearsal-cleanup") {
6550
6709
  const keys = request.genesis.sshPublicKeyFiles.map((path) => publicIdentity(path));
6551
6710
  if ((mode === "rehearsal" || mode === "rehearsal-cleanup") && !request.genesis.rehearsalNode) {
@@ -6592,15 +6751,19 @@ export {
6592
6751
  writeOperatorPlatformBootstrapRequest,
6593
6752
  writeOperatorPlatformBootstrapFleetRequest,
6594
6753
  writeOperatorMetalBootstrapRequest,
6754
+ writeOperatorGuestHostKeyEvidence,
6755
+ withOperatorGuestTransport,
6595
6756
  verifyOperatorPlatformFleet,
6596
6757
  readOperatorPlatformBootstrapRequest,
6597
6758
  readOperatorPlatformBootstrapFleetRequest,
6598
6759
  readOperatorMetalBootstrapRequest,
6760
+ readOperatorGuestHostKeyEvidence,
6599
6761
  planOperatorPlatformFleetBootstrap,
6600
6762
  planOperatorPlatformBootstrap,
6601
6763
  planOperatorMetalBootstrap,
6602
6764
  operatorPlatformFleetCoordinates,
6603
6765
  createOperatorSshHop,
6766
+ collectOperatorGuestHostKeys,
6604
6767
  applyOperatorPlatformFleetBootstrap,
6605
6768
  applyOperatorPlatformBootstrap,
6606
6769
  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.68";
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.68";
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.68";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgezero/agent",
3
- "version": "0.1.67",
3
+ "version": "0.1.68",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "check": "tsc --noEmit",