@forgezero/agent 0.1.9 → 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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @forgezero/agent
2
2
 
3
- **`fz-agent` so the application beside it holds no credential at all.**
3
+ **`fz` controls the machine; `fz-agent` runs its managed work.**
4
4
 
5
5
  The agent keeps a project-scoped copy of your vault in RAM and answers over a
6
6
  unix socket. `@forgezero/vault` prefers that socket over an API key whenever it
@@ -10,9 +10,24 @@ from it.
10
10
 
11
11
  ```bash
12
12
  bun add -g @forgezero/agent # or let `fz agent install` do it
13
+ fz --help
13
14
  ```
14
15
 
15
- Most people never install this directly:
16
+ The one public package installs both commands. Keeping bootstrap and the daemon
17
+ in one version prevents a newly installed `fz` from provisioning a different
18
+ agent protocol.
19
+
20
+ The operator command launches a platform, runs custody ceremonies, inspects
21
+ status, wraps processes, and installs the daemon:
22
+
23
+ ```bash
24
+ fz keys
25
+ fz status
26
+ fz genesis --mode 2-of-3
27
+ fz unlock
28
+ ```
29
+
30
+ Install the service explicitly:
16
31
 
17
32
  ```bash
18
33
  fz agent install --apply # writes a hardened systemd unit
@@ -104,6 +119,14 @@ to the keyed queue. A restart in either state resumes the exact commit; only an
104
119
  awaited successful pipeline writes `deployed`. Tenant nodes receive the same
105
120
  exact-commit request from the signed API claim path.
106
121
 
122
+ Repository read authorization is explicit per pipeline: public HTTPS, the
123
+ compute's systemd-sealed SSH deploy key, or a fine-grained HTTPS token selected
124
+ by a project-vault secret name. A signed claim contains the mode and secret name
125
+ only. The agent resolves the value from its scoped memory cache, limits the Git
126
+ header to the repository origin, never writes the token into a command, and
127
+ never exposes it to tenant pipeline steps. Automatic GitHub App token minting
128
+ can feed the same short-lived token mode later; it is not implemented today.
129
+
107
130
  An operator may also force a deployment and await the complete result over the
108
131
  private control socket:
109
132
 
@@ -0,0 +1,82 @@
1
+ import { type Capabilities, type ProvisionPlan } from '../provision';
2
+ /**
3
+ * `fz agent install` — run the provision plan on THIS machine.
4
+ *
5
+ * The plan itself is the agent's public `provision` subpath, with no transport, and
6
+ * that is the whole point: the platform runs the same steps over SSH when it
7
+ * enrols a compute. This file is the local executor and nothing else — it
8
+ * decides how to run a command here, never what the commands are.
9
+ *
10
+ * It was briefly the other thing. This module had its own machine detection
11
+ * calling `existsSync` directly and its own unit renderer, while the API had a
12
+ * `Check`-based preflight designed to run remotely. Two answers to "is this box
13
+ * ready" is one answer nobody can trust, and the local one drifts first because
14
+ * it is the one somebody runs while debugging.
15
+ */
16
+ export type { ProvisionPlan };
17
+ /** Parse NAME=value pairs used only for non-secret unit coordinates and credential paths. */
18
+ export declare function parseAssignments(value: string | undefined): Record<string, string> | undefined;
19
+ /**
20
+ * Answer the capability checks by running their commands.
21
+ *
22
+ * Running the command rather than calling `existsSync` is deliberate even
23
+ * though this is the local path: it means the answer here and the answer over
24
+ * SSH come from the same test, so a box that reports `attested` to an operator
25
+ * cannot report `enrolled` to the platform.
26
+ */
27
+ export declare function readCapabilities(run: (command: string) => Promise<{
28
+ stdout: string;
29
+ exitCode: number;
30
+ }>): Promise<Capabilities>;
31
+ /** Run a command locally. The SSH executor is the platform's half. */
32
+ export declare function localRunner(command: string): Promise<{
33
+ stdout: string;
34
+ exitCode: number;
35
+ }>;
36
+ export interface InstallOptions {
37
+ capabilities: Capabilities;
38
+ socketPath: string;
39
+ seedPath: string;
40
+ seedCredentialPath?: string;
41
+ gitCredentialPath?: string;
42
+ gitPublicKeyPath?: string;
43
+ generateGitIdentity?: boolean;
44
+ controlSocketPath?: string;
45
+ repository?: string;
46
+ branch?: string;
47
+ role?: string;
48
+ deployRoot?: string;
49
+ publicApiUrl?: string;
50
+ deploymentEnvironment?: Record<string, string>;
51
+ deploymentCredentials?: Record<string, string>;
52
+ pullDeployments?: boolean;
53
+ binPath?: string;
54
+ sourceBinPath?: string;
55
+ user?: string;
56
+ apiUrl?: string;
57
+ project?: string;
58
+ environment?: string;
59
+ enrolTokenSourcePath?: string;
60
+ enrolTokenCredentialPath?: string;
61
+ enrolStatePath?: string;
62
+ nodeLabel?: string;
63
+ }
64
+ export declare function planInstall(options: InstallOptions): ProvisionPlan;
65
+ /** Execute the same ordered plan the control plane executes over SSH. */
66
+ export declare function applyPlan(plan: ProvisionPlan, run: (command: string) => Promise<{
67
+ stdout: string;
68
+ exitCode: number;
69
+ }>): Promise<readonly {
70
+ label: string;
71
+ command: string;
72
+ exitCode: number;
73
+ }[]>;
74
+ /**
75
+ * What a reader needs to see before running any of it.
76
+ *
77
+ * Printed rather than executed by default. Installing a system service that
78
+ * holds key material should not happen because somebody typed a subcommand, and
79
+ * an operator who reads the unit first is one who can notice it is about to run
80
+ * as the wrong user.
81
+ */
82
+ export declare function renderPlan(plan: ProvisionPlan): string;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { type AgentIdentity } from '@forgezero/runtime/ssh-agent';
2
+ /**
3
+ * Choosing an SSH key for custody, safely.
4
+ *
5
+ * A real agent is not a clean room. It commonly holds forwarded keys whose
6
+ * upstream connection is gone, and confirm-on-use keys that wait for a human at
7
+ * a terminal nobody is sitting at. Neither fails — both **hang**, which during a
8
+ * genesis ceremony looks exactly like the platform being broken.
9
+ *
10
+ * So every key is probed under a timeout before it is offered, and the one
11
+ * actually chosen is proved deterministic before it is trusted with a share. An
12
+ * agent that signs differently twice would seal a share that can never be
13
+ * reopened, and that failure would surface only during recovery.
14
+ */
15
+ export declare const PROBE_TIMEOUT_MS = 2000;
16
+ export interface UsableIdentity extends AgentIdentity {
17
+ /** Milliseconds the agent took to sign. Slow keys are usually forwarded. */
18
+ responseMs: number;
19
+ }
20
+ /**
21
+ * Every Ed25519 key in the agent that actually responds.
22
+ *
23
+ * Returns an empty array rather than throwing when the agent holds nothing
24
+ * usable — the caller has a better error to give than this function does.
25
+ */
26
+ export declare function usableIdentities(socketPath?: string): Promise<UsableIdentity[]>;
27
+ /**
28
+ * Derive the custody key for a chosen identity, having proved it reproduces.
29
+ *
30
+ * `assertDeterministic` signs twice and compares. It costs one extra signature
31
+ * and removes the only failure mode that is invisible until recovery.
32
+ */
33
+ export declare function custodyKeyFor(identity: AgentIdentity, socketPath?: string): Promise<Uint8Array>;
34
+ /** Match a key by fingerprint prefix, comment, or 1-based index. */
35
+ export declare function selectIdentity(identities: UsableIdentity[], selector: string): UsableIdentity | null;
@@ -0,0 +1,79 @@
1
+ import { type SealedShare } from '@forgezero/runtime/custody-share';
2
+ import type { UsableIdentity } from './custody';
3
+ /**
4
+ * The genesis ceremony, driven from a terminal.
5
+ *
6
+ * Every value that matters is produced HERE and only sealed material crosses
7
+ * the wire:
8
+ *
9
+ * phrase generated locally, printed once, never transmitted or stored
10
+ * ssh key derived locally from an agent signature, never transmitted
11
+ *
12
+ * The API receives the derived 32-byte keys because that is what seals a share,
13
+ * and it holds them for exactly the length of one request. Nothing here gives
14
+ * the CLI a privileged path — it calls the same endpoints the browser does, and
15
+ * a bypass that existed for bootstrapping would exist for an attacker too.
16
+ */
17
+ export interface Transport {
18
+ (path: string, init?: {
19
+ method?: string;
20
+ body?: unknown;
21
+ }): Promise<{
22
+ status: number;
23
+ body: unknown;
24
+ }>;
25
+ }
26
+ export interface GenesisResult {
27
+ ceremonyKey: string;
28
+ phrase: string[];
29
+ fingerprint: string;
30
+ activated: boolean;
31
+ }
32
+ /**
33
+ * Run a single-custodian genesis.
34
+ *
35
+ * Deliberately limited to the operator running the command. A multi-custodian
36
+ * genesis needs each person at their own terminal with their own agent, which
37
+ * is a coordination problem rather than a code one — and pretending to do it
38
+ * from one shell would mean one machine briefly holding every share.
39
+ */
40
+ export declare function runGenesis(args: {
41
+ api: Transport;
42
+ modeId: string;
43
+ userKey: string;
44
+ email: string;
45
+ identity: UsableIdentity;
46
+ /** The `plt_` token setup.sh wrote. Required — see below. */
47
+ token: string;
48
+ displayName?: string;
49
+ socketPath?: string;
50
+ onStep?: (message: string) => void;
51
+ }): Promise<GenesisResult>;
52
+ /**
53
+ * Reconstruct the seed and unlock.
54
+ *
55
+ * A restart genuinely locks the vault — residency is derived from memory, never
56
+ * a stored flag — so this is the routine every reboot needs, not an exceptional
57
+ * recovery path.
58
+ */
59
+ export declare function runUnlock(args: {
60
+ api: Transport;
61
+ userKey: string;
62
+ identity?: UsableIdentity;
63
+ phrase?: string[];
64
+ socketPath?: string;
65
+ /**
66
+ * This custodian's sealed envelope, read from the ceremony.
67
+ *
68
+ * Supplied by the caller because only they know which ceremony they are
69
+ * unlocking. Opening it is a local step now, so no factor travels.
70
+ */
71
+ sealed?: SealedShare;
72
+ /** The salt the phrase wrapping key was derived over. */
73
+ phraseSalt?: string;
74
+ onStep?: (message: string) => void;
75
+ }): Promise<{
76
+ fingerprint: string;
77
+ }>;
78
+ /** Resolve `--key` to a usable identity, with an actionable error if it cannot. */
79
+ export declare function resolveIdentity(selector: string | undefined, socketPath?: string): Promise<UsableIdentity>;
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bun
2
+ import { type AgentIdentity } from '@forgezero/runtime/ssh-agent';
3
+ /**
4
+ * What every CLI request carries.
5
+ *
6
+ * Extracted and exported because the `origin` header below is load-bearing and
7
+ * was invisible: `fz --help` proved the binary starts, and every command that
8
+ * CHANGED anything was answered 403 by the CSRF rule with nothing in between to
9
+ * notice. A header nobody can see is a header nobody maintains.
10
+ */
11
+ export declare function requestHeaders(apiBase: string, cookie: string | null): Record<string, string>;
12
+ export declare function useCustodyIdentity(identity: AgentIdentity, socketPath?: string): void;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Operator process injection, shipped beside the agent command.
3
+ *
4
+ * `fz run -- <command>` — hand secrets to a process that cannot ask for them.
5
+ *
6
+ * This is the FALLBACK, and it is worth saying so at the top of the file. The
7
+ * agent serves secrets over a unix socket and anything using `@forgezero/vault`
8
+ * reads them there, which means a rotation reaches a running process. An
9
+ * injected environment cannot rotate: a process started before a rotation runs
10
+ * on the old value until somebody restarts it, and nothing tells them.
11
+ *
12
+ * It exists because a great deal of software will never read a socket, and
13
+ * "rewrite your build tool" is not an adoption path.
14
+ *
15
+ * ## What an environment variable costs
16
+ *
17
+ * On Linux `/proc/<pid>/environ` is readable by the same user, and any child
18
+ * inherits everything. That is the standard trade and it is not hidden here:
19
+ * the whole point of the socket path is that it does not make that trade.
20
+ *
21
+ * Values never reach argv, because argv is world-readable in `ps` — which is a
22
+ * different and much worse exposure than the environment.
23
+ */
24
+ export declare class RunError extends Error {
25
+ readonly code: 'NO_COMMAND' | 'NO_SEPARATOR' | 'BAD_NAME';
26
+ constructor(code: 'NO_COMMAND' | 'NO_SEPARATOR' | 'BAD_NAME', message: string);
27
+ }
28
+ /**
29
+ * Split fz's own arguments from the child's.
30
+ *
31
+ * `--` is the boundary and it is not optional. Without it `fz run node --watch`
32
+ * would have fz eat `--watch`, and the failure is a flag that silently does not
33
+ * reach the program somebody is debugging.
34
+ *
35
+ * Everything after the FIRST separator belongs to the child, separators
36
+ * included: `fz run -- sh -c 'x -- y'` has to pass the inner one through.
37
+ */
38
+ export declare function splitAtSeparator(argv: readonly string[]): {
39
+ own: string[];
40
+ command: string[];
41
+ };
42
+ export interface MergeOptions {
43
+ /** Keep an existing value when both sides have the name. Default false. */
44
+ preserveEnv?: boolean;
45
+ }
46
+ export interface MergeResult {
47
+ env: Record<string, string>;
48
+ /** Names present in both. Reported so a collision is never silent. */
49
+ collisions: string[];
50
+ /** Names the shell cannot express, refused rather than mangled. */
51
+ refused: string[];
52
+ }
53
+ /**
54
+ * Merge vault values into an environment.
55
+ *
56
+ * The vault wins by default: a caller running `fz run` is asking for vault
57
+ * values, and quietly preferring a stale variable already in the shell is how
58
+ * somebody debugs a wrong credential for an hour.
59
+ *
60
+ * `--preserve-env` inverts it for the case where a wrapper genuinely wants to
61
+ * override. Either way the COLLISIONS are returned so the caller can say which
62
+ * names were affected. Silence is what makes this confusing, not the direction.
63
+ */
64
+ export declare function mergeEnvironment(base: Record<string, string | undefined>, secrets: Record<string, string>, options?: MergeOptions): MergeResult;
65
+ /**
66
+ * What to print before handing over.
67
+ *
68
+ * Names only. A wrapper that echoed values would put every secret into whatever
69
+ * captured the build log, which is usually the one place they are kept longest.
70
+ */
71
+ export declare function describeInjection(result: MergeResult, count: number): string;
72
+ /**
73
+ * Turn a child's exit into this process's exit.
74
+ *
75
+ * A wrapper that always exits 0 breaks every CI pipeline it is put in front of,
76
+ * silently, by turning a failed build into a passing one. A signal death is
77
+ * reported the way a shell reports it — 128 plus the signal number — so
78
+ * `fz run -- make` behaves like `make` for anything reading the code.
79
+ */
80
+ export declare function exitCodeFor(status: {
81
+ code: number | null;
82
+ signal: string | null;
83
+ }): number;
84
+ /**
85
+ * Start the child and become its exit code.
86
+ *
87
+ * Separated from the command handler so it can be driven by a test with real
88
+ * processes. The interesting behaviour is entirely here — inherited stdio,
89
+ * forwarded signals, propagated status — and none of it is provable against a
90
+ * mock.
91
+ */
92
+ export declare function spawnWith(command: readonly string[], env: Record<string, string>, report?: (line: string) => void): Promise<number>;
@@ -0,0 +1 @@
1
+ export {};
@@ -1,5 +1,5 @@
1
1
  import type { NodeKeyPair } from '@forgezero/runtime/identity';
2
- import type { DeploymentManager, DeploymentResult } from './deployment';
2
+ import type { DeploymentManager, DeploymentResult, GitSourceAuth } from './deployment';
3
3
  export interface RemoteDeploymentClaim {
4
4
  runKey: string;
5
5
  pipelineKey: string;
@@ -12,6 +12,7 @@ export interface RemoteDeploymentClaim {
12
12
  branch: string;
13
13
  role: string;
14
14
  knownHosts?: string;
15
+ auth?: GitSourceAuth;
15
16
  };
16
17
  }
17
18
  export interface DeploymentPullOptions {
@@ -27,6 +27,19 @@ export interface DeploymentResult {
27
27
  ok: boolean;
28
28
  phases: readonly RunResult[];
29
29
  }
30
+ /**
31
+ * How the credential-bearing agent may read one server-owned Git source.
32
+ * Secret values never appear in a deployment claim or checked-in pipeline.
33
+ */
34
+ export type GitSourceAuth = {
35
+ kind: 'public';
36
+ } | {
37
+ kind: 'node-ssh';
38
+ } | {
39
+ kind: 'vault-token';
40
+ secret: string;
41
+ username?: string;
42
+ };
30
43
  export interface DeploymentOptions {
31
44
  /** Queue key. Same project/environment is serial; other managers may run in parallel. */
32
45
  key: string;
@@ -35,6 +48,7 @@ export interface DeploymentOptions {
35
48
  role: string;
36
49
  root: string;
37
50
  publicApiUrl?: string;
51
+ sourceAuth?: GitSourceAuth;
38
52
  gitCredentialPath?: string;
39
53
  knownHostsPath?: string;
40
54
  /** Server-owned, operator-pinned host keys for a dynamically assigned source. */
package/dist/fz-agent.js CHANGED
@@ -448,10 +448,39 @@ function createDeploymentManager(options) {
448
448
  renameSync(next, knownHostsPath);
449
449
  chmodSync2(knownHostsPath, 384);
450
450
  };
451
- const gitEnvironment = () => {
452
- if (/^https:\/\//i.test(options.repository)) {
451
+ const gitEnvironment = async () => {
452
+ const https = /^https:\/\//i.test(options.repository);
453
+ const auth = options.sourceAuth ?? (https ? { kind: "public" } : { kind: "node-ssh" });
454
+ if (auth.kind === "public") {
455
+ if (!https)
456
+ throw new DeploymentError("SOURCE_FAILED", "Public Git sources must use HTTPS.");
453
457
  return { GIT_TERMINAL_PROMPT: "0" };
454
458
  }
459
+ if (auth.kind === "vault-token") {
460
+ if (!https)
461
+ throw new DeploymentError("SOURCE_FAILED", "Vault Git tokens may only be sent to HTTPS sources.");
462
+ if (!/^[A-Z_][A-Z0-9_]{0,127}$/.test(auth.secret)) {
463
+ throw new DeploymentError("SOURCE_FAILED", "The Git token secret name is invalid.");
464
+ }
465
+ if (!options.cache) {
466
+ throw new DeploymentError("SECRET_MISSING", `Git source credential ${auth.secret} is unavailable.`);
467
+ }
468
+ const username = auth.username ?? "x-access-token";
469
+ if (!/^[A-Za-z0-9._@+-]{1,128}$/.test(username)) {
470
+ throw new DeploymentError("SOURCE_FAILED", "The Git token username is invalid.");
471
+ }
472
+ const token = await options.cache.get(auth.secret);
473
+ if (!token || token.length > 8192 || /[\r\n\0]/.test(token)) {
474
+ throw new DeploymentError("SOURCE_FAILED", `Git source credential ${auth.secret} is malformed.`);
475
+ }
476
+ const origin = new URL(options.repository).origin;
477
+ return {
478
+ GIT_TERMINAL_PROMPT: "0",
479
+ GIT_CONFIG_COUNT: "1",
480
+ GIT_CONFIG_KEY_0: `http.${origin}/.extraHeader`,
481
+ GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`${username}:${token}`).toString("base64")}`
482
+ };
483
+ }
455
484
  if (!gitCredentialPath) {
456
485
  throw new DeploymentError("SOURCE_FAILED", "No Git deploy-key credential was loaded for the agent.");
457
486
  }
@@ -474,7 +503,7 @@ function createDeploymentManager(options) {
474
503
  }
475
504
  const stamp = `${new Date(now()).toISOString().replace(/[-:.TZ]/g, "")}-${process.pid}-${randomUUID().slice(0, 8)}`;
476
505
  const release = join(options.root, "releases", stamp);
477
- const env = gitEnvironment();
506
+ const env = await gitEnvironment();
478
507
  await checked({
479
508
  command: `test -d ${quote(join(options.root, "releases"))} && test -w ${quote(join(options.root, "releases"))}`
480
509
  }, "SOURCE_FAILED");
@@ -565,7 +594,7 @@ function createDeploymentManager(options) {
565
594
  async latestRevision() {
566
595
  const result = await checked({
567
596
  command: `git ls-remote --exit-code ${quote(options.repository)} ${quote(`refs/heads/${options.branch}`)}`,
568
- env: gitEnvironment()
597
+ env: await gitEnvironment()
569
598
  }, "SOURCE_FAILED");
570
599
  const revision = result.output.trim().split(/\s+/)[0] ?? "";
571
600
  if (!/^[a-f0-9]{40}$/i.test(revision)) {
@@ -2633,7 +2662,7 @@ async function applyMetalIsolation(profile, exec = defaultExec) {
2633
2662
  }
2634
2663
 
2635
2664
  // src/index.ts
2636
- var VERSION = "0.1.9";
2665
+ var VERSION = "0.1.10";
2637
2666
  function loadOrCreateSeed(path) {
2638
2667
  if (existsSync9(path)) {
2639
2668
  const seed2 = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
@@ -2974,6 +3003,7 @@ if (import.meta.main) {
2974
3003
  repository: source.repository,
2975
3004
  branch: source.branch,
2976
3005
  role: source.role,
3006
+ sourceAuth: source.auth,
2977
3007
  root,
2978
3008
  publicApiUrl: process.env.FZ_PUBLIC_API_URL,
2979
3009
  environment: Object.fromEntries((process.env.FZ_DEPLOY_ENV_NAMES ?? "").split(",").filter(Boolean).map((name) => {
@@ -3021,7 +3051,8 @@ if (import.meta.main) {
3021
3051
  claim.pipelineKey,
3022
3052
  claim.source.repository,
3023
3053
  claim.source.branch,
3024
- claim.source.role
3054
+ claim.source.role,
3055
+ JSON.stringify(claim.source.auth ?? null)
3025
3056
  ].join("\x00");
3026
3057
  const existing = managers.get(cacheKey);
3027
3058
  if (existing)