@forgezero/agent 0.1.2 → 0.1.10

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.
Files changed (55) hide show
  1. package/README.md +70 -4
  2. package/dist/attestation-client.d.ts +22 -0
  3. package/dist/attestation-client.test.d.ts +1 -0
  4. package/dist/cli/agent-install.d.ts +82 -0
  5. package/dist/cli/agent-install.test.d.ts +1 -0
  6. package/dist/cli/custody.d.ts +35 -0
  7. package/dist/cli/genesis.d.ts +79 -0
  8. package/dist/cli/index.d.ts +12 -0
  9. package/dist/cli/options.test.d.ts +1 -0
  10. package/dist/cli/run.d.ts +92 -0
  11. package/dist/cli/run.test.d.ts +1 -0
  12. package/dist/compute.d.ts +122 -0
  13. package/dist/compute.js +150 -0
  14. package/dist/compute.test.d.ts +1 -0
  15. package/dist/control.d.ts +57 -0
  16. package/dist/control.test.d.ts +1 -0
  17. package/dist/definition.d.ts +34 -0
  18. package/dist/definition.js +159 -0
  19. package/dist/definition.test.d.ts +1 -0
  20. package/dist/deployment-pull.d.ts +61 -0
  21. package/dist/deployment-pull.test.d.ts +1 -0
  22. package/dist/deployment-runner.d.ts +23 -0
  23. package/dist/deployment-runner.js +199 -0
  24. package/dist/deployment-runner.test.d.ts +1 -0
  25. package/dist/deployment-watch.d.ts +36 -0
  26. package/dist/deployment-watch.test.d.ts +1 -0
  27. package/dist/deployment.d.ts +100 -0
  28. package/dist/deployment.test.d.ts +1 -0
  29. package/dist/fz-agent.js +2934 -182
  30. package/dist/fz.js +1270 -0
  31. package/dist/guest-enrolment.d.ts +29 -0
  32. package/dist/guest-enrolment.js +88 -0
  33. package/dist/guest-enrolment.test.d.ts +1 -0
  34. package/dist/index.d.ts +50 -4
  35. package/dist/metal-helper-socket.d.ts +15 -0
  36. package/dist/metal-helper-socket.js +1123 -0
  37. package/dist/metal-helper-socket.test.d.ts +1 -0
  38. package/dist/metal-isolation.d.ts +14 -0
  39. package/dist/metal-isolation.test.d.ts +1 -0
  40. package/dist/metal-provision.d.ts +85 -0
  41. package/dist/metal-provision.js +1014 -0
  42. package/dist/metal-provision.test.d.ts +1 -0
  43. package/dist/node-vault.d.ts +24 -0
  44. package/dist/node-vault.js +211 -0
  45. package/dist/node-vault.test.d.ts +1 -0
  46. package/dist/provision.d.ts +50 -2
  47. package/dist/provision.js +286 -12
  48. package/dist/provisioning-pull.d.ts +75 -0
  49. package/dist/provisioning-pull.js +188 -0
  50. package/dist/provisioning-pull.test.d.ts +1 -0
  51. package/dist/signed-node-http.d.ts +14 -0
  52. package/dist/snp-attestation.d.ts +18 -0
  53. package/dist/snp-attestation.test.d.ts +1 -0
  54. package/dist/socket.d.ts +4 -23
  55. package/package.json +27 -9
package/dist/fz.js ADDED
@@ -0,0 +1,1270 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+ var __require = import.meta.require;
4
+
5
+ // src/cli/index.ts
6
+ import { THRESHOLD_MODES, thresholdMode } from "@forgezero/access/ceremony-modes";
7
+ import { signWithIdentity } from "@forgezero/runtime/ssh-agent";
8
+
9
+ // src/cli/run.ts
10
+ class RunError extends Error {
11
+ code;
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.code = code;
15
+ this.name = "RunError";
16
+ }
17
+ }
18
+ function splitAtSeparator(argv) {
19
+ const at = argv.indexOf("--");
20
+ if (at === -1) {
21
+ throw new RunError("NO_SEPARATOR", "Put `--` before the command: fz run -- npm start. Without it the flags after it would be read as fz\u2019s own.");
22
+ }
23
+ const command = argv.slice(at + 1);
24
+ if (command.length === 0) {
25
+ throw new RunError("NO_COMMAND", "Nothing to run after `--`.");
26
+ }
27
+ return { own: argv.slice(0, at), command };
28
+ }
29
+ var NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
30
+ function mergeEnvironment(base, secrets, options = {}) {
31
+ const env = {};
32
+ for (const [name, value] of Object.entries(base)) {
33
+ if (value !== undefined)
34
+ env[name] = value;
35
+ }
36
+ const collisions = [];
37
+ const refused = [];
38
+ for (const [name, value] of Object.entries(secrets)) {
39
+ if (!NAME.test(name)) {
40
+ refused.push(name);
41
+ continue;
42
+ }
43
+ if (name in env) {
44
+ collisions.push(name);
45
+ if (options.preserveEnv)
46
+ continue;
47
+ }
48
+ env[name] = value;
49
+ }
50
+ return { env, collisions: collisions.sort(), refused: refused.sort() };
51
+ }
52
+ function describeInjection(result, count) {
53
+ const lines = [`${count} secret${count === 1 ? "" : "s"} injected`];
54
+ if (result.collisions.length > 0) {
55
+ lines.push(` overriding: ${result.collisions.join(", ")} (use --preserve-env to keep the existing values)`);
56
+ }
57
+ if (result.refused.length > 0) {
58
+ lines.push(` skipped: ${result.refused.join(", ")} \u2014 not usable as environment variable names`);
59
+ }
60
+ return lines.join(`
61
+ `);
62
+ }
63
+ function exitCodeFor(status) {
64
+ if (status.signal) {
65
+ const numbers = {
66
+ SIGHUP: 1,
67
+ SIGINT: 2,
68
+ SIGQUIT: 3,
69
+ SIGKILL: 9,
70
+ SIGTERM: 15
71
+ };
72
+ return 128 + (numbers[status.signal] ?? 0);
73
+ }
74
+ return status.code ?? 0;
75
+ }
76
+ async function spawnWith(command, env, report = () => {}) {
77
+ const { spawn } = await import("child_process");
78
+ const child = spawn(command[0], command.slice(1), {
79
+ env,
80
+ stdio: "inherit"
81
+ });
82
+ const forward = (signal) => () => {
83
+ child.kill(signal);
84
+ };
85
+ const onInt = forward("SIGINT");
86
+ const onTerm = forward("SIGTERM");
87
+ process.on("SIGINT", onInt);
88
+ process.on("SIGTERM", onTerm);
89
+ try {
90
+ return await new Promise((resolve) => {
91
+ child.on("error", (error) => {
92
+ report(`Could not start ${command[0]}: ${error.message}`);
93
+ resolve(127);
94
+ });
95
+ child.on("exit", (code, signal) => resolve(exitCodeFor({ code, signal })));
96
+ });
97
+ } finally {
98
+ process.off("SIGINT", onInt);
99
+ process.off("SIGTERM", onTerm);
100
+ }
101
+ }
102
+
103
+ // src/cli/index.ts
104
+ import { readFileSync, unlinkSync, writeFileSync } from "fs";
105
+ import { fileURLToPath } from "url";
106
+ import { DEFAULT_SOCKET } from "@forgezero/vault";
107
+
108
+ // src/provision.ts
109
+ function atLeast(version, floor) {
110
+ const parse = (value) => (value.trim().replace(/^v/, "").match(/\d+/g) ?? []).slice(0, 3).map(Number);
111
+ const got = parse(version);
112
+ const want = parse(floor);
113
+ if (got.length === 0)
114
+ return false;
115
+ for (let index = 0;index < want.length; index += 1) {
116
+ const a = got[index] ?? 0;
117
+ const b = want[index] ?? 0;
118
+ if (a > b)
119
+ return true;
120
+ if (a < b)
121
+ return false;
122
+ }
123
+ return true;
124
+ }
125
+ var CAPABILITY_CHECKS = {
126
+ snpGuest: {
127
+ command: "test -e /dev/sev-guest && echo yes || echo no",
128
+ satisfied: (stdout) => stdout.trim() === "yes",
129
+ remedy: "Not a confidential guest. The agent will run in `enrolled` mode, which is still stronger than an API key in the application."
130
+ },
131
+ systemd: {
132
+ command: "test -d /run/systemd/system && echo yes || echo no",
133
+ satisfied: (stdout) => stdout.trim() === "yes",
134
+ remedy: "systemd is what supervises the agent. On a non-systemd host, run `fz-agent` under whatever supervises services there."
135
+ },
136
+ bun: {
137
+ command: "bun --version 2>/dev/null || echo missing",
138
+ satisfied: (stdout) => atLeast(stdout, "1.1.0"),
139
+ remedy: "Install bun: curl -fsSL https://bun.sh/install | bash"
140
+ },
141
+ python: {
142
+ command: "python3 --version 2>/dev/null || echo missing",
143
+ satisfied: (stdout, exitCode) => exitCode === 0 && /^Python 3\./.test(stdout.trim()),
144
+ remedy: "Install Python 3. It supplies the standard-library ioctl boundary for SNP reports."
145
+ }
146
+ };
147
+ var modeFor = (capabilities) => capabilities.snpGuest ? "attested" : "enrolled";
148
+ var reasonFor = (mode) => mode === "attested" ? "SEV-SNP guest device present, so the agent can prove what it is running and the platform can refuse it if the measurement is wrong." : "No SEV-SNP guest device. The agent authenticates with its enrolment token and hybrid Ed25519 + ML-DSA signature \u2014 weaker than attestation, stronger than an API key in the application.";
149
+ var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
150
+ var DEPLOYMENT_GROUP = "forgezero-deploy";
151
+ var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
152
+ var DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.socket";
153
+ var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
154
+ var ENROLMENT_UNIT_PATH = "/etc/systemd/system/forgezero-agent-enrol.service";
155
+ function agentEnrolmentUnit(options) {
156
+ if (!options.apiUrl || !options.enrolTokenCredentialPath || !options.enrolStatePath) {
157
+ throw new Error("direct enrolment needs API, credential and state paths");
158
+ }
159
+ const bin = options.binPath ?? "fz-agent";
160
+ const user = options.user ?? "forgezero";
161
+ const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
162
+ const label = options.nodeLabel ? `Environment=FZ_NODE_LABEL=${options.nodeLabel}
163
+ ` : "";
164
+ const gitPublicKey = options.gitPublicKeyPath ? `Environment=FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}
165
+ ` : "";
166
+ const stateDir = options.enrolStatePath.replace(/\/[^/]+$/, "");
167
+ return `[Unit]
168
+ Description=Bind this machine to its ForgeZero compute
169
+ After=network-online.target
170
+ Wants=network-online.target
171
+ Before=forgezero-agent.service
172
+ ConditionPathExists=!${options.enrolStatePath}
173
+
174
+ [Service]
175
+ Type=oneshot
176
+ User=${user}
177
+ Group=${user}
178
+ LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
179
+ LoadCredentialEncrypted=enrol-token:${options.enrolTokenCredentialPath}
180
+ Environment=FZ_SEED_CREDENTIAL=agent-seed
181
+ Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
182
+ Environment=FZ_ENROL_STATE_FILE=${options.enrolStatePath}
183
+ Environment=FZ_API=${options.apiUrl}
184
+ ${label}${gitPublicKey}ExecStart=${bin} enrol
185
+ # A '+' fixed command runs as root solely to remove the host-bound one-time
186
+ # ciphertext. Tenant code and the agent never receive a privilege boundary.
187
+ ExecStartPost=+/usr/bin/rm -f ${options.enrolTokenCredentialPath}
188
+ NoNewPrivileges=true
189
+ PrivateTmp=true
190
+ ProtectSystem=strict
191
+ ProtectHome=true
192
+ ReadWritePaths=${stateDir}
193
+ LimitCORE=0
194
+
195
+ [Install]
196
+ WantedBy=multi-user.target
197
+ `;
198
+ }
199
+ function deploymentRunnerSocketUnit(agentUser) {
200
+ return `[Unit]
201
+ Description=ForgeZero private project-command socket
202
+
203
+ [Socket]
204
+ ListenStream=${DEPLOYMENT_RUNNER_SOCKET}
205
+ SocketUser=${agentUser}
206
+ SocketGroup=${agentUser}
207
+ SocketMode=0600
208
+ DirectoryMode=0710
209
+ RemoveOnStop=true
210
+
211
+ [Install]
212
+ WantedBy=sockets.target
213
+ `;
214
+ }
215
+ function deploymentRunnerUnit(options) {
216
+ const bin = options.binPath ?? "fz-agent";
217
+ const root = options.deployRoot ?? "/opt/forgezero";
218
+ return `[Unit]
219
+ Description=ForgeZero credential-free project command runner
220
+ Documentation=https://www.forgezero.net/docs/agent
221
+ After=forgezero-deploy-runner.socket
222
+ Requires=forgezero-deploy-runner.socket
223
+
224
+ [Service]
225
+ Type=simple
226
+ User=${DEPLOYMENT_RUNNER_USER}
227
+ Group=${DEPLOYMENT_GROUP}
228
+ Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
229
+ Sockets=forgezero-deploy-runner.socket
230
+ ExecStart=${bin} deploy-runner --root=${root} --home=${root}/runner-home
231
+ Restart=always
232
+ RestartSec=2
233
+ UMask=0007
234
+ LimitCORE=0
235
+ NoNewPrivileges=false
236
+ PrivateTmp=true
237
+ ProtectSystem=strict
238
+ ProtectHome=true
239
+ ProtectKernelTunables=true
240
+ ProtectKernelModules=true
241
+ ProtectControlGroups=true
242
+ RestrictRealtime=true
243
+ MemoryDenyWriteExecute=true
244
+ LockPersonality=true
245
+ ReadWritePaths=${root}/releases ${root}/runner-home
246
+
247
+ [Install]
248
+ WantedBy=multi-user.target
249
+ `;
250
+ }
251
+ function agentUnit(options) {
252
+ const bin = options.binPath ?? "fz-agent";
253
+ const user = options.user ?? "forgezero";
254
+ const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
255
+ const controlSocketPath = options.controlSocketPath ?? "/run/forgezero/control.sock";
256
+ const deployRoot = options.deployRoot ?? "/opt/forgezero";
257
+ const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
258
+ const enrolmentEnabled = Boolean(options.enrolTokenCredentialPath && options.enrolStatePath);
259
+ const deploymentEnvironment = options.deploymentEnvironment ?? {};
260
+ const deploymentCredentials = options.deploymentCredentials ?? {};
261
+ for (const [name, value] of Object.entries(deploymentEnvironment)) {
262
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || !/^[A-Za-z0-9._:\/@+-]+$/.test(value)) {
263
+ throw new Error(`invalid deployment environment entry: ${name}`);
264
+ }
265
+ }
266
+ for (const [name, path] of Object.entries(deploymentCredentials)) {
267
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || !path.startsWith("/") || /[\r\n:]/.test(path)) {
268
+ throw new Error(`invalid deployment credential entry: ${name}`);
269
+ }
270
+ }
271
+ const environment = [
272
+ `FZ_SOCKET_PATH=${options.socketPath}`,
273
+ `FZ_CONTROL_SOCKET=${controlSocketPath}`,
274
+ `FZ_SEED_CREDENTIAL=agent-seed`,
275
+ `FZ_AGENT_MODE=${options.mode}`,
276
+ options.apiUrl ? `FZ_API=${options.apiUrl}` : null,
277
+ options.project ? `FZ_PROJECT=${options.project}` : null,
278
+ options.environment ? `FZ_ENVIRONMENT=${options.environment}` : null,
279
+ options.enrolStatePath ? `FZ_ENROL_STATE_FILE=${options.enrolStatePath}` : null,
280
+ options.nodeLabel ? `FZ_NODE_LABEL=${options.nodeLabel}` : null,
281
+ options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
282
+ options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
283
+ options.branch ? `FZ_DEPLOY_BRANCH=${options.branch}` : null,
284
+ options.role ? `FZ_DEPLOY_ROLE=${options.role}` : null,
285
+ options.repository && options.branch ? `FZ_DEPLOY_KEY=${options.project ?? "platform"}:${options.environment ?? "production"}` : null,
286
+ deploymentEnabled ? `FZ_DEPLOY_ROOT=${deployRoot}` : null,
287
+ deploymentEnabled ? `FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}` : null,
288
+ Object.keys(deploymentCredentials).length > 0 ? `FZ_DEPLOY_SYSTEMD_SECRETS=${Object.keys(deploymentCredentials).join(",")}` : null,
289
+ Object.keys(deploymentEnvironment).length > 0 ? `FZ_DEPLOY_ENV_NAMES=${Object.keys(deploymentEnvironment).join(",")}` : null,
290
+ ...Object.entries(deploymentEnvironment).map(([name, value]) => `${name}=${value}`),
291
+ options.publicApiUrl ? `FZ_PUBLIC_API_URL=${options.publicApiUrl}` : null,
292
+ options.pullDeployments ? "FZ_DEPLOY_PULL=true" : null
293
+ ].filter((line) => line !== null);
294
+ if (deploymentEnabled) {
295
+ environment.push(`HOME=${deployRoot}/agent-home`, `XDG_CACHE_HOME=${deployRoot}/cache`);
296
+ }
297
+ const gitCredential = options.gitCredentialPath ? `LoadCredentialEncrypted=git-deploy-key:${options.gitCredentialPath}
298
+ ` : "";
299
+ const projectCredentials = Object.entries(deploymentCredentials).map(([name, path]) => `LoadCredentialEncrypted=${name}:${path}`).join(`
300
+ `);
301
+ const deploymentWrites = deploymentEnabled ? `ReadWritePaths=${deployRoot}/releases ${deployRoot}/agent-home ${deployRoot}/cache` : "";
302
+ const deploymentGroup = deploymentEnabled ? `SupplementaryGroups=${DEPLOYMENT_GROUP}` : "";
303
+ const after = [
304
+ "network-online.target",
305
+ deploymentEnabled ? "forgezero-deploy-runner.service" : null,
306
+ enrolmentEnabled ? "forgezero-agent-enrol.service" : null
307
+ ].filter((value) => value !== null);
308
+ const requires = [
309
+ deploymentEnabled ? "forgezero-deploy-runner.service" : null,
310
+ enrolmentEnabled ? "forgezero-agent-enrol.service" : null
311
+ ].filter((value) => value !== null);
312
+ const deploymentDependency = [
313
+ `After=${after.join(" ")}`,
314
+ "Wants=network-online.target",
315
+ requires.length > 0 ? `Requires=${requires.join(" ")}` : null
316
+ ].filter((value) => value !== null).join(`
317
+ `);
318
+ const snpDevice = options.mode === "attested" ? `DevicePolicy=closed
319
+ DeviceAllow=/dev/sev-guest rw` : "";
320
+ const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${user} /dev/sev-guest
321
+ ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
322
+ ` : "";
323
+ return `[Unit]
324
+ Description=ForgeZero node agent (${options.mode})
325
+ Documentation=https://www.forgezero.net/docs/agent
326
+ ${deploymentDependency}
327
+
328
+ [Service]
329
+ Type=simple
330
+ User=${user}
331
+ Group=${user}
332
+ ${deploymentGroup}
333
+ LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
334
+ ${gitCredential}${projectCredentials}${projectCredentials ? `
335
+ ` : ""}${snpPrepare}ExecStart=${bin}
336
+ Restart=always
337
+ RestartSec=2
338
+
339
+ ${environment.map((line) => `Environment=${line}`).join(`
340
+ `)}
341
+
342
+ # The node seed and the vault replica live in this process's memory. A core dump
343
+ # writes both to disk, which is the one artefact this design exists to remove.
344
+ LimitCORE=0
345
+
346
+ # The socket is the entire interface: anything that can read it can read the
347
+ # scope. So it lives in a directory systemd creates with a known owner rather
348
+ # than wherever the process happened to have write access.
349
+ RuntimeDirectory=forgezero
350
+ RuntimeDirectoryMode=0710
351
+ UMask=0077
352
+
353
+ # Tenant-controlled commands execute in forgezero-deploy-runner.service. This
354
+ # credential-bearing process never needs to cross a privilege boundary.
355
+ NoNewPrivileges=true
356
+ PrivateTmp=true
357
+ ProtectSystem=strict
358
+ ProtectHome=true
359
+ ProtectKernelTunables=true
360
+ ProtectKernelModules=true
361
+ ProtectControlGroups=true
362
+ RestrictSUIDSGID=true
363
+ RestrictRealtime=true
364
+ MemoryDenyWriteExecute=true
365
+ LockPersonality=true
366
+ ${snpDevice}
367
+ ${deploymentWrites}
368
+
369
+ [Install]
370
+ WantedBy=multi-user.target
371
+ `;
372
+ }
373
+ var UNIT_PATH = "/etc/systemd/system/forgezero-agent.service";
374
+ function planProvision(options) {
375
+ const mode = options.mode;
376
+ const user = options.user ?? "forgezero";
377
+ const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
378
+ const credentialDir = seedCredentialPath.replace(/\/[^/]+$/, "");
379
+ const deployRoot = options.deployRoot ?? "/opt/forgezero";
380
+ const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
381
+ const enrolmentEnabled = Boolean(options.enrolTokenSourcePath && options.enrolTokenCredentialPath && options.enrolStatePath);
382
+ if (Boolean(options.enrolTokenSourcePath) !== Boolean(options.enrolTokenCredentialPath) || Boolean(options.enrolTokenCredentialPath) !== Boolean(options.enrolStatePath))
383
+ throw new Error("direct enrolment paths must be supplied together");
384
+ const safePath = (value, label) => {
385
+ if (!value || !/^\/[A-Za-z0-9._@/-]+$/.test(value))
386
+ throw new Error(`invalid ${label} path`);
387
+ return value;
388
+ };
389
+ const enrolTokenSourcePath = enrolmentEnabled ? safePath(options.enrolTokenSourcePath, "enrolment source") : undefined;
390
+ const enrolTokenCredentialPath = enrolmentEnabled ? safePath(options.enrolTokenCredentialPath, "enrolment credential") : undefined;
391
+ const enrolStatePath = enrolmentEnabled ? safePath(options.enrolStatePath, "enrolment state") : undefined;
392
+ const enrolStateDir = enrolStatePath?.replace(/\/[^/]+$/, "");
393
+ const sourceBinPath = options.sourceBinPath ? safePath(options.sourceBinPath, "agent source binary") : undefined;
394
+ const binPath = options.binPath ? safePath(options.binPath, "agent binary") : undefined;
395
+ const gitCredentialPath = options.gitCredentialPath ? safePath(options.gitCredentialPath, "Git credential") : undefined;
396
+ const gitPublicKeyPath = options.gitPublicKeyPath ? safePath(options.gitPublicKeyPath, "Git public key") : undefined;
397
+ if (options.generateGitIdentity && (!gitCredentialPath || !gitPublicKeyPath)) {
398
+ throw new Error("generated Git identity needs credential and public-key paths");
399
+ }
400
+ const gitPublicKeyDir = gitPublicKeyPath?.replace(/\/[^/]+$/, "");
401
+ return {
402
+ mode,
403
+ reason: reasonFor(mode),
404
+ unitPath: UNIT_PATH,
405
+ unit: agentUnit({ ...options, mode }),
406
+ auxiliaryUnits: [
407
+ ...deploymentEnabled ? [
408
+ { path: DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH, unit: deploymentRunnerSocketUnit(user) },
409
+ { path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) }
410
+ ] : [],
411
+ ...enrolmentEnabled ? [
412
+ { path: ENROLMENT_UNIT_PATH, unit: agentEnrolmentUnit(options) }
413
+ ] : []
414
+ ],
415
+ socketPath: options.socketPath,
416
+ user,
417
+ steps: [
418
+ ...sourceBinPath && binPath ? [{
419
+ label: "root-owned agent runtime",
420
+ command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")}; ` + `install -o root -g root -m 0755 ${sourceBinPath} ${binPath}`
421
+ }] : [],
422
+ ...deploymentEnabled ? [{
423
+ label: "deployment isolation group",
424
+ command: `groupadd --system ${DEPLOYMENT_GROUP} || true`
425
+ }] : [],
426
+ {
427
+ label: "service account",
428
+ command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
429
+ },
430
+ ...deploymentEnabled ? [{
431
+ label: "credential-free deployment account",
432
+ command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP} ${user}`
433
+ }] : [],
434
+ {
435
+ label: "credential directory",
436
+ command: `install -d -o root -g root -m 0700 ${credentialDir}`
437
+ },
438
+ {
439
+ label: "encrypted node identity",
440
+ command: `test -s ${seedCredentialPath} || { ` + `openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\\n' | ` + `systemd-creds encrypt --name=agent-seed - ${seedCredentialPath}; ` + `chmod 0400 ${seedCredentialPath}; }`
441
+ },
442
+ ...options.generateGitIdentity && gitCredentialPath && gitPublicKeyPath ? [
443
+ {
444
+ label: "Git deploy identity directory",
445
+ command: `install -d -o root -g root -m 0755 ${gitPublicKeyDir}`
446
+ },
447
+ {
448
+ label: "unique encrypted Git deploy identity",
449
+ command: `test -s ${gitCredentialPath} || { ` + `rm -f /run/forgezero-git-deploy-key /run/forgezero-git-deploy-key.pub; ` + `ssh-keygen -q -t ed25519 -N '' -C forgezero-compute -f /run/forgezero-git-deploy-key; ` + `systemd-creds encrypt --name=git-deploy-key /run/forgezero-git-deploy-key ${gitCredentialPath}; ` + `install -o root -g root -m 0444 /run/forgezero-git-deploy-key.pub ${gitPublicKeyPath}; ` + `rm -f /run/forgezero-git-deploy-key /run/forgezero-git-deploy-key.pub; ` + `chmod 0400 ${gitCredentialPath}; }; ` + `test -s ${gitPublicKeyPath} || { ` + `systemd-creds decrypt --name=git-deploy-key ${gitCredentialPath} /run/forgezero-git-deploy-key; ` + `ssh-keygen -y -f /run/forgezero-git-deploy-key | ` + `sed 's/$/ forgezero-compute/' > /run/forgezero-git-deploy-key.pub; ` + `install -o root -g root -m 0444 /run/forgezero-git-deploy-key.pub ${gitPublicKeyPath}; ` + `rm -f /run/forgezero-git-deploy-key /run/forgezero-git-deploy-key.pub; }; ` + `test -s ${gitCredentialPath} && test -s ${gitPublicKeyPath}`
450
+ }
451
+ ] : [],
452
+ ...enrolmentEnabled ? [
453
+ {
454
+ label: "enrolment state directory",
455
+ command: `install -d -o ${user} -g ${user} -m 0700 ${enrolStateDir}`
456
+ },
457
+ {
458
+ label: "encrypted one-time enrolment capability",
459
+ command: `test -s ${enrolTokenCredentialPath} || { test -r ${enrolTokenSourcePath}; ` + `systemd-creds encrypt --name=enrol-token ${enrolTokenSourcePath} ${enrolTokenCredentialPath}; ` + `chmod 0400 ${enrolTokenCredentialPath}; rm -f ${enrolTokenSourcePath}; }`
460
+ }
461
+ ] : [],
462
+ ...deploymentEnabled ? [{
463
+ label: "deployment directories",
464
+ command: `install -d -o root -g root -m 0755 ${deployRoot} && ` + `install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 ${deployRoot}/releases && ` + `install -d -o ${user} -g ${user} -m 0750 ${deployRoot}/cache && ` + `install -d -o ${user} -g ${user} -m 0700 ${deployRoot}/agent-home && ` + `install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 ${deployRoot}/runner-home ${deployRoot}/runner-home/cache`
465
+ }] : [],
466
+ { label: "reload units", command: "systemctl daemon-reload" },
467
+ {
468
+ label: "enable and start",
469
+ command: `systemctl enable --now ${[
470
+ ...deploymentEnabled ? ["forgezero-deploy-runner.socket", "forgezero-deploy-runner.service"] : [],
471
+ ...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : [],
472
+ "forgezero-agent.service"
473
+ ].join(" ")}`
474
+ },
475
+ ...enrolmentEnabled ? [{
476
+ label: "prove the compute binding is durable",
477
+ command: `test -s ${enrolStatePath}`
478
+ }] : [],
479
+ { label: "prove it is running", command: "systemctl is-active forgezero-agent.service" },
480
+ { label: "prove the vault socket exists", command: `test -S ${options.socketPath}` },
481
+ ...deploymentEnabled ? [{
482
+ label: "prove the deployment runner socket exists",
483
+ command: `test -S ${DEPLOYMENT_RUNNER_SOCKET}`
484
+ }] : [],
485
+ ...options.repository ? [{
486
+ label: "prove the deployment control socket exists",
487
+ command: `test -S ${options.controlSocketPath ?? "/run/forgezero/control.sock"}`
488
+ }] : []
489
+ ]
490
+ };
491
+ }
492
+
493
+ // src/cli/agent-install.ts
494
+ function parseAssignments(value) {
495
+ if (!value?.trim())
496
+ return;
497
+ const parsed = {};
498
+ for (const entry of value.split(",")) {
499
+ const separator = entry.indexOf("=");
500
+ if (separator < 1)
501
+ throw new Error(`invalid deployment assignment: ${entry}`);
502
+ const name = entry.slice(0, separator);
503
+ const item = entry.slice(separator + 1);
504
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || !item || /[\r\n]/.test(item)) {
505
+ throw new Error(`invalid deployment assignment: ${name}`);
506
+ }
507
+ if (Object.hasOwn(parsed, name))
508
+ throw new Error(`duplicate deployment assignment: ${name}`);
509
+ parsed[name] = item;
510
+ }
511
+ return parsed;
512
+ }
513
+ async function readCapabilities(run) {
514
+ const answers = {};
515
+ const checks = Object.entries(CAPABILITY_CHECKS);
516
+ for (const [id, check] of checks) {
517
+ try {
518
+ const result = await run(check.command);
519
+ answers[id] = check.satisfied(result.stdout, result.exitCode);
520
+ } catch {
521
+ answers[id] = false;
522
+ }
523
+ }
524
+ return answers;
525
+ }
526
+ async function localRunner(command) {
527
+ const proc = Bun.spawn(["sh", "-c", command], { stdout: "pipe", stderr: "pipe" });
528
+ const stdout = await new Response(proc.stdout).text();
529
+ return { stdout, exitCode: await proc.exited };
530
+ }
531
+ function planInstall(options) {
532
+ const { capabilities, ...unit } = options;
533
+ return planProvision({ ...unit, mode: modeFor(capabilities) });
534
+ }
535
+ async function applyPlan(plan, run) {
536
+ const transcript = [];
537
+ for (const step of plan.steps) {
538
+ const result = await run(step.command);
539
+ transcript.push({ label: step.label, command: step.command, exitCode: result.exitCode });
540
+ if (result.exitCode !== 0 && !step.optional) {
541
+ throw new Error(`${step.label} failed (exit ${result.exitCode}): ${step.command}`);
542
+ }
543
+ }
544
+ return transcript;
545
+ }
546
+ function renderPlan(plan) {
547
+ return [
548
+ "",
549
+ ` Mode ${plan.mode.toUpperCase()}`,
550
+ ` Reason ${plan.reason}`,
551
+ "",
552
+ ` User ${plan.user}`,
553
+ ` Socket ${plan.socketPath}`,
554
+ ` Unit ${plan.unitPath}`,
555
+ ...plan.auxiliaryUnits.map((unit) => ` Aux unit ${unit.path}`),
556
+ "",
557
+ " Applications on this machine will then read secrets through the socket.",
558
+ " @forgezero/vault prefers it over FORGEZERO_API_KEY automatically, so no",
559
+ " application code changes.",
560
+ "",
561
+ " Run with --apply to write the unit and start the service.",
562
+ ""
563
+ ].join(`
564
+ `);
565
+ }
566
+
567
+ // src/cli/custody.ts
568
+ import {
569
+ listCustodyIdentities,
570
+ deriveCustodyKey,
571
+ assertDeterministic,
572
+ SshAgentError
573
+ } from "@forgezero/runtime/ssh-agent";
574
+ var PROBE_TIMEOUT_MS = 2000;
575
+ function withTimeout(work, ms, label) {
576
+ return Promise.race([
577
+ work,
578
+ new Promise((_, reject) => setTimeout(() => reject(new SshAgentError(`SSH_AGENT_TIMEOUT:${label}`)), ms))
579
+ ]);
580
+ }
581
+ async function usableIdentities(socketPath) {
582
+ const candidates = await listCustodyIdentities(socketPath);
583
+ const usable = [];
584
+ for (const identity of candidates) {
585
+ const started = Date.now();
586
+ try {
587
+ await withTimeout(deriveCustodyKey(identity, socketPath), PROBE_TIMEOUT_MS, identity.fingerprint);
588
+ usable.push({ ...identity, responseMs: Date.now() - started });
589
+ } catch {}
590
+ }
591
+ return usable;
592
+ }
593
+ async function custodyKeyFor(identity, socketPath) {
594
+ return withTimeout(assertDeterministic(identity, socketPath), PROBE_TIMEOUT_MS * 2, identity.fingerprint);
595
+ }
596
+ function selectIdentity(identities, selector) {
597
+ const index = Number.parseInt(selector, 10);
598
+ if (Number.isInteger(index) && index >= 1 && index <= identities.length) {
599
+ return identities[index - 1];
600
+ }
601
+ return identities.find((id) => id.fingerprint === selector) ?? identities.find((id) => id.fingerprint.startsWith(selector)) ?? identities.find((id) => id.comment === selector) ?? null;
602
+ }
603
+
604
+ // src/cli/genesis.ts
605
+ import { generatePhrase } from "@forgezero/runtime/phrase";
606
+ import {
607
+ wrappingKeysFor,
608
+ openFactorEnvelope
609
+ } from "@forgezero/runtime/custody-share";
610
+ import { listCustodyIdentities as listCustodyIdentities2 } from "@forgezero/runtime/ssh-agent";
611
+ var b64 = (bytes) => Buffer.from(bytes).toString("base64url");
612
+ function fail(response, step) {
613
+ const body = response.body;
614
+ const raw = body?.error;
615
+ const detail = typeof raw === "string" ? raw : raw?.code ? `${raw.code}${raw.message ? ` \u2014 ${raw.message}` : ""}` : body?.message ?? `HTTP ${response.status}`;
616
+ throw new Error(`${step}: ${detail}`);
617
+ }
618
+ async function runGenesis(args) {
619
+ const step = args.onStep ?? (() => {});
620
+ step("Claiming the platform with the setup token");
621
+ const claimed = await args.api("/onboarding/platform/claim", {
622
+ method: "POST",
623
+ body: { token: args.token, email: args.email, displayName: args.displayName }
624
+ });
625
+ if (claimed.status !== 200) {
626
+ const code = claimed.body?.error;
627
+ if (code !== "ALREADY_CLAIMED")
628
+ fail(claimed, "claim");
629
+ step("Already claimed \u2014 continuing to the ceremony");
630
+ }
631
+ step(`Registering ${args.identity.fingerprint} as a custody key`);
632
+ const registered = await args.api("/security/ssh-identities", {
633
+ method: "POST",
634
+ body: {
635
+ authorizedKey: `${args.identity.type} ${Buffer.from(args.identity.blob).toString("base64")}`
636
+ }
637
+ });
638
+ if (registered.status !== 200)
639
+ fail(registered, "register-ssh-key");
640
+ step(`Deriving custody key from ${args.identity.fingerprint}`);
641
+ const sshKey = await custodyKeyFor(args.identity, args.socketPath);
642
+ step("Generating recovery phrase");
643
+ const phrase = generatePhrase();
644
+ step(`Proposing ${args.modeId} ceremony`);
645
+ const proposed = await args.api("/custody/ceremony/propose", {
646
+ method: "POST",
647
+ body: {
648
+ modeId: args.modeId,
649
+ custodians: [{ userKey: args.userKey, email: args.email }]
650
+ }
651
+ });
652
+ if (proposed.status !== 200)
653
+ fail(proposed, "propose");
654
+ const ceremonyKey = proposed.body?.ceremony?._key;
655
+ if (!ceremonyKey)
656
+ throw new Error("propose: no ceremony key returned");
657
+ step("Enrolling \u2014 the share is sealed to this machine, never sent in the clear");
658
+ const keys = wrappingKeysFor({
659
+ custodianKey: args.userKey,
660
+ passkeyPrfOutput: sshKey,
661
+ phraseWords: phrase
662
+ });
663
+ const enrolled = await args.api("/custody/ceremony/share", {
664
+ method: "POST",
665
+ body: { ceremonyKey, keys }
666
+ });
667
+ if (enrolled.status !== 200)
668
+ fail(enrolled, "enroll");
669
+ const sealed = enrolled.body?.ceremony?.custodians?.find((c) => c.userKey === args.userKey)?.sealed;
670
+ if (!sealed)
671
+ throw new Error("enroll: the server returned no sealed share");
672
+ for (const factor of ["passkey", "phrase"]) {
673
+ step(`Proving the ${factor === "passkey" ? "ssh" : "phrase"} route opens the share`);
674
+ const { probe } = openFactorEnvelope({
675
+ sealed,
676
+ custodianKey: args.userKey,
677
+ factor,
678
+ ...factor === "passkey" ? { passkeyPrfOutput: sshKey } : { phraseWords: phrase }
679
+ });
680
+ const tested = await args.api("/custody/ceremony/test", {
681
+ method: "POST",
682
+ body: { ceremonyKey, factor, openedProbe: b64(probe) }
683
+ });
684
+ if (tested.status !== 200)
685
+ fail(tested, `test:${factor}`);
686
+ }
687
+ step("Activating \u2014 the seed is split and the vault opens");
688
+ const activated = await args.api("/custody/ceremony/activate", {
689
+ method: "POST",
690
+ body: { ceremonyKey }
691
+ });
692
+ if (activated.status !== 200)
693
+ fail(activated, "activate");
694
+ sshKey.fill(0);
695
+ return {
696
+ ceremonyKey,
697
+ phrase,
698
+ fingerprint: activated.body?.ceremony?.masterSeedFingerprint ?? "",
699
+ activated: true
700
+ };
701
+ }
702
+ async function runUnlock(args) {
703
+ const step = args.onStep ?? (() => {});
704
+ const contributions = [];
705
+ const sealed = args.sealed;
706
+ if (args.identity && sealed) {
707
+ step(`Deriving custody key from ${args.identity.fingerprint}`);
708
+ const sshKey = await custodyKeyFor(args.identity, args.socketPath);
709
+ const { share } = openFactorEnvelope({
710
+ sealed,
711
+ custodianKey: args.userKey,
712
+ factor: "passkey",
713
+ passkeyPrfOutput: sshKey
714
+ });
715
+ contributions.push({
716
+ userKey: args.userKey,
717
+ factor: "passkey",
718
+ share: b64(share)
719
+ });
720
+ sshKey.fill(0);
721
+ }
722
+ if (args.phrase?.length && sealed) {
723
+ const { share } = openFactorEnvelope({
724
+ sealed,
725
+ custodianKey: args.userKey,
726
+ factor: "phrase",
727
+ phraseWords: args.phrase
728
+ });
729
+ contributions.push({
730
+ userKey: args.userKey,
731
+ factor: "phrase",
732
+ share: b64(share)
733
+ });
734
+ }
735
+ if (contributions.length === 0) {
736
+ throw new Error("unlock: supply an ssh key or a recovery phrase");
737
+ }
738
+ step(`Reconstructing the seed from ${contributions.length} contribution(s)`);
739
+ const unlocked = await args.api("/custody/ceremony/unlock", {
740
+ method: "POST",
741
+ body: { contributions }
742
+ });
743
+ if (unlocked.status !== 200)
744
+ fail(unlocked, "unlock");
745
+ return {
746
+ fingerprint: unlocked.body?.fingerprint ?? ""
747
+ };
748
+ }
749
+ async function resolveIdentity(selector, socketPath) {
750
+ if (selector && !/^\d+$/.test(selector)) {
751
+ const listed = await listCustodyIdentities2(socketPath);
752
+ const match = listed.find((id) => id.fingerprint === selector) ?? listed.find((id) => id.fingerprint.startsWith(selector)) ?? listed.find((id) => id.comment === selector);
753
+ if (match) {
754
+ await custodyKeyFor(match, socketPath);
755
+ return { ...match, responseMs: 0 };
756
+ }
757
+ }
758
+ const identities = await usableIdentities(socketPath);
759
+ if (identities.length === 0) {
760
+ throw new Error("No usable Ed25519 key in the agent. Add one with `ssh-add ~/.ssh/id_ed25519`, " + "then confirm with `fz keys`.");
761
+ }
762
+ if (!selector) {
763
+ if (identities.length > 1) {
764
+ throw new Error(`${identities.length} usable keys \u2014 choose one with --key <fingerprint|index>. ` + "Run `fz keys` to list them.");
765
+ }
766
+ return identities[0];
767
+ }
768
+ const chosen = selectIdentity(identities, selector);
769
+ if (!chosen)
770
+ throw new Error(`No usable key matches "${selector}". Run \`fz keys\`.`);
771
+ return chosen;
772
+ }
773
+
774
+ // src/cli/index.ts
775
+ var DEFAULT_MODE = THRESHOLD_MODES[0].id;
776
+ var RECOMMENDED_MODE = (THRESHOLD_MODES.find((mode) => mode.recommended) ?? THRESHOLD_MODES[0]).id;
777
+ var VERSION = "0.1.10";
778
+ var PACKAGED_AGENT_BIN = fileURLToPath(new URL("./fz-agent.js", import.meta.url));
779
+ function parseOptions(argv) {
780
+ const options = {
781
+ api: process.env.FZ_API ?? "http://localhost:8787",
782
+ realm: process.env.FZ_REALM ?? "platform",
783
+ json: false,
784
+ apply: false,
785
+ enrol: false,
786
+ socket: process.env.SSH_AUTH_SOCK,
787
+ mode: DEFAULT_MODE,
788
+ user: process.env.FZ_USER ?? "operator",
789
+ email: process.env.FZ_EMAIL ?? "operator@localhost",
790
+ preserveEnv: false
791
+ };
792
+ const positional = [];
793
+ for (let index = 0;index < argv.length; index += 1) {
794
+ const token = argv[index];
795
+ if (token === "--api")
796
+ options.api = argv[++index] ?? options.api;
797
+ else if (token === "--realm")
798
+ options.realm = argv[++index] ?? options.realm;
799
+ else if (token === "--socket")
800
+ options.socket = argv[++index];
801
+ else if (token === "--json")
802
+ options.json = true;
803
+ else if (token === "--apply")
804
+ options.apply = true;
805
+ else if (token === "--enrol")
806
+ options.enrol = true;
807
+ else if (token === "--preserve-env")
808
+ options.preserveEnv = true;
809
+ else if (token === "--key")
810
+ options.key = argv[++index];
811
+ else if (token === "--mode")
812
+ options.mode = argv[++index] ?? options.mode;
813
+ else if (token === "--user")
814
+ options.user = argv[++index] ?? options.user;
815
+ else if (token === "--email")
816
+ options.email = argv[++index] ?? options.email;
817
+ else if (token === "--token")
818
+ options.token = argv[++index];
819
+ else if (token === "--phrase")
820
+ options.phrase = (argv[++index] ?? "").trim().split(/\s+/);
821
+ else
822
+ positional.push(token);
823
+ }
824
+ return { command: positional[0] ?? "help", args: positional.slice(1), options };
825
+ }
826
+ var out = {
827
+ line: (text = "") => process.stdout.write(`${text}
828
+ `),
829
+ step: (text) => process.stdout.write(` ${text}
830
+ `),
831
+ warn: (text) => process.stderr.write(` ! ${text}
832
+ `),
833
+ fail: (text) => process.stderr.write(` \u2717 ${text}
834
+ `),
835
+ ok: (text) => process.stdout.write(` \u2713 ${text}
836
+ `)
837
+ };
838
+ var sessionCookie = null;
839
+ function requestHeaders(apiBase, cookie) {
840
+ return {
841
+ "content-type": "application/json",
842
+ origin: new URL(apiBase).origin,
843
+ ...cookie ? { cookie } : {}
844
+ };
845
+ }
846
+ async function api(options, path, init) {
847
+ const base = options.realm === "platform" ? "/api" : `/api/t/${options.realm}`;
848
+ const response = await fetch(`${options.api}${base}${path}`, {
849
+ method: init?.method ?? "GET",
850
+ headers: requestHeaders(options.api, sessionCookie),
851
+ body: init?.body === undefined ? undefined : JSON.stringify(init.body)
852
+ });
853
+ const setCookie = response.headers.get("set-cookie");
854
+ if (setCookie)
855
+ sessionCookie = setCookie.split(";")[0];
856
+ let body = null;
857
+ try {
858
+ body = await response.json();
859
+ } catch {
860
+ body = null;
861
+ }
862
+ const challenge = body?.security;
863
+ if (response.status === 428 && challenge?.scope === "action" && challenge.requestKey && custodyIdentity && !path.startsWith("/security/step-up/")) {
864
+ const proved = await proveWithAgent(options, challenge.requestKey);
865
+ if (proved) {
866
+ const replay = await fetch(`${options.api}${base}${path}`, {
867
+ method: init?.method ?? "GET",
868
+ headers: {
869
+ ...requestHeaders(options.api, sessionCookie),
870
+ "x-security-request-key": challenge.requestKey
871
+ },
872
+ body: init?.body === undefined ? undefined : JSON.stringify(init.body)
873
+ });
874
+ let replayed = null;
875
+ try {
876
+ replayed = await replay.json();
877
+ } catch {
878
+ replayed = null;
879
+ }
880
+ return { status: replay.status, body: replayed };
881
+ }
882
+ }
883
+ return { status: response.status, body };
884
+ }
885
+ var custodyIdentity = null;
886
+ function useCustodyIdentity(identity, socketPath) {
887
+ custodyIdentity = { identity, socketPath };
888
+ }
889
+ async function proveWithAgent(options, requestKey) {
890
+ if (!custodyIdentity)
891
+ return false;
892
+ const begun = await api(options, "/security/step-up/ssh/begin", {
893
+ method: "POST",
894
+ body: { requestKey }
895
+ });
896
+ const nonce = begun.body?.nonce;
897
+ if (begun.status !== 200 || !nonce)
898
+ return false;
899
+ const signature = await signWithIdentity(custodyIdentity.identity, new Uint8Array(Buffer.from(nonce, "base64")), custodyIdentity.socketPath);
900
+ const proved = await api(options, "/security/step-up/ssh/prove", {
901
+ method: "POST",
902
+ body: { requestKey, signature: Buffer.from(signature).toString("base64") }
903
+ });
904
+ return proved.status === 200;
905
+ }
906
+ async function cmdKeys(options) {
907
+ const identities = await usableIdentities(options.socket);
908
+ if (options.json) {
909
+ out.line(JSON.stringify(identities.map(({ fingerprint, comment, responseMs }) => ({
910
+ fingerprint,
911
+ comment,
912
+ responseMs
913
+ })), null, 2));
914
+ return 0;
915
+ }
916
+ if (identities.length === 0) {
917
+ out.fail("No usable Ed25519 keys in the agent.");
918
+ out.line();
919
+ out.step("Custody needs a key that signs DETERMINISTICALLY, so Ed25519 only.");
920
+ out.step("Add one with: ssh-add ~/.ssh/id_ed25519");
921
+ out.line();
922
+ out.step("Note that keys which hang \u2014 forwarded agents whose upstream is");
923
+ out.step("gone, or confirm-on-use keys with nobody at the terminal \u2014 are");
924
+ out.step("skipped here rather than listed, so this can be shorter than");
925
+ out.step("`ssh-add -l`.");
926
+ return 1;
927
+ }
928
+ out.line("Usable custody keys:");
929
+ out.line();
930
+ identities.forEach((identity, index) => {
931
+ out.line(` ${index + 1}. ${identity.fingerprint}`);
932
+ out.line(` ${identity.comment || "(no comment)"} ${identity.responseMs}ms`);
933
+ });
934
+ out.line();
935
+ return 0;
936
+ }
937
+ async function cmdStatus(options) {
938
+ try {
939
+ const health = await api(options, "/health");
940
+ const ceremony = await api(options, "/custody/ceremony");
941
+ if (options.json) {
942
+ out.line(JSON.stringify({ health: health.body, ceremony: ceremony.body }, null, 2));
943
+ return 0;
944
+ }
945
+ out.line();
946
+ out.line(` API ${options.api}`);
947
+ out.line(` Realm ${options.realm}`);
948
+ if (health.status !== 200) {
949
+ out.fail(`API unreachable or unhealthy (HTTP ${health.status}).`);
950
+ return 1;
951
+ }
952
+ out.ok("API reachable");
953
+ const state = ceremony.body;
954
+ if (!state?.active) {
955
+ out.warn("No active ceremony \u2014 the platform has not been launched.");
956
+ out.step(`Run: fz genesis --mode ${RECOMMENDED_MODE}`);
957
+ return 0;
958
+ }
959
+ out.line(` Vault ${state.vaultUnlocked ? "UNLOCKED" : "LOCKED"}`);
960
+ if (!state.vaultUnlocked)
961
+ out.step("Run: fz unlock");
962
+ out.line();
963
+ return 0;
964
+ } catch (cause) {
965
+ out.fail(`Cannot reach ${options.api}: ${cause.message}`);
966
+ return 1;
967
+ }
968
+ }
969
+ async function cmdAgent(options, args) {
970
+ if (args[0] !== "install") {
971
+ out.fail("Usage: fz agent install [--apply] [--enrol]");
972
+ return 1;
973
+ }
974
+ const enrolTokenSourcePath = "/run/forgezero-enrol-token";
975
+ const enrolTokenCredentialPath = "/etc/forgezero/creds/enrol-token.cred";
976
+ const enrolStatePath = "/var/lib/forgezero/enrolment.json";
977
+ const gitCredentialPath = process.env.FZ_GIT_CREDENTIAL_PATH ?? "/etc/forgezero/creds/git-deploy-key.cred";
978
+ const gitPublicKeyPath = process.env.FZ_GIT_PUBLIC_KEY_PATH ?? "/etc/forgezero/git/deploy.pub";
979
+ const plan = planInstall({
980
+ capabilities: await readCapabilities(localRunner),
981
+ socketPath: process.env.FZ_SOCKET_PATH ?? DEFAULT_SOCKET,
982
+ seedPath: process.env.FZ_SEED_PATH ?? "/var/lib/forgezero/node.seed",
983
+ seedCredentialPath: process.env.FZ_SEED_CREDENTIAL_PATH,
984
+ gitCredentialPath,
985
+ gitPublicKeyPath,
986
+ generateGitIdentity: true,
987
+ controlSocketPath: process.env.FZ_CONTROL_SOCKET,
988
+ repository: process.env.FZ_DEPLOY_REPO,
989
+ branch: process.env.FZ_DEPLOY_BRANCH,
990
+ role: process.env.FZ_DEPLOY_ROLE,
991
+ deployRoot: process.env.FZ_DEPLOY_ROOT,
992
+ publicApiUrl: process.env.FZ_PUBLIC_API_URL,
993
+ deploymentEnvironment: parseAssignments(process.env.FZ_DEPLOY_ENV),
994
+ deploymentCredentials: parseAssignments(process.env.FZ_DEPLOY_CREDENTIALS),
995
+ pullDeployments: process.env.FZ_DEPLOY_PULL === "true",
996
+ ...options.enrol ? {
997
+ pullDeployments: true,
998
+ enrolTokenSourcePath,
999
+ enrolTokenCredentialPath,
1000
+ enrolStatePath,
1001
+ nodeLabel: process.env.FZ_NODE_LABEL,
1002
+ deployRoot: process.env.FZ_DEPLOY_ROOT ?? "/opt/forgezero"
1003
+ } : {},
1004
+ binPath: process.env.FZ_AGENT_BIN ?? "/usr/local/lib/forgezero/agent/fz-agent",
1005
+ sourceBinPath: process.env.FZ_AGENT_SOURCE_BIN ?? PACKAGED_AGENT_BIN,
1006
+ user: process.env.FZ_AGENT_USER,
1007
+ apiUrl: options.api,
1008
+ project: process.env.FZ_PROJECT,
1009
+ environment: process.env.FZ_ENVIRONMENT
1010
+ });
1011
+ if (options.json) {
1012
+ out.line(JSON.stringify(plan, null, 2));
1013
+ return 0;
1014
+ }
1015
+ out.line(renderPlan(plan));
1016
+ if (!options.apply) {
1017
+ out.line(" --- unit ---");
1018
+ out.line(plan.unit);
1019
+ for (const auxiliary of plan.auxiliaryUnits) {
1020
+ out.line(` --- unit ${auxiliary.path} ---`);
1021
+ out.line(auxiliary.unit);
1022
+ }
1023
+ out.line(" --- then ---");
1024
+ for (const step of plan.steps)
1025
+ out.step(`${step.command}`);
1026
+ return 0;
1027
+ }
1028
+ try {
1029
+ writeFileSync(plan.unitPath, plan.unit, { mode: 420 });
1030
+ out.ok(`Wrote ${plan.unitPath}`);
1031
+ for (const auxiliary of plan.auxiliaryUnits) {
1032
+ writeFileSync(auxiliary.path, auxiliary.unit, { mode: 420 });
1033
+ out.ok(`Wrote ${auxiliary.path}`);
1034
+ }
1035
+ if (options.enrol) {
1036
+ const prompt = Bun.spawn(["systemd-ask-password", "--timeout=0", "--echo=no", "ForgeZero one-time enrolment token:"], { stdin: "inherit", stdout: "pipe", stderr: "inherit" });
1037
+ const token = (await new Response(prompt.stdout).text()).trim();
1038
+ if (await prompt.exited !== 0 || !/^fze_[A-Za-z0-9_-]{40,100}$/.test(token)) {
1039
+ throw new Error("A valid fze_ enrolment token was not provided.");
1040
+ }
1041
+ writeFileSync(enrolTokenSourcePath, `${token}
1042
+ `, { mode: 384, flag: "wx" });
1043
+ }
1044
+ const transcript = await applyPlan(plan, localRunner);
1045
+ for (const step of transcript)
1046
+ out.ok(step.label);
1047
+ out.ok("Agent service and socket verified");
1048
+ out.line();
1049
+ out.line(" Add this machine-specific PUBLIC key as a read-only deploy key:");
1050
+ out.line();
1051
+ out.line(` ${readFileSync(gitPublicKeyPath, "utf8").trim()}`);
1052
+ out.line();
1053
+ return 0;
1054
+ } catch (cause) {
1055
+ if (options.enrol) {
1056
+ try {
1057
+ unlinkSync(enrolTokenSourcePath);
1058
+ } catch {}
1059
+ }
1060
+ out.fail(`Could not write ${plan.unitPath}: ${cause.message}`);
1061
+ out.step("Run this as root, or use --json and hand the unit to your provisioner.");
1062
+ return 1;
1063
+ }
1064
+ }
1065
+ async function cmdGenesis(options) {
1066
+ try {
1067
+ if (!thresholdMode(options.mode)) {
1068
+ throw new Error(`"${options.mode}" is not a threshold mode. ` + `Use one of: ${THRESHOLD_MODES.map((mode) => mode.id).join(", ")}.`);
1069
+ }
1070
+ const identity = await resolveIdentity(options.key, options.socket);
1071
+ let token = options.token;
1072
+ if (!token) {
1073
+ const path = `${process.env.FZ_SHARED_DIR ?? "/etc/forgezero"}/platform-invite.token`;
1074
+ try {
1075
+ token = (await Bun.file(path).text()).trim();
1076
+ } catch {
1077
+ throw new Error(`No --token given and ${path} is unreadable.
1078
+
1079
+ ` + ` On the server: the token is read automatically, or set FZ_SHARED_DIR
1080
+ ` + ` From a laptop: fz genesis --api https://<your-host> --token plt_...
1081
+
1082
+ ` + " The token is what setup.sh printed. It is single use.");
1083
+ }
1084
+ }
1085
+ useCustodyIdentity(identity, options.socket);
1086
+ const result = await runGenesis({
1087
+ token,
1088
+ api: (path, init) => api(options, path, init),
1089
+ modeId: options.mode,
1090
+ userKey: options.user,
1091
+ email: options.email,
1092
+ identity,
1093
+ socketPath: options.socket,
1094
+ onStep: (message) => out.step(message)
1095
+ });
1096
+ if (options.json) {
1097
+ out.line(JSON.stringify(result, null, 2));
1098
+ return 0;
1099
+ }
1100
+ out.line();
1101
+ out.ok("Platform launched. The vault is unlocked.");
1102
+ out.line();
1103
+ out.line(" RECOVERY PHRASE \u2014 written down now, or not at all.");
1104
+ out.line(" It is not stored, not recoverable, and opens your share");
1105
+ out.line(" on its own if the ssh key is ever lost.");
1106
+ out.line();
1107
+ result.phrase.forEach((word, index) => {
1108
+ const column = index % 4;
1109
+ process.stdout.write(` ${String(index + 1).padStart(2)}. ${word.padEnd(12)}`);
1110
+ if (column === 3)
1111
+ out.line();
1112
+ });
1113
+ out.line();
1114
+ out.line();
1115
+ if (result.fingerprint)
1116
+ out.line(` Seed fingerprint ${result.fingerprint}`);
1117
+ out.line();
1118
+ return 0;
1119
+ } catch (cause) {
1120
+ out.fail(cause.message);
1121
+ return 1;
1122
+ }
1123
+ }
1124
+ async function cmdUnlock(options) {
1125
+ try {
1126
+ const identity = options.phrase?.length ? undefined : await resolveIdentity(options.key, options.socket);
1127
+ const result = await runUnlock({
1128
+ api: (path, init) => api(options, path, init),
1129
+ userKey: options.user,
1130
+ identity,
1131
+ phrase: options.phrase,
1132
+ socketPath: options.socket,
1133
+ onStep: (message) => out.step(message)
1134
+ });
1135
+ if (options.json) {
1136
+ out.line(JSON.stringify(result, null, 2));
1137
+ return 0;
1138
+ }
1139
+ out.ok(`Vault unlocked. fingerprint ${result.fingerprint}`);
1140
+ return 0;
1141
+ } catch (cause) {
1142
+ out.fail(cause.message);
1143
+ return 1;
1144
+ }
1145
+ }
1146
+ async function runCommand(argv, options) {
1147
+ let command;
1148
+ try {
1149
+ ({ command } = splitAtSeparator(argv));
1150
+ } catch (error) {
1151
+ out.line(error instanceof RunError ? error.message : String(error));
1152
+ return 2;
1153
+ }
1154
+ const { createVault } = await import("@forgezero/vault");
1155
+ let secrets;
1156
+ try {
1157
+ secrets = await createVault().getAll();
1158
+ } catch (error) {
1159
+ out.line(`Could not read secrets: ${error instanceof Error ? error.message : String(error)}
1160
+ Is fz-agent running on this machine? \`fz agent install --apply\`.`);
1161
+ return 1;
1162
+ }
1163
+ const merged = mergeEnvironment(process.env, secrets, { preserveEnv: options.preserveEnv });
1164
+ if (!options.json)
1165
+ out.line(describeInjection(merged, Object.keys(secrets).length));
1166
+ return spawnWith(command, merged.env, (line) => out.line(line));
1167
+ }
1168
+ function usage() {
1169
+ out.line(`
1170
+ fz ${VERSION} \u2014 ForgeZero control surface
1171
+
1172
+ The platform is launched from here, not from the UI: a session needs a
1173
+ passkey, and a passkey needs a platform to register against. This breaks
1174
+ that circle using an SSH agent and a written recovery phrase.
1175
+
1176
+ COMMANDS
1177
+ fz keys List agent keys usable for custody
1178
+ fz status Platform, ceremony and vault state
1179
+ fz genesis Run the first custodian ceremony and launch
1180
+ fz unlock Reconstruct the seed and unlock the vault
1181
+ fz run -- <command> Start a process with vault values in its
1182
+ environment. A FALLBACK: anything using
1183
+ @forgezero/vault reads the socket and sees a
1184
+ rotation immediately, while an injected environment
1185
+ is frozen at spawn
1186
+ fz agent install Install the node agent as a systemd service, so
1187
+ applications on this box read secrets through a
1188
+ local socket instead of holding an API key
1189
+
1190
+ CEREMONY OPTIONS
1191
+ --key <fp|index> Which agent key to use for custody
1192
+ --mode <m-of-n> Threshold mode (default ${DEFAULT_MODE})
1193
+ one of: ${THRESHOLD_MODES.map((m) => m.id).join(", ")}
1194
+ --user <key> Custodian user key (env FZ_USER)
1195
+ --email <address> Custodian email (env FZ_EMAIL)
1196
+ --phrase "<24 words>" Unlock by phrase instead of the agent
1197
+ --token <plt_...> Platform invite from setup.sh; defaults to
1198
+ $FZ_SHARED_DIR/platform-invite.token
1199
+
1200
+ OPTIONS
1201
+ --api <url> API base URL (env FZ_API)
1202
+ defaults to http://localhost:8787
1203
+ FROM A LAPTOP pass your public host:
1204
+ fz genesis --api https://fz.example --token plt_...
1205
+ --realm <id> Realm to act in (env FZ_REALM, default platform)
1206
+ --socket <path> SSH agent socket (env SSH_AUTH_SOCK)
1207
+ --json Machine-readable output
1208
+ --apply Write the unit rather than printing it (root)
1209
+ --enrol Bind this machine with a one-time token prompted
1210
+ securely by systemd (tenant-owned compute)
1211
+
1212
+ CUSTODY FACTORS
1213
+ Every custodian share is sealed TWICE and either envelope alone opens it:
1214
+
1215
+ ssh a key in your agent, Ed25519 only, signed deterministically
1216
+ phrase a BIP-39 recovery phrase, printed once and never stored
1217
+
1218
+ Ed25519 is not a preference. The derived key must reproduce on every
1219
+ unlock, and RSA-PSS signatures are randomised \u2014 a share sealed under one
1220
+ could never be opened again.
1221
+ `);
1222
+ }
1223
+ if (import.meta.main) {
1224
+ await runCli();
1225
+ }
1226
+ async function runCli() {
1227
+ const { command, args, options } = parseOptions(process.argv.slice(2));
1228
+ let code = 0;
1229
+ switch (command) {
1230
+ case "keys":
1231
+ code = await cmdKeys(options);
1232
+ break;
1233
+ case "status":
1234
+ code = await cmdStatus(options);
1235
+ break;
1236
+ case "agent":
1237
+ code = await cmdAgent(options, args);
1238
+ break;
1239
+ case "genesis":
1240
+ code = await cmdGenesis(options);
1241
+ break;
1242
+ case "run":
1243
+ process.exit(await runCommand(process.argv.slice(3), options));
1244
+ break;
1245
+ case "unlock":
1246
+ code = await cmdUnlock(options);
1247
+ break;
1248
+ case "help":
1249
+ case "--help":
1250
+ case "-h":
1251
+ usage();
1252
+ break;
1253
+ case "version":
1254
+ case "--version":
1255
+ out.line(VERSION);
1256
+ break;
1257
+ default:
1258
+ out.fail(`Unknown command: ${command}`);
1259
+ usage();
1260
+ code = 1;
1261
+ }
1262
+ if (args.length > 0 && code === 0 && command !== "agent") {
1263
+ out.warn(`Ignored: ${args.join(" ")}`);
1264
+ }
1265
+ process.exit(code);
1266
+ }
1267
+ export {
1268
+ useCustodyIdentity,
1269
+ requestHeaders
1270
+ };