@serfab/cadre-host 0.8.1 → 0.10.0

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 (198) hide show
  1. package/dist/auth/index.d.ts +2 -0
  2. package/dist/auth/index.js +1 -0
  3. package/dist/auth/index.js.map +1 -1
  4. package/dist/auth/self-label.d.ts +36 -0
  5. package/dist/auth/self-label.js +57 -0
  6. package/dist/auth/self-label.js.map +1 -0
  7. package/dist/auth/trust-circle.d.ts +36 -9
  8. package/dist/auth/trust-circle.js +40 -20
  9. package/dist/auth/trust-circle.js.map +1 -1
  10. package/dist/auth/types.d.ts +2 -2
  11. package/dist/bin/host.js +291 -54
  12. package/dist/bin/host.js.map +1 -1
  13. package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
  14. package/dist/donation/__tests__/fake-orchestrator.js +180 -0
  15. package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
  16. package/dist/donation/donation-service.d.ts +361 -0
  17. package/dist/donation/donation-service.js +763 -0
  18. package/dist/donation/donation-service.js.map +1 -0
  19. package/dist/donation/donation-store.d.ts +49 -0
  20. package/dist/donation/donation-store.js +148 -0
  21. package/dist/donation/donation-store.js.map +1 -0
  22. package/dist/donation/donation-supervisor.d.ts +167 -0
  23. package/dist/donation/donation-supervisor.js +341 -0
  24. package/dist/donation/donation-supervisor.js.map +1 -0
  25. package/dist/donation/grant-service.d.ts +71 -0
  26. package/dist/donation/grant-service.js +163 -0
  27. package/dist/donation/grant-service.js.map +1 -0
  28. package/dist/donation/grant-store.d.ts +38 -0
  29. package/dist/donation/grant-store.js +135 -0
  30. package/dist/donation/grant-store.js.map +1 -0
  31. package/dist/donation/index.d.ts +20 -0
  32. package/dist/donation/index.js +17 -0
  33. package/dist/donation/index.js.map +1 -0
  34. package/dist/donation/types.d.ts +201 -0
  35. package/dist/donation/types.js +40 -0
  36. package/dist/donation/types.js.map +1 -0
  37. package/dist/index.d.ts +9 -5
  38. package/dist/index.js +7 -3
  39. package/dist/index.js.map +1 -1
  40. package/dist/installer/config.d.ts +26 -0
  41. package/dist/installer/config.js +13 -0
  42. package/dist/installer/config.js.map +1 -1
  43. package/dist/installer/index.d.ts +2 -0
  44. package/dist/installer/index.js +3 -0
  45. package/dist/installer/index.js.map +1 -1
  46. package/dist/installer/wizard.d.ts +4 -0
  47. package/dist/installer/wizard.js +5 -1
  48. package/dist/installer/wizard.js.map +1 -1
  49. package/dist/nat/nat-service.d.ts +5 -5
  50. package/dist/nat/nat-service.js +9 -9
  51. package/dist/nat/nat-service.js.map +1 -1
  52. package/dist/nat/types.d.ts +1 -1
  53. package/dist/orchestrator/host-process-orchestrator.d.ts +144 -32
  54. package/dist/orchestrator/host-process-orchestrator.js +397 -128
  55. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  56. package/dist/orchestrator/index.d.ts +3 -3
  57. package/dist/orchestrator/index.js +1 -1
  58. package/dist/orchestrator/index.js.map +1 -1
  59. package/dist/orchestrator/node-identity.d.ts +26 -0
  60. package/dist/orchestrator/node-identity.js +36 -0
  61. package/dist/orchestrator/node-identity.js.map +1 -0
  62. package/dist/orchestrator/port-allocator.d.ts +14 -0
  63. package/dist/orchestrator/port-allocator.js +45 -0
  64. package/dist/orchestrator/port-allocator.js.map +1 -1
  65. package/dist/orchestrator/state-store.d.ts +5 -5
  66. package/dist/orchestrator/state-store.js +2 -2
  67. package/dist/orchestrator/state-store.js.map +1 -1
  68. package/dist/orchestrator/types.d.ts +8 -8
  69. package/dist/owner/index.d.ts +11 -0
  70. package/dist/owner/index.js +11 -0
  71. package/dist/owner/index.js.map +1 -0
  72. package/dist/owner/owner-node-client.d.ts +110 -0
  73. package/dist/{authority/authority-node-client.js → owner/owner-node-client.js} +58 -17
  74. package/dist/owner/owner-node-client.js.map +1 -0
  75. package/dist/server/error-handler.d.ts +22 -1
  76. package/dist/server/error-handler.js +64 -1
  77. package/dist/server/error-handler.js.map +1 -1
  78. package/dist/server/events/types.d.ts +3 -0
  79. package/dist/server/index.d.ts +33 -2
  80. package/dist/server/index.js +43 -16
  81. package/dist/server/index.js.map +1 -1
  82. package/dist/server/routes/grants-admin.d.ts +17 -0
  83. package/dist/server/routes/grants-admin.js +32 -0
  84. package/dist/server/routes/grants-admin.js.map +1 -0
  85. package/dist/server/routes/grants.d.ts +28 -0
  86. package/dist/server/routes/grants.js +182 -0
  87. package/dist/server/routes/grants.js.map +1 -0
  88. package/dist/server/routes/nodes.d.ts +5 -4
  89. package/dist/server/routes/nodes.js +19 -18
  90. package/dist/server/routes/nodes.js.map +1 -1
  91. package/dist/server/routes/settings.d.ts +6 -1
  92. package/dist/server/routes/settings.js +5 -2
  93. package/dist/server/routes/settings.js.map +1 -1
  94. package/dist/server/routes/status.d.ts +8 -4
  95. package/dist/server/routes/status.js +9 -4
  96. package/dist/server/routes/status.js.map +1 -1
  97. package/dist/server/routes/strands.d.ts +20 -0
  98. package/dist/server/routes/strands.js +38 -0
  99. package/dist/server/routes/strands.js.map +1 -0
  100. package/dist/strands/index.d.ts +7 -0
  101. package/dist/strands/index.js +6 -0
  102. package/dist/strands/index.js.map +1 -0
  103. package/dist/strands/strand-service.d.ts +50 -0
  104. package/dist/strands/strand-service.js +103 -0
  105. package/dist/strands/strand-service.js.map +1 -0
  106. package/dist/strands/types.d.ts +93 -0
  107. package/dist/strands/types.js +23 -0
  108. package/dist/strands/types.js.map +1 -0
  109. package/dist/ui/assets/index-BVoqLjyX.css +1 -0
  110. package/dist/ui/assets/index-C-iks41M.js +17 -0
  111. package/dist/ui/assets/index-C-iks41M.js.map +1 -0
  112. package/dist/ui/index.html +2 -2
  113. package/dist/update/release-key.d.ts +1 -1
  114. package/dist/update/release-key.js +8 -6
  115. package/dist/update/release-key.js.map +1 -1
  116. package/package.json +5 -5
  117. package/src/__tests__/build-targets.test.ts +26 -0
  118. package/src/__tests__/global-setup.ts +51 -0
  119. package/src/__tests__/{host-authority.smoke.test.ts → host-owner.smoke.test.ts} +7 -7
  120. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
  121. package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
  122. package/src/__tests__/{orchestrator-authority.test.ts → orchestrator-owner.test.ts} +33 -33
  123. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
  124. package/src/__tests__/orchestrator-push.test.ts +10 -10
  125. package/src/__tests__/orchestrator.test.ts +387 -6
  126. package/src/auth/__tests__/self-label.test.ts +85 -0
  127. package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
  128. package/src/auth/__tests__/trust-circle.test.ts +55 -11
  129. package/src/auth/index.ts +2 -0
  130. package/src/auth/self-label.ts +74 -0
  131. package/src/auth/trust-circle.ts +54 -22
  132. package/src/auth/types.ts +2 -2
  133. package/src/bin/host.ts +1299 -1038
  134. package/src/donation/__tests__/donation-service.test.ts +1068 -0
  135. package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
  136. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
  137. package/src/donation/__tests__/fake-orchestrator.ts +225 -0
  138. package/src/donation/__tests__/grant-service.test.ts +213 -0
  139. package/src/donation/__tests__/grant-store.test.ts +113 -0
  140. package/src/donation/donation-service.ts +962 -0
  141. package/src/donation/donation-store.ts +171 -0
  142. package/src/donation/donation-supervisor.ts +388 -0
  143. package/src/donation/grant-service.ts +200 -0
  144. package/src/donation/grant-store.ts +152 -0
  145. package/src/donation/index.ts +64 -0
  146. package/src/donation/types.ts +243 -0
  147. package/src/index.ts +51 -5
  148. package/src/installer/__tests__/config.test.ts +27 -1
  149. package/src/installer/__tests__/installer.smoke.test.ts +31 -1
  150. package/src/installer/__tests__/wizard.test.ts +5 -1
  151. package/src/installer/config.ts +34 -0
  152. package/src/installer/index.ts +5 -0
  153. package/src/installer/wizard.ts +12 -1
  154. package/src/nat/__tests__/nat-service.test.ts +5 -5
  155. package/src/nat/nat-service.ts +10 -10
  156. package/src/nat/types.ts +1 -1
  157. package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
  158. package/src/orchestrator/host-process-orchestrator.ts +401 -134
  159. package/src/orchestrator/index.ts +3 -3
  160. package/src/orchestrator/node-identity.ts +40 -0
  161. package/src/orchestrator/port-allocator.ts +48 -0
  162. package/src/orchestrator/state-store.ts +7 -7
  163. package/src/orchestrator/types.ts +8 -8
  164. package/src/{authority/__tests__/authority-node-client.test.ts → owner/__tests__/owner-node-client.test.ts} +45 -17
  165. package/src/owner/index.ts +12 -0
  166. package/src/{authority/authority-node-client.ts → owner/owner-node-client.ts} +83 -23
  167. package/src/server/__tests__/error-handler.test.ts +22 -0
  168. package/src/server/__tests__/grants-admin-route.test.ts +73 -0
  169. package/src/server/__tests__/grants-route.test.ts +263 -0
  170. package/src/server/__tests__/nodes-route.test.ts +25 -25
  171. package/src/server/__tests__/server.smoke.test.ts +75 -0
  172. package/src/server/__tests__/settings-route.test.ts +17 -0
  173. package/src/server/__tests__/status-route.test.ts +21 -0
  174. package/src/server/__tests__/strands-route.test.ts +233 -0
  175. package/src/server/error-handler.ts +67 -1
  176. package/src/server/events/types.ts +1 -0
  177. package/src/server/index.ts +75 -17
  178. package/src/server/routes/grants-admin.ts +41 -0
  179. package/src/server/routes/grants.ts +210 -0
  180. package/src/server/routes/nodes.ts +19 -18
  181. package/src/server/routes/settings.ts +11 -3
  182. package/src/server/routes/status.ts +18 -8
  183. package/src/server/routes/strands.ts +54 -0
  184. package/src/strands/__tests__/strand-service.test.ts +165 -0
  185. package/src/strands/index.ts +14 -0
  186. package/src/strands/strand-service.ts +135 -0
  187. package/src/strands/types.ts +102 -0
  188. package/src/update/__tests__/release-key.test.ts +16 -3
  189. package/src/update/release-key.ts +8 -6
  190. package/dist/authority/authority-node-client.d.ts +0 -77
  191. package/dist/authority/authority-node-client.js.map +0 -1
  192. package/dist/authority/index.d.ts +0 -11
  193. package/dist/authority/index.js +0 -11
  194. package/dist/authority/index.js.map +0 -1
  195. package/dist/ui/assets/index-BlMXQPSw.js +0 -14
  196. package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
  197. package/dist/ui/assets/index-BwU5mJGF.css +0 -1
  198. package/src/authority/index.ts +0 -12
@@ -2,11 +2,12 @@ import { spawn } from 'node:child_process';
2
2
  import { createRequire } from 'node:module';
3
3
  import { randomBytes } from 'node:crypto';
4
4
  import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync, statSync, openSync, readSync, closeSync, } from 'node:fs';
5
- import { join, resolve as resolvePath } from 'node:path';
5
+ import { join, resolve as resolvePath, sep } from 'node:path';
6
6
  import debug from 'debug';
7
7
  import pidusage from 'pidusage';
8
8
  import { defaultLogPath, rotateOnDisk } from './log-rotator.js';
9
- import { PortAllocator } from './port-allocator.js';
9
+ import { ensureNodeIdentity } from './node-identity.js';
10
+ import { allocateNodePorts, PortAllocator } from './port-allocator.js';
10
11
  import { StateStore } from './state-store.js';
11
12
  import { isPidAlive } from './pid-liveness.js';
12
13
  import { decodeDockerId, encodeDockerId, } from './types.js';
@@ -21,8 +22,28 @@ const DEFAULTS = {
21
22
  const STARTUP_TOKEN_FILE = '.startup-token';
22
23
  const CONFIG_FILE = 'cadre.json';
23
24
  const LIVENESS_POLL_MS = 100;
24
- /** Fixed friendly id for the admin's authority node. */
25
- export const AUTHORITY_CONTAINER_ID = 'authority';
25
+ const CADRE_ENV_PREFIX = 'CADRE_';
26
+ /**
27
+ * Manager's own `process.env` with every `CADRE_*` key removed. A child node
28
+ * takes its whole configuration from the config file this orchestrator writes
29
+ * plus the vars set explicitly below, and `@serfab/cadre-cli` treats inherited
30
+ * `CADRE_*` vars as overrides that BEAT that config file — so inheriting them
31
+ * would let a var set on the manager (e.g. CADRE_PARTY_ID) silently
32
+ * reconfigure every spawned child. Scrubbing the whole prefix rather than a
33
+ * list of known keys means a new cli env var can't reintroduce the leak by
34
+ * being forgotten here. Non-CADRE vars (PATH, DEBUG, proxy/TLS settings, etc.)
35
+ * still pass through untouched.
36
+ */
37
+ function scrubbedParentEnv() {
38
+ const env = { ...process.env };
39
+ for (const key of Object.keys(env)) {
40
+ if (key.startsWith(CADRE_ENV_PREFIX))
41
+ delete env[key];
42
+ }
43
+ return env;
44
+ }
45
+ /** Fixed friendly id for the admin's owner node. */
46
+ export const OWNER_CONTAINER_ID = 'owner';
26
47
  /**
27
48
  * Runs cadre nodes as plain Node child processes on the host.
28
49
  *
@@ -78,7 +99,7 @@ export class HostProcessOrchestrator {
78
99
  */
79
100
  async init() {
80
101
  const state = this.stateStore.load();
81
- this.authorityConfig = state.authorityConfig;
102
+ this.ownerConfig = state.ownerConfig;
82
103
  for (const persisted of state.handles) {
83
104
  const alive = isPidAlive(persisted.pid) && tokenMatches(persisted.workdir, persisted.startupToken);
84
105
  const handle = {
@@ -91,18 +112,12 @@ export class HostProcessOrchestrator {
91
112
  spawnedAt: persisted.spawnedAt,
92
113
  partyId: persisted.partyId,
93
114
  profile: persisted.profile,
94
- ...(persisted.authority ? { authority: true } : {}),
115
+ ...(persisted.owner ? { owner: true } : {}),
95
116
  alive,
96
117
  };
97
118
  this.handles.set(handle.dockerId, handle);
98
119
  // Always reserve ports — even for dead handles, until the caller cleans them up
99
- this.portAllocator.markUsed(persisted.ports.health);
100
- this.portAllocator.markUsed(persisted.ports.metrics);
101
- this.portAllocator.markUsed(persisted.ports.p2p);
102
- // `admin` is absent on handles persisted before this build.
103
- if (typeof persisted.ports.admin === 'number') {
104
- this.portAllocator.markUsed(persisted.ports.admin);
105
- }
120
+ this.reservePorts(persisted.ports);
106
121
  }
107
122
  log('init: re-attached %d handles', state.handles.length);
108
123
  }
@@ -148,36 +163,156 @@ export class HostProcessOrchestrator {
148
163
  this.stateListeners.add(listener);
149
164
  return () => { this.stateListeners.delete(listener); };
150
165
  }
151
- /** Handles that init() found dead (PID gone or token mismatch). */
152
- listDeadHandles() {
153
- const dead = [];
154
- for (const h of this.handles.values()) {
155
- if (!h.alive) {
156
- dead.push(this.toPersisted(h));
157
- }
166
+ /**
167
+ * Spawn a generic managed node — the donated-node path (the requester's cadre,
168
+ * pinned to the *requester's* owner key). The node is given its own protobuf
169
+ * identity key inside its workdir (`ensureNodeIdentity`, reused across
170
+ * re-spawns of the same containerId), which is what makes its peer id stable
171
+ * across restarts AND what makes its node-local stores durable: `cadre-cli
172
+ * start` opens the file-backed bootstrap-peer and trusted-owner stores only
173
+ * when a protobuf identity key file is configured, and puts them beside it.
174
+ * Terminating the loan (`removeContainer`) deletes the workdir, so the key and
175
+ * both stores go with it.
176
+ */
177
+ async createContainer(request) {
178
+ const workdir = this.workdirFor(request.containerId);
179
+ // Sampled BEFORE `ensureNodeIdentity`, which brings the directory into
180
+ // existence as a side effect of writing the key. Only a spawn that found
181
+ // nothing here may delete anything on its way out — a re-spawn is REUSING
182
+ // the identity key and node-local stores this directory holds, and they are
183
+ // the whole reason the node comes back as the same peer. `false` is
184
+ // therefore the safe default: a directory that exists for any reason
185
+ // (respawn, an earlier failed provision, an operator's file) is never
186
+ // touched by the unwind below.
187
+ const isNewWorkdir = !existsSync(workdir);
188
+ let dropped = [];
189
+ let ports;
190
+ try {
191
+ // Identity BEFORE any port is reserved: it is the one step here that can
192
+ // fail on its own (an unreadable or undecodable identity.key throws rather
193
+ // than silently re-keying), and everything past the allocation is either
194
+ // infallible or releases the ports itself. Allocating first would leak four
195
+ // ports from a bounded range on every failed provision attempt.
196
+ const identity = await ensureNodeIdentity(workdir);
197
+ log('container %s identity peerId=%s', request.containerId, identity.peerId);
198
+ // A node started with pinned owner keys belongs to a FOREIGN cadre (the
199
+ // node-donation flow: it trusts the requester's owner key, not the host's).
200
+ // The host's FCM/APNs credentials are minted for the host owner's own app
201
+ // and are meaningless to a foreign cadre — so donated nodes get NO push
202
+ // block. (Per-grantee push creds would be a future ticket; none in v1.)
203
+ const foreignParty = (request.pinnedOwnerKeys?.length ?? 0) > 0;
204
+ // Only storage-profile nodes participate in strands and thus fan out push
205
+ // wakes — a transaction-only node need not carry credentials. Resolved
206
+ // BEFORE the drop below: it is the last `await` on this path, which is what
207
+ // keeps the drop → launch window synchronous (see restoreDroppedHandles).
208
+ const push = request.profile === 'storage' && !foreignParty ? await this.resolvePush() : undefined;
209
+ // Pinned owner key(s) reach the child via CADRE_OWNER_KEYS (comma-separated);
210
+ // `cadre-cli start` unions it into its cold-start pinnedKeyTrustPolicy so the
211
+ // node will accept the foreign-authority-signed seed presented via POST /seed.
212
+ const extraEnv = request.pinnedOwnerKeys?.length
213
+ ? { CADRE_OWNER_KEYS: request.pinnedOwnerKeys.join(',') }
214
+ : undefined;
215
+ // Nothing from here to the launch is `await`ed — which is what keeps
216
+ // `restoreDroppedHandles`'s documented precondition true even though the
217
+ // `try` now opens further up. Do not add an `await` past this line.
218
+ dropped = this.dropStaleHandle(request.containerId);
219
+ // NOTE: the p2p port is re-allocated per spawn, so a re-spawned donated
220
+ // node keeps its peer id but may announce a different port. Recoverable —
221
+ // it dials out to its retained bootstrap peers and republishes its own
222
+ // CadrePeer row once connected. If that reconnect ever proves too slow,
223
+ // pin the p2p port per containerId (see donated-node-respawn-core).
224
+ ports = allocateNodePorts(this.portAllocator);
225
+ return this.launchChild({
226
+ containerId: request.containerId,
227
+ partyId: request.partyId,
228
+ profile: request.profile,
229
+ ports,
230
+ owner: false,
231
+ buildConfig: () => this.buildChildConfig(request, workdir, push),
232
+ extraArgs: ['--identity-protobuf', identity.path],
233
+ ...(extraEnv ? { extraEnv } : {}),
234
+ ...(request.resources?.memoryLimit ? { memoryLimit: request.resources.memoryLimit } : {}),
235
+ });
236
+ }
237
+ catch (err) {
238
+ // Release BEFORE restoring: a re-spawn allocates the very ports the drop
239
+ // just freed, so restoring first and releasing second would hand the
240
+ // restored handle's own ports straight back to the allocator.
241
+ if (ports)
242
+ this.releasePorts(ports);
243
+ // A no-op for the failures that happen before the drop (`dropped` is
244
+ // still empty).
245
+ this.restoreDroppedHandles(dropped);
246
+ // Safe because a throw out of `launchChild` means NO child is running:
247
+ // its throw surface ends where the child is spawned and its handle stored
248
+ // (everything past that line is best-effort), and the `if (!child.pid)`
249
+ // throw is on the no-process side too. So nothing is ever holding the
250
+ // directory this deletes.
251
+ if (isNewWorkdir)
252
+ this.discardWorkdir(workdir);
253
+ throw err;
158
254
  }
159
- return dead;
160
255
  }
161
- async createContainer(request) {
162
- const ports = {
163
- health: this.portAllocator.allocate(),
164
- metrics: this.portAllocator.allocate(),
165
- p2p: this.portAllocator.allocate(),
166
- admin: this.portAllocator.allocate(),
167
- };
168
- // Only storage-profile nodes participate in strands and thus fan out push
169
- // wakes — a transaction-only node need not carry credentials.
170
- const push = request.profile === 'storage' ? await this.resolvePush() : undefined;
171
- return this.launchChild({
172
- containerId: request.containerId,
173
- partyId: request.partyId,
174
- profile: request.profile,
175
- ports,
176
- authority: false,
177
- buildConfig: (workdir) => this.buildChildConfig(request, workdir, push),
178
- extraArgs: [],
179
- ...(request.resources?.memoryLimit ? { memoryLimit: request.resources.memoryLimit } : {}),
180
- });
256
+ /**
257
+ * Remove the working directory a spawn created for `containerId` when no
258
+ * handle owns it — the cleanup of last resort for a donation record that
259
+ * never got a `dockerId` (the host died between `ensureNodeIdentity` and the
260
+ * handle write, so `createContainer`'s own unwind never ran). Returns whether
261
+ * anything was removed.
262
+ *
263
+ * Synchronous, mirroring {@link resolveDockerId}: nothing on this path needs
264
+ * to await.
265
+ */
266
+ reclaimWorkdir(containerId) {
267
+ // The admin's own node is never reclaimed by this route: its workdir is a
268
+ // single fixed path reused by every restart and it holds the host's own
269
+ // control-DB storage.
270
+ if (containerId === OWNER_CONTAINER_ID)
271
+ return false;
272
+ // A live handle owns that directory, and `removeContainer` — which stops
273
+ // the child first — is the only thing allowed to delete it.
274
+ if (this.resolveDockerId(containerId))
275
+ return false;
276
+ const workdir = this.workdirFor(containerId);
277
+ // Defence, not a live case: donation ids are `grn_<base64url>` and so can
278
+ // hold neither a path separator nor a dot. A caller that ever passes
279
+ // something else must not be able to walk out of `rootDir`.
280
+ if (!workdir.startsWith(this.rootDir + sep)) {
281
+ log('refusing to reclaim workdir outside rootDir: %s', workdir);
282
+ return false;
283
+ }
284
+ // Must be a DIRECTORY, not merely something that exists: `rootDir` also
285
+ // holds `state.json` (this orchestrator's own persisted handles), and
286
+ // `discardWorkdir`'s `rmSync` deletes files as happily as trees. Same
287
+ // defence class as the escape check above — no real containerId names a
288
+ // file — but the blast radius if one ever did is the whole handle map.
289
+ if (!statSync(workdir, { throwIfNoEntry: false })?.isDirectory())
290
+ return false;
291
+ this.discardWorkdir(workdir);
292
+ return true;
293
+ }
294
+ /**
295
+ * Best-effort removal of a workdir no handle owns. Logs, never throws: every
296
+ * caller is either unwinding a failure it must report instead of this one, or
297
+ * tearing a container down along a path where a surviving directory is a
298
+ * nuisance rather than a fault.
299
+ *
300
+ * `retries` sizes the wait for the OS to release a cwd handle — Windows drops
301
+ * it asynchronously after a child exits. `createContainer`'s unwind needs none
302
+ * of that (its throw surface ends before any child is spawned), but
303
+ * `reclaimWorkdir` can still meet an orphan process the previous host left
304
+ * running with this directory as its cwd, so it is not a *guaranteed*
305
+ * no-process path either — it simply logs and leaves the directory when the
306
+ * removal fails.
307
+ */
308
+ discardWorkdir(workdir, retries = { maxRetries: 5, retryDelay: 100 }) {
309
+ try {
310
+ rmSync(workdir, { recursive: true, force: true, ...retries });
311
+ log('discarded workdir %s', workdir);
312
+ }
313
+ catch (err) {
314
+ log('failed to discard workdir %s: %o', workdir, err);
315
+ }
181
316
  }
182
317
  /**
183
318
  * Resolve the push credentials to inject into the node about to spawn. Called
@@ -202,64 +337,72 @@ export class HostProcessOrchestrator {
202
337
  }
203
338
  }
204
339
  /**
205
- * Ensure the admin's authority node is running, spawning it if absent or
340
+ * Ensure the admin's owner node is running, spawning it if absent or
206
341
  * dead. Idempotent: a second call with the node already alive returns the
207
342
  * existing handle without launching a second child. The spawn parameters
208
343
  * are persisted so a later `/api/nodes/:id/{start,restart}` (or an
209
344
  * orchestrator restart) can re-spawn it without re-supplying them.
210
345
  *
211
- * The authority node is spawned as `cadre-cli start --authority
346
+ * The owner node is spawned as `cadre-cli start --owner
212
347
  * --admin-port <p> --identity-protobuf <identityPath>` so it carries the
213
348
  * host's libp2p identity, founds/joins the control network, and exposes the
214
349
  * loopback admin channel the manager delegates to.
215
350
  */
216
- async ensureAuthorityNode(cfg) {
217
- const config = cfg ?? this.authorityConfig;
351
+ async ensureOwnerNode(cfg) {
352
+ const config = cfg ?? this.ownerConfig;
218
353
  if (!config) {
219
- throw new Error('ensureAuthorityNode: no authority config supplied and none persisted');
354
+ throw new Error('ensureOwnerNode: no owner config supplied and none persisted');
220
355
  }
221
- this.authorityConfig = config;
222
- const existing = this.findAuthorityHandle();
356
+ this.ownerConfig = config;
357
+ const existing = this.findOwnerHandle();
223
358
  if (existing && isPidAlive(existing.pid) && tokenMatches(existing.workdir, existing.startupToken)) {
224
359
  this.persist();
225
360
  return toNodeInfo(existing);
226
361
  }
227
- if (existing) {
228
- // Drop the stale handle and release its ports — but DO NOT delete the
229
- // workdir: the authority node's control-DB storage lives there and must
230
- // survive a restart. launchChild reuses the same workdir.
231
- this.releasePorts(existing.ports);
232
- this.handles.delete(existing.dockerId);
233
- }
234
362
  const profile = config.profile ?? 'storage';
235
363
  // Re-resolve push credentials from the secret store on every (re-)spawn so a
236
364
  // restart picks up rotated keys and nothing raw is replayed from state.json.
365
+ // Resolved BEFORE the drop below so the drop → launch window stays
366
+ // synchronous (see restoreDroppedHandles).
237
367
  const push = await this.resolvePush();
238
- const ports = {
239
- health: this.portAllocator.allocate(),
240
- metrics: this.portAllocator.allocate(),
241
- admin: this.portAllocator.allocate(),
242
- // The authority node must listen on the configured libp2p port so the
243
- // NAT mapping (external → internal) lands on it. That port is outside
244
- // the managed range, so markUsed is a harmless no-op.
245
- p2p: config.libp2pPort,
246
- };
247
- this.portAllocator.markUsed(config.libp2pPort);
248
- const result = this.launchChild({
249
- containerId: AUTHORITY_CONTAINER_ID,
250
- partyId: config.partyId,
251
- profile,
252
- ports,
253
- authority: true,
254
- buildConfig: (workdir) => this.buildAuthorityChildConfig(config, profile, workdir, push),
255
- extraArgs: ['--authority', '--admin-port', String(ports.admin), '--identity-protobuf', config.identityPath],
256
- });
257
- const handle = this.handles.get(result.dockerId);
258
- return toNodeInfo(handle);
368
+ // NOTE: the short-circuit above matches via `findOwnerHandle` (owner flag OR
369
+ // containerId), this drop only by containerId. Equivalent today — every
370
+ // owner handle is launched with OWNER_CONTAINER_ID. If an owner spawn ever
371
+ // takes a different containerId, the two would disagree and the handle the
372
+ // short-circuit found would survive here, leaking it and its ports.
373
+ const dropped = this.dropStaleHandle(OWNER_CONTAINER_ID);
374
+ let ports;
375
+ try {
376
+ // The owner node must listen on the configured libp2p port so the NAT
377
+ // mapping (external → internal) lands on it — hence the p2p override.
378
+ ports = allocateNodePorts(this.portAllocator, { p2p: config.libp2pPort });
379
+ const result = this.launchChild({
380
+ containerId: OWNER_CONTAINER_ID,
381
+ partyId: config.partyId,
382
+ profile,
383
+ ports,
384
+ owner: true,
385
+ buildConfig: (workdir) => this.buildOwnerChildConfig(config, profile, workdir, push),
386
+ extraArgs: ['--owner', '--admin-port', String(ports.admin), '--identity-protobuf', config.identityPath],
387
+ });
388
+ const handle = this.handles.get(result.dockerId);
389
+ return toNodeInfo(handle);
390
+ }
391
+ catch (err) {
392
+ // Release before restoring — see the same note in createContainer.
393
+ if (ports)
394
+ this.releasePorts(ports);
395
+ this.restoreDroppedHandles(dropped);
396
+ // Deliberately NOT `discardWorkdir` — the asymmetry with createContainer
397
+ // is the point. `<rootDir>/owner` is a single fixed path reused by every
398
+ // restart and holds the host's own control-DB storage, so it neither
399
+ // grows without bound nor is safe to delete on a failed spawn.
400
+ throw err;
401
+ }
259
402
  }
260
- /** Endpoint + bearer for the authority node's admin channel, if spawned. */
261
- getAuthorityAdminEndpoint() {
262
- const handle = this.findAuthorityHandle();
403
+ /** Endpoint + bearer for the owner node's admin channel, if spawned. */
404
+ getOwnerAdminEndpoint() {
405
+ const handle = this.findOwnerHandle();
263
406
  if (!handle)
264
407
  return undefined;
265
408
  return {
@@ -267,46 +410,56 @@ export class HostProcessOrchestrator {
267
410
  token: handle.startupToken,
268
411
  };
269
412
  }
270
- /** Whether spawn parameters for the authority node are known (persisted). */
271
- hasAuthorityConfig() {
272
- return this.authorityConfig !== undefined;
413
+ /** Whether spawn parameters for the owner node are known (persisted). */
414
+ hasOwnerConfig() {
415
+ return this.ownerConfig !== undefined;
273
416
  }
274
- /** Whether the given friendly/docker id refers to the authority node. */
275
- isAuthorityNode(idOrDockerId) {
276
- if (idOrDockerId === AUTHORITY_CONTAINER_ID)
417
+ /** Whether the given friendly/docker id refers to the owner node. */
418
+ isOwnerNode(idOrDockerId) {
419
+ if (idOrDockerId === OWNER_CONTAINER_ID)
277
420
  return true;
278
421
  const direct = this.handles.get(idOrDockerId);
279
- if (direct?.authority)
422
+ if (direct?.owner)
280
423
  return true;
281
424
  for (const h of this.handles.values()) {
282
- if (h.authority && h.containerId === idOrDockerId)
425
+ if (h.owner && h.containerId === idOrDockerId)
283
426
  return true;
284
427
  }
285
428
  return false;
286
429
  }
287
- /** Stop the authority node (if running). Used on graceful shutdown. */
288
- async stopAuthorityNode() {
289
- const handle = this.findAuthorityHandle();
430
+ /** Stop the owner node (if running). Used on graceful shutdown. */
431
+ async stopOwnerNode() {
432
+ const handle = this.findOwnerHandle();
290
433
  if (handle && isPidAlive(handle.pid)) {
291
434
  await this.stopContainer(handle.dockerId);
292
435
  }
293
436
  }
294
- /** Stop the authority node (if running), then re-spawn it from saved config. */
295
- async restartAuthorityNode() {
296
- const handle = this.findAuthorityHandle();
437
+ /** Stop the owner node (if running), then re-spawn it from saved config. */
438
+ async restartOwnerNode() {
439
+ const handle = this.findOwnerHandle();
297
440
  if (handle && isPidAlive(handle.pid)) {
298
441
  await this.stopContainer(handle.dockerId);
299
442
  }
300
- return this.ensureAuthorityNode();
443
+ return this.ensureOwnerNode();
301
444
  }
302
445
  /**
303
- * Core spawn mechanics shared by `createContainer` and `ensureAuthorityNode`.
304
- * The caller has already allocated `ports`. Releases the ports on a
305
- * synchronous spawn failure.
446
+ * Core spawn mechanics shared by `createContainer` and `ensureOwnerNode`.
447
+ *
448
+ * The caller has already allocated `ports` and **owns their lifetime**: this
449
+ * method never releases them, so every caller must release on any throw out
450
+ * of here (it has to unwind its handle drop on the same path anyway — see
451
+ * `restoreDroppedHandles`).
452
+ *
453
+ * That contract holds because this method throws only *before* the child is
454
+ * spawned and its handle stored — everything after that point is
455
+ * best-effort. Anything new added at the tail must keep it that way.
456
+ *
457
+ * Entirely synchronous, which is what makes that unwind sound. Do not add an
458
+ * `await` here.
306
459
  */
307
460
  launchChild(opts) {
308
461
  const { containerId, ports } = opts;
309
- const workdir = join(this.rootDir, containerId);
462
+ const workdir = this.workdirFor(containerId);
310
463
  mkdirSync(workdir, { recursive: true });
311
464
  mkdirSync(join(workdir, 'storage'), { recursive: true });
312
465
  const token = randomBytes(16).toString('hex');
@@ -330,12 +483,23 @@ export class HostProcessOrchestrator {
330
483
  const heapBytes = parseMemoryLimit(memoryLimit);
331
484
  const heapMB = heapBytes !== undefined ? Math.max(64, Math.floor(heapBytes / (1024 * 1024))) : undefined;
332
485
  const env = {
333
- ...process.env,
486
+ // Scrubbed, not raw process.env: every CADRE_* config-override key is
487
+ // stripped so a var set on the manager can't silently reconfigure every
488
+ // child (see scrubbedParentEnv). Caller-supplied extras come next — the
489
+ // fixed per-child vars below always win so extraEnv can never clobber
490
+ // the seed/startup tokens or port wiring.
491
+ ...scrubbedParentEnv(),
492
+ ...opts.extraEnv,
334
493
  CADRE_STARTUP_TOKEN: token,
335
494
  CADRE_HEALTH_PORT: String(ports.health),
336
495
  CADRE_METRICS_PORT: String(ports.metrics),
337
496
  CADRE_LISTEN_ADDRS: `/ip4/0.0.0.0/tcp/${ports.p2p}`,
338
497
  CADRE_SEED_TOKEN: seedToken,
498
+ // Pin each child's node-local state (trusted-owner anchor, retained
499
+ // cold-start dial targets) to its OWN workdir. This is the same value the
500
+ // cli would derive by default (the config file lives here too), but stated
501
+ // explicitly rather than relying on the scrub above (belt and suspenders).
502
+ CADRE_NODE_STATE_DIR: workdir,
339
503
  };
340
504
  if (heapMB !== undefined) {
341
505
  env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=${heapMB}`.trim();
@@ -368,17 +532,16 @@ export class HostProcessOrchestrator {
368
532
  });
369
533
  }
370
534
  catch (err) {
371
- // Synchronous spawn failure — release the fd and the reserved ports
372
- // before propagating, so a bad-args bug doesn't leak resources.
535
+ // Synchronous spawn failure — close the fd before propagating. This is
536
+ // the ONLY fd-leak guard on this path: nothing between the `openSync`
537
+ // above and the `spawn` can throw, so no other catch is needed. Keep it
538
+ // that way, or add one. (The ports are the caller's to release.)
373
539
  closeSync(logFd);
374
- this.releasePorts(ports);
375
540
  throw err;
376
541
  }
377
542
  // Always release the parent's copy of the fd — the child has its own.
378
543
  closeSync(logFd);
379
544
  if (!child.pid) {
380
- // Spawn returned without a pid — release reservations and report.
381
- this.releasePorts(ports);
382
545
  throw new Error(`Failed to spawn child for container ${containerId}`);
383
546
  }
384
547
  child.unref();
@@ -393,7 +556,7 @@ export class HostProcessOrchestrator {
393
556
  spawnedAt: new Date().toISOString(),
394
557
  partyId: opts.partyId,
395
558
  profile: opts.profile,
396
- ...(opts.authority ? { authority: true } : {}),
559
+ ...(opts.owner ? { owner: true } : {}),
397
560
  child,
398
561
  alive: true,
399
562
  };
@@ -405,7 +568,20 @@ export class HostProcessOrchestrator {
405
568
  }
406
569
  });
407
570
  this.handles.set(dockerId, handle);
408
- this.persist();
571
+ // Past this line the launch has SUCCEEDED: the child is running and this
572
+ // handle owns its ports. So the state mirror is best-effort — a throw here
573
+ // would reach the caller's unwind, which would release ports the live child
574
+ // is bound to and restore the handle this spawn just replaced, leaving two
575
+ // handles for one container and one of them holding unreserved ports. A
576
+ // lost write self-heals: the next persist() (any stop/remove/spawn)
577
+ // rewrites the whole map. Until then a manager restart would fail to
578
+ // re-attach this child, hence the log.
579
+ try {
580
+ this.persist();
581
+ }
582
+ catch (err) {
583
+ log('failed to persist state after launching container %s: %o', containerId, err);
584
+ }
409
585
  this.emitStateChange(handle);
410
586
  log('created container %s -> pid %d ports=%j', containerId, child.pid, handle.ports);
411
587
  return {
@@ -418,14 +594,101 @@ export class HostProcessOrchestrator {
418
594
  p2pPort: ports.p2p,
419
595
  };
420
596
  }
421
- /** Locate the authority node's handle, if one has been spawned. */
422
- findAuthorityHandle() {
597
+ /**
598
+ * Drop any handle left over from a previous spawn of the same `containerId`
599
+ * and release its ports. Handles are keyed by the per-spawn `dockerId`, so
600
+ * without this a re-spawn (donated-node respawn) would strand the prior
601
+ * handle in the map forever, leaking four ports from a bounded range each
602
+ * time. Mirrors the same cleanup in `ensureOwnerNode`.
603
+ *
604
+ * DO NOT delete the workdir: the identity key and node-local stores
605
+ * (trusted-owner anchor, retained cold-start dial targets) live there and are
606
+ * the whole reason a respawn comes back as the same node. `launchChild`
607
+ * reuses the same workdir.
608
+ *
609
+ * NOTE: releasing the ports hands them straight back to the allocator, so a
610
+ * re-spawn while the *previous* child is still listening can bind-clash. Every
611
+ * caller today re-spawns only a container it has established is not running
612
+ * (`DonationService.respawn`, `ensureOwnerNode`). If a caller ever needs to
613
+ * replace a live child, stop it first — or hold the ports until its exit.
614
+ *
615
+ * **The drop is reversible.** It happens before the launch can fail, so the
616
+ * dropped handles are returned and every caller restores them via
617
+ * {@link restoreDroppedHandles} when the launch throws — otherwise the caller
618
+ * would be left holding a dockerId this orchestrator no longer knows, and the
619
+ * node's workdir could never be reclaimed. On a *successful* spawn the drop
620
+ * stands: `DonationService.abandonRespawn` depends on the old handle being
621
+ * gone by then.
622
+ */
623
+ dropStaleHandle(containerId) {
624
+ const dropped = [];
423
625
  for (const h of this.handles.values()) {
424
- if (h.authority || h.containerId === AUTHORITY_CONTAINER_ID)
626
+ if (h.containerId !== containerId)
627
+ continue;
628
+ log('dropping stale handle %s for container %s', h.dockerId, containerId);
629
+ this.releasePorts(h.ports);
630
+ this.handles.delete(h.dockerId);
631
+ dropped.push(h);
632
+ }
633
+ return dropped;
634
+ }
635
+ /**
636
+ * Inverse of {@link dropStaleHandle}, for a spawn that then failed: put each
637
+ * handle back in the map and re-reserve its four ports, leaving host state
638
+ * exactly as the failed attempt found it.
639
+ *
640
+ * **Precondition: the drop → launch window is synchronous.** Restoring is
641
+ * sound only because nothing can have taken those ports or run cleanup
642
+ * against the handle in between — `launchChild` contains no `await` and both
643
+ * callers resolve push credentials *before* their drop. `launchChild`'s half
644
+ * is enforced: its declared return type is not a Promise, so adding an
645
+ * `await` there is a compile error. The callers' half is not — an `await`
646
+ * slipped between a drop and its launch silently breaks this.
647
+ *
648
+ * No `persist()` here: `state.json` is rewritten only by `launchChild` and
649
+ * only *after* the new handle is stored, so throughout the failure window the
650
+ * on-disk state still lists the dropped handle — this restore realigns memory
651
+ * to disk rather than diverging from it.
652
+ *
653
+ * NOTE: a restored handle keeps `alive: true` even if its child exited while
654
+ * it was out of the map (`launchChild`'s exit listener looks the handle up by
655
+ * dockerId and finds nothing). Only reachable by re-spawning a container
656
+ * whose child is still live, which `dropStaleHandle`'s note above already
657
+ * forbids.
658
+ */
659
+ restoreDroppedHandles(dropped) {
660
+ for (const h of dropped) {
661
+ log('restoring dropped handle %s for container %s after failed launch', h.dockerId, h.containerId);
662
+ this.handles.set(h.dockerId, h);
663
+ this.reservePorts(h.ports);
664
+ }
665
+ }
666
+ /** A managed node's own directory under `rootDir` — config, log, storage, identity. */
667
+ workdirFor(containerId) {
668
+ return join(this.rootDir, containerId);
669
+ }
670
+ /** Locate the owner node's handle, if one has been spawned. */
671
+ findOwnerHandle() {
672
+ for (const h of this.handles.values()) {
673
+ if (h.owner || h.containerId === OWNER_CONTAINER_ID)
425
674
  return h;
426
675
  }
427
676
  return undefined;
428
677
  }
678
+ /**
679
+ * Inverse of {@link releasePorts} — hold a handle's whole port set against
680
+ * the allocator. Used both to rehydrate on `init` and to undo a drop whose
681
+ * launch then failed.
682
+ */
683
+ reservePorts(ports) {
684
+ this.portAllocator.markUsed(ports.health);
685
+ this.portAllocator.markUsed(ports.metrics);
686
+ this.portAllocator.markUsed(ports.p2p);
687
+ // `admin` is absent on handles persisted before this build; markUsed would
688
+ // otherwise put `undefined` in the used-set.
689
+ if (typeof ports.admin === 'number')
690
+ this.portAllocator.markUsed(ports.admin);
691
+ }
429
692
  releasePorts(ports) {
430
693
  this.portAllocator.release(ports.health);
431
694
  this.portAllocator.release(ports.metrics);
@@ -490,14 +753,10 @@ export class HostProcessOrchestrator {
490
753
  await this.stopContainer(dockerId);
491
754
  }
492
755
  this.releasePorts(handle.ports);
493
- // On Windows the OS releases the child's cwd handle asynchronously, so
494
- // retry rmSync to give the kernel a chance to drop it.
495
- try {
496
- rmSync(handle.workdir, { recursive: true, force: true, maxRetries: 20, retryDelay: 200 });
497
- }
498
- catch (err) {
499
- log('failed to remove workdir %s: %o', handle.workdir, err);
500
- }
756
+ // Longer retries than the other callers': this is the one path that has
757
+ // just killed a child which held this directory as its cwd, and on Windows
758
+ // the OS releases that handle asynchronously.
759
+ this.discardWorkdir(handle.workdir, { maxRetries: 20, retryDelay: 200 });
501
760
  this.handles.delete(dockerId);
502
761
  this.persist();
503
762
  }
@@ -591,11 +850,19 @@ export class HostProcessOrchestrator {
591
850
  return cfg;
592
851
  }
593
852
  /**
594
- * Child config for the authority node. It founds/joins the control network
595
- * for `partyId` (no bootstrap peers — it is the founding node), and carries
596
- * the host identity via `--identity-protobuf` (passed as a spawn arg).
853
+ * Child config for the **host's own personal cadre** owner node — the opt-in
854
+ * "founder" persona spawned only when `ownCadre.enabled` (see
855
+ * docs/cadre-host.md § Two roles: donor and founder). It founds/joins the
856
+ * control network for the host's own `partyId` (no bootstrap peers — it is the
857
+ * founding node of the host's *own* cadre) and carries the host identity via
858
+ * `--identity-protobuf` (passed as a spawn arg).
859
+ *
860
+ * This is NOT the node cadre-host donates to a requester. Donated nodes are
861
+ * generic (`createContainer` / `buildChildConfig`): they join the
862
+ * **requester's** cadre via bootstrap peers and pin the *requester's* owner
863
+ * key — they never run a host genesis.
597
864
  */
598
- buildAuthorityChildConfig(cfg, profile, workdir, push) {
865
+ buildOwnerChildConfig(cfg, profile, workdir, push) {
599
866
  const config = {
600
867
  controlNetwork: {
601
868
  partyId: cfg.partyId,
@@ -607,10 +874,12 @@ export class HostProcessOrchestrator {
607
874
  if (profile === 'storage') {
608
875
  config.storage = { type: 'file', path: join(workdir, 'storage') };
609
876
  }
610
- // The always-on authority/storage node participates in strands, so it owns
611
- // the push-wake fan-out when credentials are configured. Written into
612
- // cadre.json (same host trust boundary as the workdir's control-DB); keys
613
- // are never logged — only the redacted presence line in resolvePush.
877
+ // The host-own-cadre owner/storage node participates in strands, so it owns
878
+ // the push-wake fan-out when credentials are configured. (Donated nodes
879
+ // belong to foreign cadres and get NO host push block — see the donor path.)
880
+ // Written into cadre.json (same host trust boundary as the workdir's
881
+ // control-DB); keys are never logged — only the redacted presence line in
882
+ // resolvePush.
614
883
  if (push)
615
884
  config.push = push;
616
885
  return config;
@@ -619,7 +888,7 @@ export class HostProcessOrchestrator {
619
888
  const state = {
620
889
  version: 1,
621
890
  handles: [...this.handles.values()].map((h) => this.toPersisted(h)),
622
- ...(this.authorityConfig ? { authorityConfig: this.authorityConfig } : {}),
891
+ ...(this.ownerConfig ? { ownerConfig: this.ownerConfig } : {}),
623
892
  };
624
893
  this.stateStore.save(state);
625
894
  }
@@ -647,7 +916,7 @@ export class HostProcessOrchestrator {
647
916
  spawnedAt: h.spawnedAt,
648
917
  partyId: h.partyId,
649
918
  profile: h.profile,
650
- ...(h.authority ? { authority: true } : {}),
919
+ ...(h.owner ? { owner: true } : {}),
651
920
  };
652
921
  }
653
922
  }
@@ -661,7 +930,7 @@ function toNodeInfo(h) {
661
930
  spawnedAt: h.spawnedAt,
662
931
  workdir: h.workdir,
663
932
  ports: { ...h.ports },
664
- ...(h.authority ? { authority: true } : {}),
933
+ ...(h.owner ? { owner: true } : {}),
665
934
  };
666
935
  }
667
936
  /**