@forgezero/agent 0.1.39 → 0.1.40

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
@@ -27,7 +27,6 @@ authorization, then continues custody in the browser. Passkey PRF output and
27
27
  both recovery phrases never enter the terminal:
28
28
 
29
29
  ```bash
30
- fz keys
31
30
  fz status
32
31
  fz genesis --mode 2-of-3 --api https://api.example --app https://console.example
33
32
  fz unlock --phrase-file /secure/offline-phrase.txt # run once per participating custodian
@@ -56,6 +55,24 @@ sudo fz bootstrap status
56
55
  sudo fz bootstrap repair --bootstrap-config ./original.json --apply
57
56
  ```
58
57
 
58
+ Interactive bootstrap prompts only for missing typed inputs and validates every
59
+ answer before it changes the host. It must then render a secret-free plan, apply
60
+ idempotently, and verify the external and local result. Initial platform bring-up
61
+ has exactly three attended owner gates:
62
+
63
+ 1. SMTP host, port, sender, optional user, and an owner-only password file; the
64
+ resulting provider must pass its configured health/send acceptance.
65
+ 2. The separate Cloudflare phase consumes owner-only management and KV-runtime
66
+ token files, reconciles Tunnel/hostname state, and emits the node handoff; the
67
+ normal API/Agent never receives the management token.
68
+ 3. `prepare` prints that machine's read-only Git deploy public key. The owner adds
69
+ it to the repository and confirms completion; bootstrap then proves repository
70
+ access before the first deployment.
71
+
72
+ Browser authentication is deliberately not automated away. If npm or another
73
+ provider prints an authentication URL, leave the command attached to its TTY,
74
+ open the URL, complete the passkey flow, and let the same command continue.
75
+
59
76
  Platform database participation is explicit in the private JSON configuration.
60
77
  The three genesis nodes use `database.agency: "member"`; later DB+API joiners
61
78
  normally use `database.agency: "none"`, which starts a Coordinator and DBServer
@@ -93,31 +110,87 @@ but remains separate from the root host install so its management token never
93
110
  enters the API or Agent service environment:
94
111
 
95
112
  ```bash
96
- chmod 600 cloudflare-bootstrap.json cloudflare-management.token
113
+ chmod 600 cloudflare-bootstrap.json cloudflare-management.token cloudflare-runtime-kv.token
97
114
  fz bootstrap platform cloudflare --bootstrap-config ./cloudflare-bootstrap.json
98
115
  fz bootstrap platform cloudflare --bootstrap-config ./cloudflare-bootstrap.json --apply
99
116
  # After every host is bootstrapped and publishing healthy KV state:
100
117
  fz bootstrap platform cloudflare verify --bootstrap-config ./cloudflare-handoff.json
101
118
  ```
102
119
 
120
+ Tenant-owned public origins use the identical reviewed request and handoff with
121
+ `fz bootstrap tenant cloudflare ...`; `fz bootstrap tenant` consumes only that
122
+ tenant node's handoff. Both host kinds seal the connector and supplied KV token
123
+ and can repair after the plaintext handoff has been removed.
124
+
103
125
  The reviewed JSON has `format: 1`, kind
104
126
  `forgezero-cloudflare-bootstrap-request`, a `checkpointPath`, the typed
105
- `coordinates`, and `tokenFiles` containing file paths only. `coordinates.nodes`
106
- explicitly lists each node name, origin hostname, loopback service, Tunnel name
107
- and Access application name. Relative token, checkpoint and Worker project
108
- paths resolve beside the config file. Plan is offline. Apply creates/reuses one
109
- shared KV namespace, Worker and Access service token plus one Tunnel, Access
110
- application, ingress rule and DNS record per node. The command prints only
111
- resource IDs and per-node handoff paths. The complete owner-only checkpoint
112
- stays on the operator laptop; `<checkpoint>.hosts/<node>.json` contains only
113
- that node's connector and required runtime capabilities and is the file copied
114
- to the host for `fz bootstrap platform`. Management tokens are never persisted.
115
- Each host pins cloudflared diagnostics to loopback and bootstrap/status requires
116
- the expected Tunnel UUID, a connector UUID, and all four documented edge
117
- connections—not merely an active process. The final `verify` is read-only: it
118
- uses the checkpoint's Access service credential only on the operator laptop to
119
- probe every origin, then probes every stable Worker domain without privileged
120
- headers. Its printed evidence contains hostnames and HTTP status codes only.
127
+ `coordinates`, and token **file paths only**. The recommended token split is:
128
+
129
+ ```json
130
+ {
131
+ "format": 1,
132
+ "kind": "forgezero-cloudflare-bootstrap-request",
133
+ "checkpointPath": "./cloudflare-handoff.json",
134
+ "coordinates": {
135
+ "accountId": "0123456789abcdef0123456789abcdef",
136
+ "zoneId": "0123456789abcdef0123456789abcdef",
137
+ "hostname": "dev-api-n1.example.com",
138
+ "service": "http://127.0.0.1:3000",
139
+ "tunnelName": "dev-api-n1",
140
+ "kvNamespaceId": "0123456789abcdef0123456789abcdef",
141
+ "nodes": [
142
+ {
143
+ "nodeName": "dev-api-n1",
144
+ "hostname": "dev-api-n1.example.com",
145
+ "service": "http://127.0.0.1:3000",
146
+ "tunnelName": "dev-api-n1"
147
+ }
148
+ ]
149
+ },
150
+ "tokenFiles": {
151
+ "managementApiTokenFile": "./cloudflare-management.token",
152
+ "runtimeApiTokenFile": "./cloudflare-runtime-kv.token"
153
+ }
154
+ }
155
+ ```
156
+
157
+ The management token has exact-account **Cloudflare Tunnel Write** and
158
+ exact-zone **DNS Write**. The runtime token has exact-account **Workers KV
159
+ Storage Write**. Apply creates or adopts one remotely managed Tunnel per node,
160
+ checkpoints its connector token, configures the public-hostname ingress, and
161
+ reconciles the exact proxied CNAME. DNS reconciliation lists the hostname
162
+ without a type filter: it updates one existing CNAME, creates only when absent,
163
+ and refuses ambiguity or an incompatible A/AAAA/other owner before changing
164
+ Tunnel ingress. It does not create a Worker, KV namespace, Access application,
165
+ Access service token, or Cloudflare API token.
166
+
167
+ The Worker and its KV/DO bindings are deployed separately through Cloudflare's
168
+ Git integration. Durable Objects are reached through the Worker's binding, not
169
+ with a Cloudflare REST token. The complete 0600 checkpoint stays on the
170
+ operator machine; `<checkpoint>.hosts/<node>.json` contains only that node's
171
+ connector plus the supplied KV runtime token. Host bootstrap seals them as the
172
+ `cloudflared-token` and `cloudflare-kv-token` systemd credentials and removes
173
+ the plaintext node handoff. The API resolves `cloudflare.kvApiToken` from the
174
+ platform Vault first and falls back to the same systemd credential while the
175
+ Vault is locked or unavailable. Management credentials are never persisted or
176
+ loaded into the API/Agent service.
177
+
178
+ Platform and tenant bootstrap configs may declare a bounded
179
+ `deploymentCredentials` map from the exact pipeline secret name to its
180
+ encrypted `.cred` source, for example
181
+ `{"SMTP_PASSWORD":"/etc/forgezero/creds/SMTP_PASSWORD.cred"}`. The supervised
182
+ Agent loads only those named credentials. A deployment asks for the same name;
183
+ the Agent reads its project-scoped in-memory Vault replica first and uses the
184
+ same-name systemd credential only until the replica can supply it. There is no
185
+ directory scan, cross-project lookup, or differently named fallback.
186
+
187
+ This is a versioned runtime schema, not a naming convention. The enrolled
188
+ binding supplies the exact project and environment; `credential-schema.ts`
189
+ maps a requested name to that environment's Vault cache coordinate. Platform
190
+ and tenant computes use this Vault-first policy. Physical metal has no project
191
+ Vault and loads only its fixed `metal-agent-seed` systemd credential. Attended
192
+ operator Cloudflare/bootstrap tokens are owner-only input files and are never a
193
+ runtime fallback. The schema is exported as `@forgezero/agent/credential-schema`.
121
194
 
122
195
  ## What changes for an application
123
196
 
@@ -93,6 +93,8 @@ var checked = async (run, input, label) => {
93
93
  return result;
94
94
  };
95
95
  async function validateReleaseDirectory(directory, release, run) {
96
+ chmodSync(directory, 493);
97
+ chmodSync(join(directory, "dist"), 493);
96
98
  const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
97
99
  if (manifest.name !== release.package || manifest.version !== release.version) {
98
100
  throw new Error("agent update manifest does not match the selected release");
@@ -143,19 +145,19 @@ async function stageAgentRelease(releaseInput, options) {
143
145
  if (!timingSafeEqual(actual, expected))
144
146
  throw new Error("agent update integrity mismatch");
145
147
  writeFileSync(archive, bytes, { mode: 384, flag: "wx" });
146
- await checked(run, {
147
- command: "/usr/bin/tar",
148
- args: [
149
- "-xzf",
150
- archive,
151
- "-C",
152
- unpacked,
153
- "--strip-components=1",
154
- "package/package.json",
155
- "package/dist/fz-agent.js",
156
- "package/dist/fz.js"
157
- ]
158
- }, "agent update extraction");
148
+ for (const [member, relative] of [
149
+ ["package/package.json", "package.json"],
150
+ ["package/dist/fz-agent.js", "dist/fz-agent.js"],
151
+ ["package/dist/fz.js", "dist/fz.js"]
152
+ ]) {
153
+ const extracted = await checked(run, {
154
+ command: "/usr/bin/tar",
155
+ args: ["-xOzf", archive, member]
156
+ }, `agent update extraction of ${member}`);
157
+ const destination = join(unpacked, relative);
158
+ mkdirSync(dirname(destination), { recursive: true, mode: 448 });
159
+ writeFileSync(destination, extracted.output, { mode: 384, flag: "wx" });
160
+ }
159
161
  await validateReleaseDirectory(unpacked, release, run);
160
162
  if (!existsSync(finalDirectory)) {
161
163
  renameSync(unpacked, finalDirectory);
@@ -744,7 +746,7 @@ async function postSignedNode(options, path, body) {
744
746
  }
745
747
 
746
748
  // src/version.ts
747
- var VERSION3 = "0.1.39";
749
+ var VERSION3 = "0.1.40";
748
750
 
749
751
  // src/agent-heartbeat.ts
750
752
  var unquote = (value) => value.replace(/^['"]|['"]$/g, "");
@@ -93,6 +93,8 @@ var checked = async (run, input, label) => {
93
93
  return result;
94
94
  };
95
95
  async function validateReleaseDirectory(directory, release, run) {
96
+ chmodSync(directory, 493);
97
+ chmodSync(join(directory, "dist"), 493);
96
98
  const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
97
99
  if (manifest.name !== release.package || manifest.version !== release.version) {
98
100
  throw new Error("agent update manifest does not match the selected release");
@@ -143,19 +145,19 @@ async function stageAgentRelease(releaseInput, options) {
143
145
  if (!timingSafeEqual(actual, expected))
144
146
  throw new Error("agent update integrity mismatch");
145
147
  writeFileSync(archive, bytes, { mode: 384, flag: "wx" });
146
- await checked(run, {
147
- command: "/usr/bin/tar",
148
- args: [
149
- "-xzf",
150
- archive,
151
- "-C",
152
- unpacked,
153
- "--strip-components=1",
154
- "package/package.json",
155
- "package/dist/fz-agent.js",
156
- "package/dist/fz.js"
157
- ]
158
- }, "agent update extraction");
148
+ for (const [member, relative] of [
149
+ ["package/package.json", "package.json"],
150
+ ["package/dist/fz-agent.js", "dist/fz-agent.js"],
151
+ ["package/dist/fz.js", "dist/fz.js"]
152
+ ]) {
153
+ const extracted = await checked(run, {
154
+ command: "/usr/bin/tar",
155
+ args: ["-xOzf", archive, member]
156
+ }, `agent update extraction of ${member}`);
157
+ const destination = join(unpacked, relative);
158
+ mkdirSync(dirname(destination), { recursive: true, mode: 448 });
159
+ writeFileSync(destination, extracted.output, { mode: 384, flag: "wx" });
160
+ }
159
161
  await validateReleaseDirectory(unpacked, release, run);
160
162
  if (!existsSync(finalDirectory)) {
161
163
  renameSync(unpacked, finalDirectory);
@@ -93,6 +93,8 @@ var checked = async (run, input, label) => {
93
93
  return result;
94
94
  };
95
95
  async function validateReleaseDirectory(directory, release, run) {
96
+ chmodSync(directory, 493);
97
+ chmodSync(join(directory, "dist"), 493);
96
98
  const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
97
99
  if (manifest.name !== release.package || manifest.version !== release.version) {
98
100
  throw new Error("agent update manifest does not match the selected release");
@@ -143,19 +145,19 @@ async function stageAgentRelease(releaseInput, options) {
143
145
  if (!timingSafeEqual(actual, expected))
144
146
  throw new Error("agent update integrity mismatch");
145
147
  writeFileSync(archive, bytes, { mode: 384, flag: "wx" });
146
- await checked(run, {
147
- command: "/usr/bin/tar",
148
- args: [
149
- "-xzf",
150
- archive,
151
- "-C",
152
- unpacked,
153
- "--strip-components=1",
154
- "package/package.json",
155
- "package/dist/fz-agent.js",
156
- "package/dist/fz.js"
157
- ]
158
- }, "agent update extraction");
148
+ for (const [member, relative] of [
149
+ ["package/package.json", "package.json"],
150
+ ["package/dist/fz-agent.js", "dist/fz-agent.js"],
151
+ ["package/dist/fz.js", "dist/fz.js"]
152
+ ]) {
153
+ const extracted = await checked(run, {
154
+ command: "/usr/bin/tar",
155
+ args: ["-xOzf", archive, member]
156
+ }, `agent update extraction of ${member}`);
157
+ const destination = join(unpacked, relative);
158
+ mkdirSync(dirname(destination), { recursive: true, mode: 448 });
159
+ writeFileSync(destination, extracted.output, { mode: 384, flag: "wx" });
160
+ }
159
161
  await validateReleaseDirectory(unpacked, release, run);
160
162
  if (!existsSync(finalDirectory)) {
161
163
  renameSync(unpacked, finalDirectory);
@@ -19,6 +19,12 @@ export interface PlatformBootstrapConfig {
19
19
  branch: string;
20
20
  deployRoot?: string;
21
21
  telemetryEndpoint: string;
22
+ /**
23
+ * Pipeline secret name -> encrypted systemd credential source. The Agent asks
24
+ * its project Vault replica first and uses only the same-name entry here while
25
+ * that replica is unavailable or has not received the secret yet.
26
+ */
27
+ deploymentCredentials?: Record<string, string>;
22
28
  database: {
23
29
  role: DatabaseBootstrapRole;
24
30
  /** Genesis master/joiners are Agency members; elastic joiners normally use `none`. */
@@ -69,6 +75,8 @@ export interface TenantBootstrapConfig {
69
75
  realm: string;
70
76
  nodeHostname: string;
71
77
  telemetryEndpoint: string;
78
+ /** Same Vault-first, same-name systemd fallback contract as platform deploys. */
79
+ deploymentCredentials?: Record<string, string>;
72
80
  enrolTokenFile: string;
73
81
  repository?: string;
74
82
  branch?: string;
@@ -76,6 +84,11 @@ export interface TenantBootstrapConfig {
76
84
  deployRoot?: string;
77
85
  software?: SoftwareRequirement[];
78
86
  installCloudflared?: boolean;
87
+ /** Optional node-specific attended Tunnel/KV handoff, identical to platform hosts. */
88
+ cloudflareHandoff?: {
89
+ handoffFile: string;
90
+ nodeName: string;
91
+ };
79
92
  /** Local-only runner authority. The API receives only the resulting node scope. */
80
93
  bootstrapRunner?: {
81
94
  sshPrivateKeyFile: string;