@serfab/cadre-host 0.11.0 → 0.13.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 (46) hide show
  1. package/README.md +406 -296
  2. package/dist/bin/host.d.ts +11 -7
  3. package/dist/bin/host.js +40 -12
  4. package/dist/bin/host.js.map +1 -1
  5. package/dist/installer/config.d.ts +4 -4
  6. package/dist/installer/config.js +5 -39
  7. package/dist/installer/config.js.map +1 -1
  8. package/dist/installer/identity.d.ts +8 -1
  9. package/dist/installer/identity.js +8 -1
  10. package/dist/installer/identity.js.map +1 -1
  11. package/dist/orchestrator/host-process-orchestrator.d.ts +2 -2
  12. package/dist/orchestrator/host-process-orchestrator.js +14 -4
  13. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  14. package/dist/server/routes/bootstrap-node-validation.d.ts +96 -0
  15. package/dist/server/routes/bootstrap-node-validation.js +178 -0
  16. package/dist/server/routes/bootstrap-node-validation.js.map +1 -0
  17. package/dist/server/routes/grants.js +8 -25
  18. package/dist/server/routes/grants.js.map +1 -1
  19. package/dist/server/routes/provision-request-validation.d.ts +45 -0
  20. package/dist/server/routes/provision-request-validation.js +97 -0
  21. package/dist/server/routes/provision-request-validation.js.map +1 -0
  22. package/dist/server/settings-store.d.ts +1 -1
  23. package/dist/server/settings-store.js +5 -1
  24. package/dist/server/settings-store.js.map +1 -1
  25. package/package.json +5 -4
  26. package/src/__tests__/cli.smoke.test.ts +8 -3
  27. package/src/__tests__/global-setup.ts +9 -4
  28. package/src/__tests__/orchestrator-node-identity.test.ts +204 -204
  29. package/src/__tests__/orchestrator.test.ts +951 -951
  30. package/src/bin/__tests__/cli-reference.test.ts +167 -0
  31. package/src/bin/host.ts +1326 -1299
  32. package/src/donation/__tests__/donation-service.test.ts +1068 -1068
  33. package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
  34. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
  35. package/src/installer/__tests__/config.test.ts +134 -112
  36. package/src/installer/config.ts +5 -43
  37. package/src/installer/identity.ts +8 -1
  38. package/src/nat/__tests__/nat-service.test.ts +515 -497
  39. package/src/orchestrator/host-process-orchestrator.ts +1143 -1133
  40. package/src/server/__tests__/bootstrap-node-validation.test.ts +134 -0
  41. package/src/server/__tests__/grants-route.test.ts +326 -263
  42. package/src/server/__tests__/settings-route.test.ts +8 -7
  43. package/src/server/routes/bootstrap-node-validation.ts +187 -0
  44. package/src/server/routes/grants.ts +9 -26
  45. package/src/server/routes/provision-request-validation.ts +105 -0
  46. package/src/server/settings-store.ts +41 -37
@@ -1,1133 +1,1143 @@
1
- import { spawn, type ChildProcess } from 'node:child_process';
2
- import { createRequire } from 'node:module';
3
- import { randomBytes } from 'node:crypto';
4
- import {
5
- existsSync,
6
- readFileSync,
7
- rmSync,
8
- writeFileSync,
9
- mkdirSync,
10
- statSync,
11
- openSync,
12
- readSync,
13
- closeSync,
14
- } from 'node:fs';
15
- import { join, resolve as resolvePath, sep } from 'node:path';
16
- import debug from 'debug';
17
- import pidusage from 'pidusage';
18
- import type {
19
- Orchestrator,
20
- OrchestratorCreateRequest,
21
- OrchestratorCreateResult,
22
- OrchestratorStats,
23
- } from '@serfab/cadre-provider';
24
-
25
- import { defaultLogPath, rotateOnDisk } from './log-rotator.js';
26
- import { ensureNodeIdentity } from './node-identity.js';
27
- import { allocateNodePorts, PortAllocator } from './port-allocator.js';
28
- import { StateStore, type PersistedHandle, type PersistedState } from './state-store.js';
29
- import { isPidAlive } from './pid-liveness.js';
30
- import type { PushCredentials } from '@serfab/cadre-core';
31
- import {
32
- decodeDockerId,
33
- encodeDockerId,
34
- type OwnerSpawnConfig,
35
- type Handle,
36
- type HostProcessConfig,
37
- type ManagedNodeInfo,
38
- type NodePorts,
39
- type NodeStateListener,
40
- type PushCredentialsResolver,
41
- } from './types.js';
42
-
43
- const log = debug('cadre:host:orchestrator');
44
-
45
- const DEFAULTS = {
46
- portRangeStart: 10000,
47
- portRangeEnd: 20000,
48
- stopTimeoutMs: 10000,
49
- logMaxBytes: 10 * 1024 * 1024,
50
- logMaxFiles: 5,
51
- };
52
-
53
- const STARTUP_TOKEN_FILE = '.startup-token';
54
- const CONFIG_FILE = 'cadre.json';
55
- const LIVENESS_POLL_MS = 100;
56
-
57
- const CADRE_ENV_PREFIX = 'CADRE_';
58
-
59
- /**
60
- * Manager's own `process.env` with every `CADRE_*` key removed. A child node
61
- * takes its whole configuration from the config file this orchestrator writes
62
- * plus the vars set explicitly below, and `@serfab/cadre-cli` treats inherited
63
- * `CADRE_*` vars as overrides that BEAT that config file — so inheriting them
64
- * would let a var set on the manager (e.g. CADRE_PARTY_ID) silently
65
- * reconfigure every spawned child. Scrubbing the whole prefix rather than a
66
- * list of known keys means a new cli env var can't reintroduce the leak by
67
- * being forgotten here. Non-CADRE vars (PATH, DEBUG, proxy/TLS settings, etc.)
68
- * still pass through untouched.
69
- */
70
- function scrubbedParentEnv(): NodeJS.ProcessEnv {
71
- const env = { ...process.env };
72
- for (const key of Object.keys(env)) {
73
- if (key.startsWith(CADRE_ENV_PREFIX)) delete env[key];
74
- }
75
- return env;
76
- }
77
-
78
- /** Fixed friendly id for the admin's owner node. */
79
- export const OWNER_CONTAINER_ID = 'owner';
80
-
81
- /** Endpoint + bearer for the owner node's loopback admin channel (6.6). */
82
- export interface OwnerAdminEndpoint {
83
- /** e.g. `http://127.0.0.1:<adminPort>`. */
84
- baseUrl: string;
85
- /** The child's `CADRE_STARTUP_TOKEN`, used as the admin bearer secret. */
86
- token: string;
87
- }
88
-
89
- /**
90
- * Runs cadre nodes as plain Node child processes on the host.
91
- *
92
- * Each container gets its own workdir under `rootDir`. Handles are mirrored
93
- * to `<rootDir>/state.json` so cadre-host can re-attach after a restart.
94
- *
95
- * ## Child stdio: direct-fd inheritance
96
- *
97
- * Spawned children inherit a file descriptor opened on `<workdir>/node.log`
98
- * directly as their stdout and stderr. The orchestrator closes its own copy
99
- * of the fd immediately after spawn, so the child's write path no longer
100
- * touches any handle owned by this process. This is what lets children
101
- * survive — and keep logging through — an orchestrator restart. Were the
102
- * orchestrator instead piping stdout/stderr through an in-process Writable,
103
- * a parent exit would deliver SIGPIPE to the child (POSIX) or fail its
104
- * writes with EPIPE (Windows), silently breaking the documented "init()
105
- * re-attach" flow.
106
- *
107
- * ## Log rotation: spawn-time only
108
- *
109
- * Because the child holds a stable fd open against the inode, the
110
- * orchestrator cannot safely rename `node.log` while the child is running.
111
- * Rotation therefore happens only at spawn time, when no live child is
112
- * attached to the workdir (`createContainer` is the single producer per
113
- * workdir). If `node.log` already exceeds `logMaxBytes`, the rotation
114
- * cascade runs against the on-disk paths before the fresh fd is opened.
115
- * A child whose log grows past `logMaxBytes` mid-run will keep appending
116
- * to the active file until it (or its successor) is restarted; v1 traffic
117
- * is low enough that this is acceptable.
118
- */
119
- export class HostProcessOrchestrator implements Orchestrator {
120
- private readonly rootDir: string;
121
- private readonly portAllocator: PortAllocator;
122
- private readonly stateStore: StateStore;
123
- private readonly stopTimeoutMs: number;
124
- private readonly logMaxBytes: number;
125
- private readonly logMaxFiles: number;
126
- private readonly defaultMemoryLimit?: string;
127
- private readonly handles = new Map<string, Handle>();
128
- private readonly cliEntrypoint: string;
129
- /** Resolves push credentials fresh on every spawn (see HostProcessConfig). */
130
- private readonly pushResolver?: PushCredentialsResolver;
131
- /** State-change listeners — invoked when a handle's alive state changes. */
132
- private readonly stateListeners = new Set<NodeStateListener>();
133
- /** Persisted spawn config for the owner node (re-spawn on demand). */
134
- private ownerConfig?: OwnerSpawnConfig;
135
-
136
- constructor(private readonly cfg: HostProcessConfig) {
137
- this.rootDir = resolvePath(cfg.rootDir);
138
- mkdirSync(this.rootDir, { recursive: true });
139
- this.portAllocator = new PortAllocator(
140
- cfg.portRange?.start ?? DEFAULTS.portRangeStart,
141
- cfg.portRange?.end ?? DEFAULTS.portRangeEnd,
142
- );
143
- this.stateStore = new StateStore(this.rootDir);
144
- this.stopTimeoutMs = cfg.stopTimeoutMs ?? DEFAULTS.stopTimeoutMs;
145
- this.logMaxBytes = cfg.logMaxBytes ?? DEFAULTS.logMaxBytes;
146
- this.logMaxFiles = cfg.logMaxFiles ?? DEFAULTS.logMaxFiles;
147
- this.defaultMemoryLimit = cfg.defaultResources?.memoryLimit;
148
- this.cliEntrypoint = cfg.spawn?.entrypoint ?? resolveCadreCliBin();
149
- this.pushResolver = cfg.pushResolver;
150
- log('HostProcessOrchestrator rootDir=%s cli=%s', this.rootDir, this.cliEntrypoint);
151
- }
152
-
153
- /**
154
- * Read persisted state and re-attach to surviving children. Not part of
155
- * `Orchestrator` — call once before handing the instance to a container
156
- * service.
157
- */
158
- async init(): Promise<void> {
159
- const state = this.stateStore.load();
160
- this.ownerConfig = state.ownerConfig;
161
- for (const persisted of state.handles) {
162
- const alive = isPidAlive(persisted.pid) && tokenMatches(persisted.workdir, persisted.startupToken);
163
- const handle: Handle = {
164
- containerId: persisted.containerId,
165
- dockerId: persisted.dockerId,
166
- pid: persisted.pid,
167
- startupToken: persisted.startupToken,
168
- workdir: persisted.workdir,
169
- ports: persisted.ports,
170
- spawnedAt: persisted.spawnedAt,
171
- partyId: persisted.partyId,
172
- profile: persisted.profile,
173
- ...(persisted.owner ? { owner: true } : {}),
174
- alive,
175
- };
176
- this.handles.set(handle.dockerId, handle);
177
- // Always reserve ports — even for dead handles, until the caller cleans them up
178
- this.reservePorts(persisted.ports);
179
- }
180
- log('init: re-attached %d handles', state.handles.length);
181
- }
182
-
183
- /**
184
- * Snapshot of currently-known managed nodes. Used by the local UI's
185
- * `/api/nodes` route.
186
- */
187
- listNodes(): ManagedNodeInfo[] {
188
- return [...this.handles.values()].map((h) => toNodeInfo(h));
189
- }
190
-
191
- /**
192
- * Look up a single managed node by either containerId (friendly id) or
193
- * dockerId (opaque `pid:token`). Returns undefined when neither matches.
194
- */
195
- getNode(idOrDockerId: string): ManagedNodeInfo | undefined {
196
- const direct = this.handles.get(idOrDockerId);
197
- if (direct) return toNodeInfo(direct);
198
- for (const h of this.handles.values()) {
199
- if (h.containerId === idOrDockerId) return toNodeInfo(h);
200
- }
201
- return undefined;
202
- }
203
-
204
- /**
205
- * Resolve the opaque dockerId for a friendly containerId so callers can
206
- * pass it into stopContainer / getLogs / getStats.
207
- */
208
- resolveDockerId(idOrDockerId: string): string | undefined {
209
- if (this.handles.has(idOrDockerId)) return idOrDockerId;
210
- for (const h of this.handles.values()) {
211
- if (h.containerId === idOrDockerId) return h.dockerId;
212
- }
213
- return undefined;
214
- }
215
-
216
- /**
217
- * Register a state-change listener. Invoked when a handle's `alive`
218
- * transitions (child exit, manual stop). Returns an unsubscribe fn.
219
- */
220
- onStateChange(listener: NodeStateListener): () => void {
221
- this.stateListeners.add(listener);
222
- return () => { this.stateListeners.delete(listener); };
223
- }
224
-
225
- /**
226
- * Spawn a generic managed node — the donated-node path (the requester's cadre,
227
- * pinned to the *requester's* owner key). The node is given its own protobuf
228
- * identity key inside its workdir (`ensureNodeIdentity`, reused across
229
- * re-spawns of the same containerId), which is what makes its peer id stable
230
- * across restarts AND what makes its node-local stores durable: `cadre-cli
231
- * start` opens the file-backed bootstrap-peer and trusted-owner stores only
232
- * when a protobuf identity key file is configured, and puts them beside it.
233
- * Terminating the loan (`removeContainer`) deletes the workdir, so the key and
234
- * both stores go with it.
235
- */
236
- async createContainer(request: OrchestratorCreateRequest): Promise<OrchestratorCreateResult> {
237
- const workdir = this.workdirFor(request.containerId);
238
- // Sampled BEFORE `ensureNodeIdentity`, which brings the directory into
239
- // existence as a side effect of writing the key. Only a spawn that found
240
- // nothing here may delete anything on its way out — a re-spawn is REUSING
241
- // the identity key and node-local stores this directory holds, and they are
242
- // the whole reason the node comes back as the same peer. `false` is
243
- // therefore the safe default: a directory that exists for any reason
244
- // (respawn, an earlier failed provision, an operator's file) is never
245
- // touched by the unwind below.
246
- const isNewWorkdir = !existsSync(workdir);
247
- let dropped: Handle[] = [];
248
- let ports: NodePorts | undefined;
249
- try {
250
- // Identity BEFORE any port is reserved: it is the one step here that can
251
- // fail on its own (an unreadable or undecodable identity.key throws rather
252
- // than silently re-keying), and everything past the allocation is either
253
- // infallible or releases the ports itself. Allocating first would leak four
254
- // ports from a bounded range on every failed provision attempt.
255
- const identity = await ensureNodeIdentity(workdir);
256
- log('container %s identity peerId=%s', request.containerId, identity.peerId);
257
- // A node started with pinned owner keys belongs to a FOREIGN cadre (the
258
- // node-donation flow: it trusts the requester's owner key, not the host's).
259
- // The host's FCM/APNs credentials are minted for the host owner's own app
260
- // and are meaningless to a foreign cadre — so donated nodes get NO push
261
- // block. (Per-grantee push creds would be a future ticket; none in v1.)
262
- const foreignParty = (request.pinnedOwnerKeys?.length ?? 0) > 0;
263
- // Only storage-profile nodes participate in strands and thus fan out push
264
- // wakes — a transaction-only node need not carry credentials. Resolved
265
- // BEFORE the drop below: it is the last `await` on this path, which is what
266
- // keeps the drop → launch window synchronous (see restoreDroppedHandles).
267
- const push = request.profile === 'storage' && !foreignParty ? await this.resolvePush() : undefined;
268
- // Pinned owner key(s) reach the child via CADRE_OWNER_KEYS (comma-separated);
269
- // `cadre-cli start` unions it into its cold-start pinnedKeyTrustPolicy so the
270
- // node will accept the foreign-authority-signed seed presented via POST /seed.
271
- const extraEnv = request.pinnedOwnerKeys?.length
272
- ? { CADRE_OWNER_KEYS: request.pinnedOwnerKeys.join(',') }
273
- : undefined;
274
-
275
- // Nothing from here to the launch is `await`ed — which is what keeps
276
- // `restoreDroppedHandles`'s documented precondition true even though the
277
- // `try` now opens further up. Do not add an `await` past this line.
278
- dropped = this.dropStaleHandle(request.containerId);
279
- // NOTE: the p2p port is re-allocated per spawn, so a re-spawned donated
280
- // node keeps its peer id but may announce a different port. Recoverable —
281
- // it dials out to its retained bootstrap peers and republishes its own
282
- // CadrePeer row once connected. If that reconnect ever proves too slow,
283
- // pin the p2p port per containerId (see donated-node-respawn-core).
284
- ports = allocateNodePorts(this.portAllocator);
285
- return this.launchChild({
286
- containerId: request.containerId,
287
- partyId: request.partyId,
288
- profile: request.profile,
289
- ports,
290
- owner: false,
291
- buildConfig: () => this.buildChildConfig(request, workdir, push),
292
- extraArgs: ['--identity-protobuf', identity.path],
293
- ...(extraEnv ? { extraEnv } : {}),
294
- ...(request.resources?.memoryLimit ? { memoryLimit: request.resources.memoryLimit } : {}),
295
- });
296
- } catch (err) {
297
- // Release BEFORE restoring: a re-spawn allocates the very ports the drop
298
- // just freed, so restoring first and releasing second would hand the
299
- // restored handle's own ports straight back to the allocator.
300
- if (ports) this.releasePorts(ports);
301
- // A no-op for the failures that happen before the drop (`dropped` is
302
- // still empty).
303
- this.restoreDroppedHandles(dropped);
304
- // Safe because a throw out of `launchChild` means NO child is running:
305
- // its throw surface ends where the child is spawned and its handle stored
306
- // (everything past that line is best-effort), and the `if (!child.pid)`
307
- // throw is on the no-process side too. So nothing is ever holding the
308
- // directory this deletes.
309
- if (isNewWorkdir) this.discardWorkdir(workdir);
310
- throw err;
311
- }
312
- }
313
-
314
- /**
315
- * Remove the working directory a spawn created for `containerId` when no
316
- * handle owns it — the cleanup of last resort for a donation record that
317
- * never got a `dockerId` (the host died between `ensureNodeIdentity` and the
318
- * handle write, so `createContainer`'s own unwind never ran). Returns whether
319
- * anything was removed.
320
- *
321
- * Synchronous, mirroring {@link resolveDockerId}: nothing on this path needs
322
- * to await.
323
- */
324
- reclaimWorkdir(containerId: string): boolean {
325
- // The admin's own node is never reclaimed by this route: its workdir is a
326
- // single fixed path reused by every restart and it holds the host's own
327
- // control-DB storage.
328
- if (containerId === OWNER_CONTAINER_ID) return false;
329
- // A live handle owns that directory, and `removeContainer` — which stops
330
- // the child first — is the only thing allowed to delete it.
331
- if (this.resolveDockerId(containerId)) return false;
332
- const workdir = this.workdirFor(containerId);
333
- // Defence, not a live case: donation ids are `grn_<base64url>` and so can
334
- // hold neither a path separator nor a dot. A caller that ever passes
335
- // something else must not be able to walk out of `rootDir`.
336
- if (!workdir.startsWith(this.rootDir + sep)) {
337
- log('refusing to reclaim workdir outside rootDir: %s', workdir);
338
- return false;
339
- }
340
- // Must be a DIRECTORY, not merely something that exists: `rootDir` also
341
- // holds `state.json` (this orchestrator's own persisted handles), and
342
- // `discardWorkdir`'s `rmSync` deletes files as happily as trees. Same
343
- // defence class as the escape check above — no real containerId names a
344
- // file — but the blast radius if one ever did is the whole handle map.
345
- if (!statSync(workdir, { throwIfNoEntry: false })?.isDirectory()) return false;
346
- this.discardWorkdir(workdir);
347
- return true;
348
- }
349
-
350
- /**
351
- * Best-effort removal of a workdir no handle owns. Logs, never throws: every
352
- * caller is either unwinding a failure it must report instead of this one, or
353
- * tearing a container down along a path where a surviving directory is a
354
- * nuisance rather than a fault.
355
- *
356
- * `retries` sizes the wait for the OS to release a cwd handle — Windows drops
357
- * it asynchronously after a child exits. `createContainer`'s unwind needs none
358
- * of that (its throw surface ends before any child is spawned), but
359
- * `reclaimWorkdir` can still meet an orphan process the previous host left
360
- * running with this directory as its cwd, so it is not a *guaranteed*
361
- * no-process path either — it simply logs and leaves the directory when the
362
- * removal fails.
363
- */
364
- private discardWorkdir(workdir: string, retries = { maxRetries: 5, retryDelay: 100 }): void {
365
- try {
366
- rmSync(workdir, { recursive: true, force: true, ...retries });
367
- log('discarded workdir %s', workdir);
368
- } catch (err) {
369
- log('failed to discard workdir %s: %o', workdir, err);
370
- }
371
- }
372
-
373
- /**
374
- * Resolve the push credentials to inject into the node about to spawn. Called
375
- * on every spawn so a re-spawn re-reads the secret store — raw private keys are
376
- * never persisted. A resolver failure (e.g. a partial credential set) is
377
- * logged and degrades to "no push" rather than failing the spawn: the node
378
- * stays up and reachable, just without platform push-wake.
379
- */
380
- private async resolvePush(): Promise<PushCredentials | undefined> {
381
- if (!this.pushResolver) return undefined;
382
- try {
383
- const push = await this.pushResolver();
384
- if (push) {
385
- log('resolved push credentials: fcm=%s apns=%s cooldownMs=%s', !!push.fcm, !!push.apns, push.cooldownMs);
386
- }
387
- return push;
388
- } catch (err) {
389
- log('push resolver failed; spawning node without push: %s', (err as Error).message);
390
- return undefined;
391
- }
392
- }
393
-
394
- /**
395
- * Ensure the admin's owner node is running, spawning it if absent or
396
- * dead. Idempotent: a second call with the node already alive returns the
397
- * existing handle without launching a second child. The spawn parameters
398
- * are persisted so a later `/api/nodes/:id/{start,restart}` (or an
399
- * orchestrator restart) can re-spawn it without re-supplying them.
400
- *
401
- * The owner node is spawned as `cadre-cli start --owner
402
- * --admin-port <p> --identity-protobuf <identityPath>` so it carries the
403
- * host's libp2p identity, founds/joins the control network, and exposes the
404
- * loopback admin channel the manager delegates to.
405
- */
406
- async ensureOwnerNode(cfg?: OwnerSpawnConfig): Promise<ManagedNodeInfo> {
407
- const config = cfg ?? this.ownerConfig;
408
- if (!config) {
409
- throw new Error('ensureOwnerNode: no owner config supplied and none persisted');
410
- }
411
- this.ownerConfig = config;
412
-
413
- const existing = this.findOwnerHandle();
414
- if (existing && isPidAlive(existing.pid) && tokenMatches(existing.workdir, existing.startupToken)) {
415
- this.persist();
416
- return toNodeInfo(existing);
417
- }
418
-
419
- const profile = config.profile ?? 'storage';
420
- // Re-resolve push credentials from the secret store on every (re-)spawn so a
421
- // restart picks up rotated keys and nothing raw is replayed from state.json.
422
- // Resolved BEFORE the drop below so the drop → launch window stays
423
- // synchronous (see restoreDroppedHandles).
424
- const push = await this.resolvePush();
425
-
426
- // NOTE: the short-circuit above matches via `findOwnerHandle` (owner flag OR
427
- // containerId), this drop only by containerId. Equivalent today — every
428
- // owner handle is launched with OWNER_CONTAINER_ID. If an owner spawn ever
429
- // takes a different containerId, the two would disagree and the handle the
430
- // short-circuit found would survive here, leaking it and its ports.
431
- const dropped = this.dropStaleHandle(OWNER_CONTAINER_ID);
432
- let ports: NodePorts | undefined;
433
- try {
434
- // The owner node must listen on the configured libp2p port so the NAT
435
- // mapping (external → internal) lands on it — hence the p2p override.
436
- ports = allocateNodePorts(this.portAllocator, { p2p: config.libp2pPort });
437
- const result = this.launchChild({
438
- containerId: OWNER_CONTAINER_ID,
439
- partyId: config.partyId,
440
- profile,
441
- ports,
442
- owner: true,
443
- buildConfig: (workdir) => this.buildOwnerChildConfig(config, profile, workdir, push),
444
- extraArgs: ['--owner', '--admin-port', String(ports.admin), '--identity-protobuf', config.identityPath],
445
- });
446
- const handle = this.handles.get(result.dockerId)!;
447
- return toNodeInfo(handle);
448
- } catch (err) {
449
- // Release before restoring — see the same note in createContainer.
450
- if (ports) this.releasePorts(ports);
451
- this.restoreDroppedHandles(dropped);
452
- // Deliberately NOT `discardWorkdir` — the asymmetry with createContainer
453
- // is the point. `<rootDir>/owner` is a single fixed path reused by every
454
- // restart and holds the host's own control-DB storage, so it neither
455
- // grows without bound nor is safe to delete on a failed spawn.
456
- throw err;
457
- }
458
- }
459
-
460
- /** Endpoint + bearer for the owner node's admin channel, if spawned. */
461
- getOwnerAdminEndpoint(): OwnerAdminEndpoint | undefined {
462
- const handle = this.findOwnerHandle();
463
- if (!handle) return undefined;
464
- return {
465
- baseUrl: `http://127.0.0.1:${handle.ports.admin}`,
466
- token: handle.startupToken,
467
- };
468
- }
469
-
470
- /** Whether spawn parameters for the owner node are known (persisted). */
471
- hasOwnerConfig(): boolean {
472
- return this.ownerConfig !== undefined;
473
- }
474
-
475
- /** Whether the given friendly/docker id refers to the owner node. */
476
- isOwnerNode(idOrDockerId: string): boolean {
477
- if (idOrDockerId === OWNER_CONTAINER_ID) return true;
478
- const direct = this.handles.get(idOrDockerId);
479
- if (direct?.owner) return true;
480
- for (const h of this.handles.values()) {
481
- if (h.owner && h.containerId === idOrDockerId) return true;
482
- }
483
- return false;
484
- }
485
-
486
- /** Stop the owner node (if running). Used on graceful shutdown. */
487
- async stopOwnerNode(): Promise<void> {
488
- const handle = this.findOwnerHandle();
489
- if (handle && isPidAlive(handle.pid)) {
490
- await this.stopContainer(handle.dockerId);
491
- }
492
- }
493
-
494
- /** Stop the owner node (if running), then re-spawn it from saved config. */
495
- async restartOwnerNode(): Promise<ManagedNodeInfo> {
496
- const handle = this.findOwnerHandle();
497
- if (handle && isPidAlive(handle.pid)) {
498
- await this.stopContainer(handle.dockerId);
499
- }
500
- return this.ensureOwnerNode();
501
- }
502
-
503
- /**
504
- * Core spawn mechanics shared by `createContainer` and `ensureOwnerNode`.
505
- *
506
- * The caller has already allocated `ports` and **owns their lifetime**: this
507
- * method never releases them, so every caller must release on any throw out
508
- * of here (it has to unwind its handle drop on the same path anyway — see
509
- * `restoreDroppedHandles`).
510
- *
511
- * That contract holds because this method throws only *before* the child is
512
- * spawned and its handle stored — everything after that point is
513
- * best-effort. Anything new added at the tail must keep it that way.
514
- *
515
- * Entirely synchronous, which is what makes that unwind sound. Do not add an
516
- * `await` here.
517
- */
518
- private launchChild(opts: {
519
- containerId: string;
520
- partyId: string;
521
- profile: 'storage' | 'transaction';
522
- ports: NodePorts;
523
- owner: boolean;
524
- buildConfig: (workdir: string) => Record<string, unknown>;
525
- extraArgs: string[];
526
- /** Extra env vars merged into the child's environment (e.g. CADRE_OWNER_KEYS). */
527
- extraEnv?: Record<string, string>;
528
- memoryLimit?: string;
529
- }): OrchestratorCreateResult {
530
- const { containerId, ports } = opts;
531
- const workdir = this.workdirFor(containerId);
532
- mkdirSync(workdir, { recursive: true });
533
- mkdirSync(join(workdir, 'storage'), { recursive: true });
534
-
535
- const token = randomBytes(16).toString('hex');
536
- // Per-container secret gating the node's `POST /seed` route. The node
537
- // refuses seed delivery unless the caller presents this as a bearer token
538
- // (and unregisters the route entirely when the env var is empty), so we
539
- // mint a fresh high-entropy value per child and hand it back on the result
540
- // for `ContainerService.applySeed` to send.
541
- const seedToken = randomBytes(32).toString('base64url');
542
- const tokenPath = join(workdir, STARTUP_TOKEN_FILE);
543
- // Ensure stale token from a previous container with the same id is cleared.
544
- if (existsSync(tokenPath)) {
545
- try { rmSync(tokenPath); } catch { /* ignore */ }
546
- }
547
-
548
- const configPath = join(workdir, CONFIG_FILE);
549
- writeFileSync(configPath, JSON.stringify(opts.buildConfig(workdir), null, 2), 'utf8');
550
-
551
- const memoryLimit = opts.memoryLimit ?? this.defaultMemoryLimit;
552
- const heapBytes = parseMemoryLimit(memoryLimit);
553
- const heapMB = heapBytes !== undefined ? Math.max(64, Math.floor(heapBytes / (1024 * 1024))) : undefined;
554
-
555
- const env: NodeJS.ProcessEnv = {
556
- // Scrubbed, not raw process.env: every CADRE_* config-override key is
557
- // stripped so a var set on the manager can't silently reconfigure every
558
- // child (see scrubbedParentEnv). Caller-supplied extras come next — the
559
- // fixed per-child vars below always win so extraEnv can never clobber
560
- // the seed/startup tokens or port wiring.
561
- ...scrubbedParentEnv(),
562
- ...opts.extraEnv,
563
- CADRE_STARTUP_TOKEN: token,
564
- CADRE_HEALTH_PORT: String(ports.health),
565
- CADRE_METRICS_PORT: String(ports.metrics),
566
- CADRE_LISTEN_ADDRS: `/ip4/0.0.0.0/tcp/${ports.p2p}`,
567
- CADRE_SEED_TOKEN: seedToken,
568
- // Pin each child's node-local state (trusted-owner anchor, retained
569
- // cold-start dial targets) to its OWN workdir. This is the same value the
570
- // cli would derive by default (the config file lives here too), but stated
571
- // explicitly rather than relying on the scrub above (belt and suspenders).
572
- CADRE_NODE_STATE_DIR: workdir,
573
- };
574
- if (heapMB !== undefined) {
575
- env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=${heapMB}`.trim();
576
- }
577
-
578
- // Rotate the on-disk log files BEFORE opening the fd — once the child
579
- // is running it holds the inode open and any later rename would leave
580
- // it writing to the rotated file. See class docstring.
581
- const logPath = defaultLogPath(workdir);
582
- maybeRotateAtSpawn(logPath, this.logMaxBytes, this.logMaxFiles);
583
- const logFd = openSync(logPath, 'a');
584
-
585
- const args = [
586
- this.cliEntrypoint,
587
- 'start',
588
- '-c', configPath,
589
- '--health-port', String(ports.health),
590
- '--metrics-port', String(ports.metrics),
591
- '--startup-token-file', tokenPath,
592
- ...opts.extraArgs,
593
- ];
594
-
595
- let child: ChildProcess;
596
- try {
597
- child = spawn(process.execPath, args, {
598
- cwd: workdir,
599
- detached: true,
600
- // Inherit the log fd directly. OS duplicates it into the child;
601
- // closing our copy below is safe and severs the parent's only
602
- // tie to the child's write path.
603
- stdio: ['ignore', logFd, logFd],
604
- env,
605
- });
606
- } catch (err) {
607
- // Synchronous spawn failure — close the fd before propagating. This is
608
- // the ONLY fd-leak guard on this path: nothing between the `openSync`
609
- // above and the `spawn` can throw, so no other catch is needed. Keep it
610
- // that way, or add one. (The ports are the caller's to release.)
611
- closeSync(logFd);
612
- throw err;
613
- }
614
- // Always release the parent's copy of the fd — the child has its own.
615
- closeSync(logFd);
616
-
617
- if (!child.pid) {
618
- throw new Error(`Failed to spawn child for container ${containerId}`);
619
- }
620
-
621
- child.unref();
622
-
623
- const dockerId = encodeDockerId(child.pid, token);
624
- const handle: Handle = {
625
- containerId,
626
- dockerId,
627
- pid: child.pid,
628
- startupToken: token,
629
- workdir,
630
- ports,
631
- spawnedAt: new Date().toISOString(),
632
- partyId: opts.partyId,
633
- profile: opts.profile,
634
- ...(opts.owner ? { owner: true } : {}),
635
- child,
636
- alive: true,
637
- };
638
-
639
- child.once('exit', () => {
640
- const h = this.handles.get(dockerId);
641
- if (h && h.alive) {
642
- h.alive = false;
643
- this.emitStateChange(h);
644
- }
645
- });
646
-
647
- this.handles.set(dockerId, handle);
648
- // Past this line the launch has SUCCEEDED: the child is running and this
649
- // handle owns its ports. So the state mirror is best-effort — a throw here
650
- // would reach the caller's unwind, which would release ports the live child
651
- // is bound to and restore the handle this spawn just replaced, leaving two
652
- // handles for one container and one of them holding unreserved ports. A
653
- // lost write self-heals: the next persist() (any stop/remove/spawn)
654
- // rewrites the whole map. Until then a manager restart would fail to
655
- // re-attach this child, hence the log.
656
- try {
657
- this.persist();
658
- } catch (err) {
659
- log('failed to persist state after launching container %s: %o', containerId, err);
660
- }
661
- this.emitStateChange(handle);
662
-
663
- log('created container %s -> pid %d ports=%j', containerId, child.pid, handle.ports);
664
-
665
- return {
666
- dockerId,
667
- healthEndpoint: `http://localhost:${ports.health}/health`,
668
- metricsEndpoint: `http://localhost:${ports.metrics}/metrics`,
669
- // The node's seed API (`POST /seed`) is bound to the same server/port as `/health`.
670
- seedEndpoint: `http://localhost:${ports.health}/seed`,
671
- seedToken,
672
- p2pPort: ports.p2p,
673
- };
674
- }
675
-
676
- /**
677
- * Drop any handle left over from a previous spawn of the same `containerId`
678
- * and release its ports. Handles are keyed by the per-spawn `dockerId`, so
679
- * without this a re-spawn (donated-node respawn) would strand the prior
680
- * handle in the map forever, leaking four ports from a bounded range each
681
- * time. Mirrors the same cleanup in `ensureOwnerNode`.
682
- *
683
- * DO NOT delete the workdir: the identity key and node-local stores
684
- * (trusted-owner anchor, retained cold-start dial targets) live there and are
685
- * the whole reason a respawn comes back as the same node. `launchChild`
686
- * reuses the same workdir.
687
- *
688
- * NOTE: releasing the ports hands them straight back to the allocator, so a
689
- * re-spawn while the *previous* child is still listening can bind-clash. Every
690
- * caller today re-spawns only a container it has established is not running
691
- * (`DonationService.respawn`, `ensureOwnerNode`). If a caller ever needs to
692
- * replace a live child, stop it first — or hold the ports until its exit.
693
- *
694
- * **The drop is reversible.** It happens before the launch can fail, so the
695
- * dropped handles are returned and every caller restores them via
696
- * {@link restoreDroppedHandles} when the launch throws — otherwise the caller
697
- * would be left holding a dockerId this orchestrator no longer knows, and the
698
- * node's workdir could never be reclaimed. On a *successful* spawn the drop
699
- * stands: `DonationService.abandonRespawn` depends on the old handle being
700
- * gone by then.
701
- */
702
- private dropStaleHandle(containerId: string): Handle[] {
703
- const dropped: Handle[] = [];
704
- for (const h of this.handles.values()) {
705
- if (h.containerId !== containerId) continue;
706
- log('dropping stale handle %s for container %s', h.dockerId, containerId);
707
- this.releasePorts(h.ports);
708
- this.handles.delete(h.dockerId);
709
- dropped.push(h);
710
- }
711
- return dropped;
712
- }
713
-
714
- /**
715
- * Inverse of {@link dropStaleHandle}, for a spawn that then failed: put each
716
- * handle back in the map and re-reserve its four ports, leaving host state
717
- * exactly as the failed attempt found it.
718
- *
719
- * **Precondition: the drop → launch window is synchronous.** Restoring is
720
- * sound only because nothing can have taken those ports or run cleanup
721
- * against the handle in between — `launchChild` contains no `await` and both
722
- * callers resolve push credentials *before* their drop. `launchChild`'s half
723
- * is enforced: its declared return type is not a Promise, so adding an
724
- * `await` there is a compile error. The callers' half is not — an `await`
725
- * slipped between a drop and its launch silently breaks this.
726
- *
727
- * No `persist()` here: `state.json` is rewritten only by `launchChild` and
728
- * only *after* the new handle is stored, so throughout the failure window the
729
- * on-disk state still lists the dropped handle — this restore realigns memory
730
- * to disk rather than diverging from it.
731
- *
732
- * NOTE: a restored handle keeps `alive: true` even if its child exited while
733
- * it was out of the map (`launchChild`'s exit listener looks the handle up by
734
- * dockerId and finds nothing). Only reachable by re-spawning a container
735
- * whose child is still live, which `dropStaleHandle`'s note above already
736
- * forbids.
737
- */
738
- private restoreDroppedHandles(dropped: Handle[]): void {
739
- for (const h of dropped) {
740
- log('restoring dropped handle %s for container %s after failed launch', h.dockerId, h.containerId);
741
- this.handles.set(h.dockerId, h);
742
- this.reservePorts(h.ports);
743
- }
744
- }
745
-
746
- /** A managed node's own directory under `rootDir` — config, log, storage, identity. */
747
- private workdirFor(containerId: string): string {
748
- return join(this.rootDir, containerId);
749
- }
750
-
751
- /** Locate the owner node's handle, if one has been spawned. */
752
- private findOwnerHandle(): Handle | undefined {
753
- for (const h of this.handles.values()) {
754
- if (h.owner || h.containerId === OWNER_CONTAINER_ID) return h;
755
- }
756
- return undefined;
757
- }
758
-
759
- /**
760
- * Inverse of {@link releasePorts} — hold a handle's whole port set against
761
- * the allocator. Used both to rehydrate on `init` and to undo a drop whose
762
- * launch then failed.
763
- */
764
- private reservePorts(ports: NodePorts): void {
765
- this.portAllocator.markUsed(ports.health);
766
- this.portAllocator.markUsed(ports.metrics);
767
- this.portAllocator.markUsed(ports.p2p);
768
- // `admin` is absent on handles persisted before this build; markUsed would
769
- // otherwise put `undefined` in the used-set.
770
- if (typeof ports.admin === 'number') this.portAllocator.markUsed(ports.admin);
771
- }
772
-
773
- private releasePorts(ports: NodePorts): void {
774
- this.portAllocator.release(ports.health);
775
- this.portAllocator.release(ports.metrics);
776
- this.portAllocator.release(ports.p2p);
777
- this.portAllocator.release(ports.admin);
778
- }
779
-
780
- async stopContainer(dockerId: string): Promise<void> {
781
- const handle = this.requireHandle(dockerId);
782
- if (!isPidAlive(handle.pid)) {
783
- handle.alive = false;
784
- this.persist();
785
- return;
786
- }
787
-
788
- // Capture the exit event before we send signals so we can await full
789
- // teardown — important on Windows where cwd handles release lazily.
790
- const exitPromise = handle.child
791
- ? new Promise<void>((resolve) => handle.child!.once('exit', () => resolve()))
792
- : Promise.resolve();
793
-
794
- log('SIGTERM container %s (pid=%d)', handle.containerId, handle.pid);
795
- try {
796
- process.kill(handle.pid, 'SIGTERM');
797
- } catch (err) {
798
- const code = (err as NodeJS.ErrnoException).code;
799
- if (code !== 'ESRCH') {
800
- throw err;
801
- }
802
- }
803
-
804
- const deadline = Date.now() + this.stopTimeoutMs;
805
- while (Date.now() < deadline) {
806
- if (!isPidAlive(handle.pid)) break;
807
- await sleep(LIVENESS_POLL_MS);
808
- }
809
-
810
- if (isPidAlive(handle.pid)) {
811
- log('SIGKILL container %s (pid=%d)', handle.containerId, handle.pid);
812
- try {
813
- process.kill(handle.pid, 'SIGKILL');
814
- } catch (err) {
815
- const code = (err as NodeJS.ErrnoException).code;
816
- if (code !== 'ESRCH') {
817
- throw err;
818
- }
819
- }
820
- const killDeadline = Date.now() + 2000;
821
- while (Date.now() < killDeadline && isPidAlive(handle.pid)) {
822
- await sleep(LIVENESS_POLL_MS);
823
- }
824
- }
825
-
826
- // Wait briefly for the OS to fully tear down the child (Windows cwd lock).
827
- await Promise.race([exitPromise, sleep(1000)]);
828
-
829
- const wasAlive = handle.alive;
830
- handle.alive = false;
831
- this.persist();
832
- if (wasAlive) this.emitStateChange(handle);
833
- }
834
-
835
- async removeContainer(dockerId: string): Promise<void> {
836
- const handle = this.requireHandle(dockerId);
837
- if (isPidAlive(handle.pid)) {
838
- await this.stopContainer(dockerId);
839
- }
840
-
841
- this.releasePorts(handle.ports);
842
-
843
- // Longer retries than the other callers': this is the one path that has
844
- // just killed a child which held this directory as its cwd, and on Windows
845
- // the OS releases that handle asynchronously.
846
- this.discardWorkdir(handle.workdir, { maxRetries: 20, retryDelay: 200 });
847
-
848
- this.handles.delete(dockerId);
849
- this.persist();
850
- }
851
-
852
- async isRunning(dockerId: string): Promise<boolean> {
853
- const handle: Handle | undefined = this.handles.get(dockerId);
854
- let pid: number;
855
- let token: string;
856
- let workdir: string | undefined;
857
-
858
- if (handle) {
859
- pid = handle.pid;
860
- token = handle.startupToken;
861
- workdir = handle.workdir;
862
- } else {
863
- try {
864
- const decoded = decodeDockerId(dockerId);
865
- pid = decoded.pid;
866
- token = decoded.token;
867
- } catch {
868
- return false;
869
- }
870
- }
871
-
872
- if (!isPidAlive(pid)) {
873
- if (handle) handle.alive = false;
874
- return false;
875
- }
876
- if (workdir === undefined) {
877
- // We have no record — without a workdir we can't verify the token,
878
- // so we must treat this as unknown rather than running.
879
- return false;
880
- }
881
- return tokenMatches(workdir, token);
882
- }
883
-
884
- async getStats(dockerId: string): Promise<OrchestratorStats> {
885
- const handle = this.requireHandle(dockerId);
886
- if (!isPidAlive(handle.pid)) {
887
- return { cpuPercent: 0, memoryBytes: 0, networkRxBytes: 0, networkTxBytes: 0 };
888
- }
889
- try {
890
- const usage = await pidusage(handle.pid);
891
- return {
892
- cpuPercent: usage.cpu,
893
- memoryBytes: usage.memory,
894
- networkRxBytes: 0,
895
- networkTxBytes: 0,
896
- };
897
- } catch (err) {
898
- log('pidusage failed for %d: %o', handle.pid, err);
899
- return { cpuPercent: 0, memoryBytes: 0, networkRxBytes: 0, networkTxBytes: 0 };
900
- }
901
- }
902
-
903
- async getLogs(dockerId: string, tail = 100): Promise<string> {
904
- const handle = this.requireHandle(dockerId);
905
- const logPath = defaultLogPath(handle.workdir);
906
- return tailFile(logPath, tail);
907
- }
908
-
909
- private requireHandle(dockerId: string): Handle {
910
- const handle = this.handles.get(dockerId);
911
- if (!handle) {
912
- throw new Error(`Container not found: ${dockerId}`);
913
- }
914
- return handle;
915
- }
916
-
917
- private buildChildConfig(
918
- req: OrchestratorCreateRequest,
919
- workdir: string,
920
- push?: PushCredentials,
921
- ): Record<string, unknown> {
922
- const cfg: Record<string, unknown> = {
923
- controlNetwork: {
924
- partyId: req.partyId,
925
- bootstrapNodes: req.bootstrapNodes,
926
- },
927
- profile: req.profile,
928
- strandFilter: req.strandFilter ?? 'all',
929
- };
930
- if (req.profile === 'storage') {
931
- const storage: Record<string, unknown> = {
932
- type: 'file',
933
- path: join(workdir, 'storage'),
934
- };
935
- if (req.resources?.storageQuotaBytes !== undefined) {
936
- storage.quotaBytes = req.resources.storageQuotaBytes;
937
- }
938
- cfg.storage = storage;
939
- }
940
- // Push credentials land in the child's cadre.json on the host filesystem —
941
- // the same trust boundary as the control-DB storage in this workdir. The
942
- // private keys are NOT logged (the resolver redacts; we log only presence).
943
- if (push) cfg.push = push;
944
- return cfg;
945
- }
946
-
947
- /**
948
- * Child config for the **host's own personal cadre** owner node — the opt-in
949
- * "founder" persona spawned only when `ownCadre.enabled` (see
950
- * docs/cadre-host.md § Two roles: donor and founder). It founds/joins the
951
- * control network for the host's own `partyId` (no bootstrap peers — it is the
952
- * founding node of the host's *own* cadre) and carries the host identity via
953
- * `--identity-protobuf` (passed as a spawn arg).
954
- *
955
- * This is NOT the node cadre-host donates to a requester. Donated nodes are
956
- * generic (`createContainer` / `buildChildConfig`): they join the
957
- * **requester's** cadre via bootstrap peers and pin the *requester's* owner
958
- * key — they never run a host genesis.
959
- */
960
- private buildOwnerChildConfig(
961
- cfg: OwnerSpawnConfig,
962
- profile: 'storage' | 'transaction',
963
- workdir: string,
964
- push?: PushCredentials,
965
- ): Record<string, unknown> {
966
- const config: Record<string, unknown> = {
967
- controlNetwork: {
968
- partyId: cfg.partyId,
969
- bootstrapNodes: [],
970
- },
971
- profile,
972
- strandFilter: 'all',
973
- };
974
- if (profile === 'storage') {
975
- config.storage = { type: 'file', path: join(workdir, 'storage') };
976
- }
977
- // The host-own-cadre owner/storage node participates in strands, so it owns
978
- // the push-wake fan-out when credentials are configured. (Donated nodes
979
- // belong to foreign cadres and get NO host push block — see the donor path.)
980
- // Written into cadre.json (same host trust boundary as the workdir's
981
- // control-DB); keys are never logged — only the redacted presence line in
982
- // resolvePush.
983
- if (push) config.push = push;
984
- return config;
985
- }
986
-
987
- private persist(): void {
988
- const state: PersistedState = {
989
- version: 1,
990
- handles: [...this.handles.values()].map((h) => this.toPersisted(h)),
991
- ...(this.ownerConfig ? { ownerConfig: this.ownerConfig } : {}),
992
- };
993
- this.stateStore.save(state);
994
- }
995
-
996
- private emitStateChange(handle: Handle): void {
997
- if (this.stateListeners.size === 0) return;
998
- const info = toNodeInfo(handle);
999
- for (const listener of this.stateListeners) {
1000
- try {
1001
- listener(info);
1002
- } catch (err) {
1003
- log('state listener threw for %s: %o', info.id, err);
1004
- }
1005
- }
1006
- }
1007
-
1008
- private toPersisted(h: Handle): PersistedHandle {
1009
- return {
1010
- containerId: h.containerId,
1011
- dockerId: h.dockerId,
1012
- pid: h.pid,
1013
- startupToken: h.startupToken,
1014
- workdir: h.workdir,
1015
- ports: h.ports,
1016
- spawnedAt: h.spawnedAt,
1017
- partyId: h.partyId,
1018
- profile: h.profile,
1019
- ...(h.owner ? { owner: true } : {}),
1020
- };
1021
- }
1022
- }
1023
-
1024
- function toNodeInfo(h: Handle): ManagedNodeInfo {
1025
- return {
1026
- id: h.containerId,
1027
- dockerId: h.dockerId,
1028
- partyId: h.partyId,
1029
- profile: h.profile,
1030
- status: h.alive ? 'running' : 'stopped',
1031
- spawnedAt: h.spawnedAt,
1032
- workdir: h.workdir,
1033
- ports: { ...h.ports },
1034
- ...(h.owner ? { owner: true } : {}),
1035
- };
1036
- }
1037
-
1038
- /**
1039
- * Locate the cadre-cli bin via the consumer-style module resolver. Works for
1040
- * both yarn-managed workspaces and traditional npm installs.
1041
- */
1042
- function resolveCadreCliBin(): string {
1043
- const req = createRequire(import.meta.url);
1044
- try {
1045
- return req.resolve('@serfab/cadre-cli/bin/cadre.js');
1046
- } catch (err) {
1047
- throw new Error(
1048
- `Unable to resolve @serfab/cadre-cli bin from cadre-host. ` +
1049
- `Ensure cadre-cli is built (yarn workspace @serfab/cadre-cli build) ` +
1050
- `and listed as a dependency. Underlying error: ${(err as Error).message}`,
1051
- { cause: err },
1052
- );
1053
- }
1054
- }
1055
-
1056
- function tokenMatches(workdir: string, expected: string): boolean {
1057
- const tokenPath = join(workdir, STARTUP_TOKEN_FILE);
1058
- try {
1059
- const contents = readFileSync(tokenPath, 'utf8').trim();
1060
- return contents === expected;
1061
- } catch {
1062
- return false;
1063
- }
1064
- }
1065
-
1066
- /**
1067
- * Rotate the on-disk log files for a workdir if the active file is already
1068
- * at or above the byte threshold. Called from `createContainer` before the
1069
- * child's stdio fd is opened — the only safe rotation point because the
1070
- * caller guarantees no live child is attached to this workdir.
1071
- */
1072
- function maybeRotateAtSpawn(logPath: string, maxBytes: number, maxFiles: number): void {
1073
- if (maxBytes <= 0 || maxFiles < 1) return;
1074
- let size: number;
1075
- try {
1076
- size = statSync(logPath).size;
1077
- } catch {
1078
- return;
1079
- }
1080
- if (size >= maxBytes) {
1081
- rotateOnDisk(logPath, maxFiles);
1082
- }
1083
- }
1084
-
1085
- function sleep(ms: number): Promise<void> {
1086
- return new Promise((res) => {
1087
- const t = setTimeout(res, ms);
1088
- if (typeof t.unref === 'function') t.unref();
1089
- });
1090
- }
1091
-
1092
- /**
1093
- * Read the last `n` lines from a log file. Reads from the tail in chunks
1094
- * rather than slurping the whole file.
1095
- */
1096
- function tailFile(path: string, n: number): string {
1097
- if (!existsSync(path)) return '';
1098
- const fd = openSync(path, 'r');
1099
- try {
1100
- const size = statSync(path).size;
1101
- const chunkSize = 8192;
1102
- let pos = size;
1103
- const buffers: Buffer[] = [];
1104
- let newlineCount = 0;
1105
- while (pos > 0 && newlineCount <= n) {
1106
- const readLen = Math.min(chunkSize, pos);
1107
- pos -= readLen;
1108
- const buf = Buffer.alloc(readLen);
1109
- readSync(fd, buf, 0, readLen, pos);
1110
- buffers.unshift(buf);
1111
- for (let i = 0; i < buf.length; i++) {
1112
- if (buf[i] === 0x0a) newlineCount++;
1113
- }
1114
- }
1115
- const joined = Buffer.concat(buffers).toString('utf8');
1116
- const lines = joined.split('\n');
1117
- // Drop any trailing empty line from a terminating newline before counting.
1118
- const trimmed = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
1119
- return trimmed.slice(-n).join('\n');
1120
- } finally {
1121
- closeSync(fd);
1122
- }
1123
- }
1124
-
1125
- function parseMemoryLimit(limit?: string): number | undefined {
1126
- if (!limit) return undefined;
1127
- const match = limit.match(/^(\d+(?:\.\d+)?)\s*(B|K|M|G|T)?$/i);
1128
- if (!match) return undefined;
1129
- const [, num, unit] = match;
1130
- const multipliers: Record<string, number> = { B: 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
1131
- return Math.floor(parseFloat(num!) * (multipliers[unit?.toUpperCase() ?? 'B'] ?? 1));
1132
- }
1133
-
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import { randomBytes } from 'node:crypto';
4
+ import {
5
+ existsSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ mkdirSync,
10
+ statSync,
11
+ openSync,
12
+ readSync,
13
+ closeSync,
14
+ } from 'node:fs';
15
+ import { join, resolve as resolvePath, sep } from 'node:path';
16
+ import debug from 'debug';
17
+ import pidusage from 'pidusage';
18
+ import type {
19
+ Orchestrator,
20
+ OrchestratorCreateRequest,
21
+ OrchestratorCreateResult,
22
+ OrchestratorStats,
23
+ } from '@serfab/cadre-provider';
24
+
25
+ import { defaultLogPath, rotateOnDisk } from './log-rotator.js';
26
+ import { ensureNodeIdentity } from './node-identity.js';
27
+ import { allocateNodePorts, PortAllocator } from './port-allocator.js';
28
+ import { StateStore, type PersistedHandle, type PersistedState } from './state-store.js';
29
+ import { isPidAlive } from './pid-liveness.js';
30
+ import type { PushCredentials } from '@serfab/cadre-core';
31
+ import {
32
+ decodeDockerId,
33
+ encodeDockerId,
34
+ type OwnerSpawnConfig,
35
+ type Handle,
36
+ type HostProcessConfig,
37
+ type ManagedNodeInfo,
38
+ type NodePorts,
39
+ type NodeStateListener,
40
+ type PushCredentialsResolver,
41
+ } from './types.js';
42
+
43
+ const log = debug('cadre:host:orchestrator');
44
+
45
+ const DEFAULTS = {
46
+ portRangeStart: 10000,
47
+ portRangeEnd: 20000,
48
+ stopTimeoutMs: 10000,
49
+ logMaxBytes: 10 * 1024 * 1024,
50
+ logMaxFiles: 5,
51
+ };
52
+
53
+ const STARTUP_TOKEN_FILE = '.startup-token';
54
+ const CONFIG_FILE = 'cadre.json';
55
+ const LIVENESS_POLL_MS = 100;
56
+
57
+ const CADRE_ENV_PREFIX = 'CADRE_';
58
+
59
+ /**
60
+ * Manager's own `process.env` with every `CADRE_*` key removed. A child node
61
+ * takes its whole configuration from the config file this orchestrator writes
62
+ * plus the vars set explicitly below, and `@serfab/cadre-cli` treats inherited
63
+ * `CADRE_*` vars as overrides that BEAT that config file — so inheriting them
64
+ * would let a var set on the manager (e.g. CADRE_PARTY_ID) silently
65
+ * reconfigure every spawned child. Scrubbing the whole prefix rather than a
66
+ * list of known keys means a new cli env var can't reintroduce the leak by
67
+ * being forgotten here. Non-CADRE vars (PATH, DEBUG, proxy/TLS settings, etc.)
68
+ * still pass through untouched.
69
+ */
70
+ function scrubbedParentEnv(): NodeJS.ProcessEnv {
71
+ const env = { ...process.env };
72
+ for (const key of Object.keys(env)) {
73
+ if (key.startsWith(CADRE_ENV_PREFIX)) delete env[key];
74
+ }
75
+ return env;
76
+ }
77
+
78
+ /** Fixed friendly id for the admin's owner node. */
79
+ export const OWNER_CONTAINER_ID = 'owner';
80
+
81
+ /** Endpoint + bearer for the owner node's loopback admin channel (6.6). */
82
+ export interface OwnerAdminEndpoint {
83
+ /** e.g. `http://127.0.0.1:<adminPort>`. */
84
+ baseUrl: string;
85
+ /** The child's `CADRE_STARTUP_TOKEN`, used as the admin bearer secret. */
86
+ token: string;
87
+ }
88
+
89
+ /**
90
+ * Runs cadre nodes as plain Node child processes on the host.
91
+ *
92
+ * Each container gets its own workdir under `rootDir`. Handles are mirrored
93
+ * to `<rootDir>/state.json` so cadre-host can re-attach after a restart.
94
+ *
95
+ * ## Child stdio: direct-fd inheritance
96
+ *
97
+ * Spawned children inherit a file descriptor opened on `<workdir>/node.log`
98
+ * directly as their stdout and stderr. The orchestrator closes its own copy
99
+ * of the fd immediately after spawn, so the child's write path no longer
100
+ * touches any handle owned by this process. This is what lets children
101
+ * survive — and keep logging through — an orchestrator restart. Were the
102
+ * orchestrator instead piping stdout/stderr through an in-process Writable,
103
+ * a parent exit would deliver SIGPIPE to the child (POSIX) or fail its
104
+ * writes with EPIPE (Windows), silently breaking the documented "init()
105
+ * re-attach" flow.
106
+ *
107
+ * ## Log rotation: spawn-time only
108
+ *
109
+ * Because the child holds a stable fd open against the inode, the
110
+ * orchestrator cannot safely rename `node.log` while the child is running.
111
+ * Rotation therefore happens only at spawn time, when no live child is
112
+ * attached to the workdir (`createContainer` is the single producer per
113
+ * workdir). If `node.log` already exceeds `logMaxBytes`, the rotation
114
+ * cascade runs against the on-disk paths before the fresh fd is opened.
115
+ * A child whose log grows past `logMaxBytes` mid-run will keep appending
116
+ * to the active file until it (or its successor) is restarted; v1 traffic
117
+ * is low enough that this is acceptable.
118
+ */
119
+ export class HostProcessOrchestrator implements Orchestrator {
120
+ private readonly rootDir: string;
121
+ private readonly portAllocator: PortAllocator;
122
+ private readonly stateStore: StateStore;
123
+ private readonly stopTimeoutMs: number;
124
+ private readonly logMaxBytes: number;
125
+ private readonly logMaxFiles: number;
126
+ private readonly defaultMemoryLimit?: string;
127
+ private readonly handles = new Map<string, Handle>();
128
+ private readonly cliEntrypoint: string;
129
+ /** Resolves push credentials fresh on every spawn (see HostProcessConfig). */
130
+ private readonly pushResolver?: PushCredentialsResolver;
131
+ /** State-change listeners — invoked when a handle's alive state changes. */
132
+ private readonly stateListeners = new Set<NodeStateListener>();
133
+ /** Persisted spawn config for the owner node (re-spawn on demand). */
134
+ private ownerConfig?: OwnerSpawnConfig;
135
+
136
+ constructor(private readonly cfg: HostProcessConfig) {
137
+ this.rootDir = resolvePath(cfg.rootDir);
138
+ mkdirSync(this.rootDir, { recursive: true });
139
+ this.portAllocator = new PortAllocator(
140
+ cfg.portRange?.start ?? DEFAULTS.portRangeStart,
141
+ cfg.portRange?.end ?? DEFAULTS.portRangeEnd,
142
+ );
143
+ this.stateStore = new StateStore(this.rootDir);
144
+ this.stopTimeoutMs = cfg.stopTimeoutMs ?? DEFAULTS.stopTimeoutMs;
145
+ this.logMaxBytes = cfg.logMaxBytes ?? DEFAULTS.logMaxBytes;
146
+ this.logMaxFiles = cfg.logMaxFiles ?? DEFAULTS.logMaxFiles;
147
+ this.defaultMemoryLimit = cfg.defaultResources?.memoryLimit;
148
+ this.cliEntrypoint = cfg.spawn?.entrypoint ?? resolveCadreCliBin();
149
+ this.pushResolver = cfg.pushResolver;
150
+ log('HostProcessOrchestrator rootDir=%s cli=%s', this.rootDir, this.cliEntrypoint);
151
+ }
152
+
153
+ /**
154
+ * Read persisted state and re-attach to surviving children. Not part of
155
+ * `Orchestrator` — call once before handing the instance to a container
156
+ * service.
157
+ */
158
+ async init(): Promise<void> {
159
+ const state = this.stateStore.load();
160
+ this.ownerConfig = state.ownerConfig;
161
+ for (const persisted of state.handles) {
162
+ const alive = isPidAlive(persisted.pid) && tokenMatches(persisted.workdir, persisted.startupToken);
163
+ const handle: Handle = {
164
+ containerId: persisted.containerId,
165
+ dockerId: persisted.dockerId,
166
+ pid: persisted.pid,
167
+ startupToken: persisted.startupToken,
168
+ workdir: persisted.workdir,
169
+ ports: persisted.ports,
170
+ spawnedAt: persisted.spawnedAt,
171
+ partyId: persisted.partyId,
172
+ profile: persisted.profile,
173
+ ...(persisted.owner ? { owner: true } : {}),
174
+ alive,
175
+ };
176
+ this.handles.set(handle.dockerId, handle);
177
+ // Always reserve ports — even for dead handles, until the caller cleans them up
178
+ this.reservePorts(persisted.ports);
179
+ }
180
+ log('init: re-attached %d handles', state.handles.length);
181
+ }
182
+
183
+ /**
184
+ * Snapshot of currently-known managed nodes. Used by the local UI's
185
+ * `/api/nodes` route.
186
+ */
187
+ listNodes(): ManagedNodeInfo[] {
188
+ return [...this.handles.values()].map((h) => toNodeInfo(h));
189
+ }
190
+
191
+ /**
192
+ * Look up a single managed node by either containerId (friendly id) or
193
+ * dockerId (opaque `pid:token`). Returns undefined when neither matches.
194
+ */
195
+ getNode(idOrDockerId: string): ManagedNodeInfo | undefined {
196
+ const direct = this.handles.get(idOrDockerId);
197
+ if (direct) return toNodeInfo(direct);
198
+ for (const h of this.handles.values()) {
199
+ if (h.containerId === idOrDockerId) return toNodeInfo(h);
200
+ }
201
+ return undefined;
202
+ }
203
+
204
+ /**
205
+ * Resolve the opaque dockerId for a friendly containerId so callers can
206
+ * pass it into stopContainer / getLogs / getStats.
207
+ */
208
+ resolveDockerId(idOrDockerId: string): string | undefined {
209
+ if (this.handles.has(idOrDockerId)) return idOrDockerId;
210
+ for (const h of this.handles.values()) {
211
+ if (h.containerId === idOrDockerId) return h.dockerId;
212
+ }
213
+ return undefined;
214
+ }
215
+
216
+ /**
217
+ * Register a state-change listener. Invoked when a handle's `alive`
218
+ * transitions (child exit, manual stop). Returns an unsubscribe fn.
219
+ */
220
+ onStateChange(listener: NodeStateListener): () => void {
221
+ this.stateListeners.add(listener);
222
+ return () => { this.stateListeners.delete(listener); };
223
+ }
224
+
225
+ /**
226
+ * Spawn a generic managed node — the donated-node path (the requester's cadre,
227
+ * pinned to the *requester's* owner key). The node is given its own protobuf
228
+ * identity key inside its workdir (`ensureNodeIdentity`, reused across
229
+ * re-spawns of the same containerId), which is what makes its peer id stable
230
+ * across restarts AND what makes its node-local stores durable: `cadre-cli
231
+ * start` opens the file-backed bootstrap-peer and trusted-owner stores only
232
+ * when a protobuf identity key file is configured, and puts them beside it.
233
+ * Terminating the loan (`removeContainer`) deletes the workdir, so the key and
234
+ * both stores go with it.
235
+ */
236
+ async createContainer(request: OrchestratorCreateRequest): Promise<OrchestratorCreateResult> {
237
+ const workdir = this.workdirFor(request.containerId);
238
+ // Sampled BEFORE `ensureNodeIdentity`, which brings the directory into
239
+ // existence as a side effect of writing the key. Only a spawn that found
240
+ // nothing here may delete anything on its way out — a re-spawn is REUSING
241
+ // the identity key and node-local stores this directory holds, and they are
242
+ // the whole reason the node comes back as the same peer. `false` is
243
+ // therefore the safe default: a directory that exists for any reason
244
+ // (respawn, an earlier failed provision, an operator's file) is never
245
+ // touched by the unwind below.
246
+ const isNewWorkdir = !existsSync(workdir);
247
+ let dropped: Handle[] = [];
248
+ let ports: NodePorts | undefined;
249
+ try {
250
+ // Identity BEFORE any port is reserved: it is the one step here that can
251
+ // fail on its own (an unreadable or undecodable identity.key throws rather
252
+ // than silently re-keying), and everything past the allocation is either
253
+ // infallible or releases the ports itself. Allocating first would leak four
254
+ // ports from a bounded range on every failed provision attempt.
255
+ const identity = await ensureNodeIdentity(workdir);
256
+ log('container %s identity peerId=%s', request.containerId, identity.peerId);
257
+ // A node started with pinned owner keys belongs to a FOREIGN cadre (the
258
+ // node-donation flow: it trusts the requester's owner key, not the host's).
259
+ // The host's FCM/APNs credentials are minted for the host owner's own app
260
+ // and are meaningless to a foreign cadre — so donated nodes get NO push
261
+ // block. (Per-grantee push creds would be a future ticket; none in v1.)
262
+ const foreignParty = (request.pinnedOwnerKeys?.length ?? 0) > 0;
263
+ // Only storage-profile nodes participate in strands and thus fan out push
264
+ // wakes — a transaction-only node need not carry credentials. Resolved
265
+ // BEFORE the drop below: it is the last `await` on this path, which is what
266
+ // keeps the drop → launch window synchronous (see restoreDroppedHandles).
267
+ const push = request.profile === 'storage' && !foreignParty ? await this.resolvePush() : undefined;
268
+ // Pinned owner key(s) reach the child via CADRE_OWNER_KEYS (comma-separated);
269
+ // `cadre-cli start` unions it into its cold-start pinnedKeyTrustPolicy so the
270
+ // node will accept the foreign-authority-signed seed presented via POST /seed.
271
+ const extraEnv = request.pinnedOwnerKeys?.length
272
+ ? { CADRE_OWNER_KEYS: request.pinnedOwnerKeys.join(',') }
273
+ : undefined;
274
+
275
+ // Nothing from here to the launch is `await`ed — which is what keeps
276
+ // `restoreDroppedHandles`'s documented precondition true even though the
277
+ // `try` now opens further up. Do not add an `await` past this line.
278
+ dropped = this.dropStaleHandle(request.containerId);
279
+ // NOTE: the p2p port is re-allocated per spawn, so a re-spawned donated
280
+ // node keeps its peer id but may announce a different port. Recoverable —
281
+ // it dials out to its retained bootstrap peers and republishes its own
282
+ // CadrePeer row once connected. If that reconnect ever proves too slow,
283
+ // pin the p2p port per containerId (see donated-node-respawn-core).
284
+ ports = allocateNodePorts(this.portAllocator);
285
+ return this.launchChild({
286
+ containerId: request.containerId,
287
+ partyId: request.partyId,
288
+ profile: request.profile,
289
+ ports,
290
+ owner: false,
291
+ buildConfig: () => this.buildChildConfig(request, workdir, push),
292
+ extraArgs: ['--identity-file', identity.path],
293
+ ...(extraEnv ? { extraEnv } : {}),
294
+ ...(request.resources?.memoryLimit ? { memoryLimit: request.resources.memoryLimit } : {}),
295
+ });
296
+ } catch (err) {
297
+ // Release BEFORE restoring: a re-spawn allocates the very ports the drop
298
+ // just freed, so restoring first and releasing second would hand the
299
+ // restored handle's own ports straight back to the allocator.
300
+ if (ports) this.releasePorts(ports);
301
+ // A no-op for the failures that happen before the drop (`dropped` is
302
+ // still empty).
303
+ this.restoreDroppedHandles(dropped);
304
+ // Safe because a throw out of `launchChild` means NO child is running:
305
+ // its throw surface ends where the child is spawned and its handle stored
306
+ // (everything past that line is best-effort), and the `if (!child.pid)`
307
+ // throw is on the no-process side too. So nothing is ever holding the
308
+ // directory this deletes.
309
+ if (isNewWorkdir) this.discardWorkdir(workdir);
310
+ throw err;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Remove the working directory a spawn created for `containerId` when no
316
+ * handle owns it — the cleanup of last resort for a donation record that
317
+ * never got a `dockerId` (the host died between `ensureNodeIdentity` and the
318
+ * handle write, so `createContainer`'s own unwind never ran). Returns whether
319
+ * anything was removed.
320
+ *
321
+ * Synchronous, mirroring {@link resolveDockerId}: nothing on this path needs
322
+ * to await.
323
+ */
324
+ reclaimWorkdir(containerId: string): boolean {
325
+ // The admin's own node is never reclaimed by this route: its workdir is a
326
+ // single fixed path reused by every restart and it holds the host's own
327
+ // control-DB storage.
328
+ if (containerId === OWNER_CONTAINER_ID) return false;
329
+ // A live handle owns that directory, and `removeContainer` — which stops
330
+ // the child first — is the only thing allowed to delete it.
331
+ if (this.resolveDockerId(containerId)) return false;
332
+ const workdir = this.workdirFor(containerId);
333
+ // Defence, not a live case: donation ids are `grn_<base64url>` and so can
334
+ // hold neither a path separator nor a dot. A caller that ever passes
335
+ // something else must not be able to walk out of `rootDir`.
336
+ if (!workdir.startsWith(this.rootDir + sep)) {
337
+ log('refusing to reclaim workdir outside rootDir: %s', workdir);
338
+ return false;
339
+ }
340
+ // Must be a DIRECTORY, not merely something that exists: `rootDir` also
341
+ // holds `state.json` (this orchestrator's own persisted handles), and
342
+ // `discardWorkdir`'s `rmSync` deletes files as happily as trees. Same
343
+ // defence class as the escape check above — no real containerId names a
344
+ // file — but the blast radius if one ever did is the whole handle map.
345
+ if (!statSync(workdir, { throwIfNoEntry: false })?.isDirectory()) return false;
346
+ this.discardWorkdir(workdir);
347
+ return true;
348
+ }
349
+
350
+ /**
351
+ * Best-effort removal of a workdir no handle owns. Logs, never throws: every
352
+ * caller is either unwinding a failure it must report instead of this one, or
353
+ * tearing a container down along a path where a surviving directory is a
354
+ * nuisance rather than a fault.
355
+ *
356
+ * `retries` sizes the wait for the OS to release a cwd handle — Windows drops
357
+ * it asynchronously after a child exits. `createContainer`'s unwind needs none
358
+ * of that (its throw surface ends before any child is spawned), but
359
+ * `reclaimWorkdir` can still meet an orphan process the previous host left
360
+ * running with this directory as its cwd, so it is not a *guaranteed*
361
+ * no-process path either — it simply logs and leaves the directory when the
362
+ * removal fails.
363
+ */
364
+ private discardWorkdir(workdir: string, retries = { maxRetries: 5, retryDelay: 100 }): void {
365
+ try {
366
+ rmSync(workdir, { recursive: true, force: true, ...retries });
367
+ log('discarded workdir %s', workdir);
368
+ } catch (err) {
369
+ log('failed to discard workdir %s: %o', workdir, err);
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Resolve the push credentials to inject into the node about to spawn. Called
375
+ * on every spawn so a re-spawn re-reads the secret store — raw private keys are
376
+ * never persisted. A resolver failure (e.g. a partial credential set) is
377
+ * logged and degrades to "no push" rather than failing the spawn: the node
378
+ * stays up and reachable, just without platform push-wake.
379
+ */
380
+ private async resolvePush(): Promise<PushCredentials | undefined> {
381
+ if (!this.pushResolver) return undefined;
382
+ try {
383
+ const push = await this.pushResolver();
384
+ if (push) {
385
+ log('resolved push credentials: fcm=%s apns=%s cooldownMs=%s', !!push.fcm, !!push.apns, push.cooldownMs);
386
+ }
387
+ return push;
388
+ } catch (err) {
389
+ log('push resolver failed; spawning node without push: %s', (err as Error).message);
390
+ return undefined;
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Ensure the admin's owner node is running, spawning it if absent or
396
+ * dead. Idempotent: a second call with the node already alive returns the
397
+ * existing handle without launching a second child. The spawn parameters
398
+ * are persisted so a later `/api/nodes/:id/{start,restart}` (or an
399
+ * orchestrator restart) can re-spawn it without re-supplying them.
400
+ *
401
+ * The owner node is spawned as `cadre-cli start --owner
402
+ * --admin-port <p> --identity-file <identityPath>` so it carries the
403
+ * host's libp2p identity, founds/joins the control network, and exposes the
404
+ * loopback admin channel the manager delegates to.
405
+ */
406
+ async ensureOwnerNode(cfg?: OwnerSpawnConfig): Promise<ManagedNodeInfo> {
407
+ const config = cfg ?? this.ownerConfig;
408
+ if (!config) {
409
+ throw new Error('ensureOwnerNode: no owner config supplied and none persisted');
410
+ }
411
+ this.ownerConfig = config;
412
+
413
+ const existing = this.findOwnerHandle();
414
+ if (existing && isPidAlive(existing.pid) && tokenMatches(existing.workdir, existing.startupToken)) {
415
+ this.persist();
416
+ return toNodeInfo(existing);
417
+ }
418
+
419
+ const profile = config.profile ?? 'storage';
420
+ // Re-resolve push credentials from the secret store on every (re-)spawn so a
421
+ // restart picks up rotated keys and nothing raw is replayed from state.json.
422
+ // Resolved BEFORE the drop below so the drop → launch window stays
423
+ // synchronous (see restoreDroppedHandles).
424
+ const push = await this.resolvePush();
425
+
426
+ // NOTE: the short-circuit above matches via `findOwnerHandle` (owner flag OR
427
+ // containerId), this drop only by containerId. Equivalent today — every
428
+ // owner handle is launched with OWNER_CONTAINER_ID. If an owner spawn ever
429
+ // takes a different containerId, the two would disagree and the handle the
430
+ // short-circuit found would survive here, leaking it and its ports.
431
+ const dropped = this.dropStaleHandle(OWNER_CONTAINER_ID);
432
+ let ports: NodePorts | undefined;
433
+ try {
434
+ // The owner node must listen on the configured libp2p port so the NAT
435
+ // mapping (external → internal) lands on it — hence the p2p override.
436
+ ports = allocateNodePorts(this.portAllocator, { p2p: config.libp2pPort });
437
+ const result = this.launchChild({
438
+ containerId: OWNER_CONTAINER_ID,
439
+ partyId: config.partyId,
440
+ profile,
441
+ ports,
442
+ owner: true,
443
+ buildConfig: (workdir) => this.buildOwnerChildConfig(config, profile, workdir, push),
444
+ extraArgs: ['--owner', '--admin-port', String(ports.admin), '--identity-file', config.identityPath],
445
+ });
446
+ const handle = this.handles.get(result.dockerId)!;
447
+ return toNodeInfo(handle);
448
+ } catch (err) {
449
+ // Release before restoring — see the same note in createContainer.
450
+ if (ports) this.releasePorts(ports);
451
+ this.restoreDroppedHandles(dropped);
452
+ // Deliberately NOT `discardWorkdir` — the asymmetry with createContainer
453
+ // is the point. `<rootDir>/owner` is a single fixed path reused by every
454
+ // restart and holds the host's own control-DB storage, so it neither
455
+ // grows without bound nor is safe to delete on a failed spawn.
456
+ throw err;
457
+ }
458
+ }
459
+
460
+ /** Endpoint + bearer for the owner node's admin channel, if spawned. */
461
+ getOwnerAdminEndpoint(): OwnerAdminEndpoint | undefined {
462
+ const handle = this.findOwnerHandle();
463
+ if (!handle) return undefined;
464
+ return {
465
+ baseUrl: `http://127.0.0.1:${handle.ports.admin}`,
466
+ token: handle.startupToken,
467
+ };
468
+ }
469
+
470
+ /** Whether spawn parameters for the owner node are known (persisted). */
471
+ hasOwnerConfig(): boolean {
472
+ return this.ownerConfig !== undefined;
473
+ }
474
+
475
+ /** Whether the given friendly/docker id refers to the owner node. */
476
+ isOwnerNode(idOrDockerId: string): boolean {
477
+ if (idOrDockerId === OWNER_CONTAINER_ID) return true;
478
+ const direct = this.handles.get(idOrDockerId);
479
+ if (direct?.owner) return true;
480
+ for (const h of this.handles.values()) {
481
+ if (h.owner && h.containerId === idOrDockerId) return true;
482
+ }
483
+ return false;
484
+ }
485
+
486
+ /** Stop the owner node (if running). Used on graceful shutdown. */
487
+ async stopOwnerNode(): Promise<void> {
488
+ const handle = this.findOwnerHandle();
489
+ if (handle && isPidAlive(handle.pid)) {
490
+ await this.stopContainer(handle.dockerId);
491
+ }
492
+ }
493
+
494
+ /** Stop the owner node (if running), then re-spawn it from saved config. */
495
+ async restartOwnerNode(): Promise<ManagedNodeInfo> {
496
+ const handle = this.findOwnerHandle();
497
+ if (handle && isPidAlive(handle.pid)) {
498
+ await this.stopContainer(handle.dockerId);
499
+ }
500
+ return this.ensureOwnerNode();
501
+ }
502
+
503
+ /**
504
+ * Core spawn mechanics shared by `createContainer` and `ensureOwnerNode`.
505
+ *
506
+ * The caller has already allocated `ports` and **owns their lifetime**: this
507
+ * method never releases them, so every caller must release on any throw out
508
+ * of here (it has to unwind its handle drop on the same path anyway — see
509
+ * `restoreDroppedHandles`).
510
+ *
511
+ * That contract holds because this method throws only *before* the child is
512
+ * spawned and its handle stored — everything after that point is
513
+ * best-effort. Anything new added at the tail must keep it that way.
514
+ *
515
+ * Entirely synchronous, which is what makes that unwind sound. Do not add an
516
+ * `await` here.
517
+ */
518
+ private launchChild(opts: {
519
+ containerId: string;
520
+ partyId: string;
521
+ profile: 'storage' | 'transaction';
522
+ ports: NodePorts;
523
+ owner: boolean;
524
+ buildConfig: (workdir: string) => Record<string, unknown>;
525
+ extraArgs: string[];
526
+ /** Extra env vars merged into the child's environment (e.g. CADRE_OWNER_KEYS). */
527
+ extraEnv?: Record<string, string>;
528
+ memoryLimit?: string;
529
+ }): OrchestratorCreateResult {
530
+ const { containerId, ports } = opts;
531
+ const workdir = this.workdirFor(containerId);
532
+ mkdirSync(workdir, { recursive: true });
533
+ mkdirSync(join(workdir, 'storage'), { recursive: true });
534
+
535
+ const token = randomBytes(16).toString('hex');
536
+ // Per-container secret gating the node's `POST /seed` route. The node
537
+ // refuses seed delivery unless the caller presents this as a bearer token
538
+ // (and unregisters the route entirely when the env var is empty), so we
539
+ // mint a fresh high-entropy value per child and hand it back on the result
540
+ // for `ContainerService.applySeed` to send.
541
+ const seedToken = randomBytes(32).toString('base64url');
542
+ const tokenPath = join(workdir, STARTUP_TOKEN_FILE);
543
+ // Ensure stale token from a previous container with the same id is cleared.
544
+ if (existsSync(tokenPath)) {
545
+ try { rmSync(tokenPath); } catch { /* ignore */ }
546
+ }
547
+
548
+ const configPath = join(workdir, CONFIG_FILE);
549
+ writeFileSync(configPath, JSON.stringify(opts.buildConfig(workdir), null, 2), 'utf8');
550
+
551
+ const memoryLimit = opts.memoryLimit ?? this.defaultMemoryLimit;
552
+ const heapBytes = parseMemoryLimit(memoryLimit);
553
+ const heapMB = heapBytes !== undefined ? Math.max(64, Math.floor(heapBytes / (1024 * 1024))) : undefined;
554
+
555
+ const env: NodeJS.ProcessEnv = {
556
+ // Scrubbed, not raw process.env: every CADRE_* config-override key is
557
+ // stripped so a var set on the manager can't silently reconfigure every
558
+ // child (see scrubbedParentEnv). Caller-supplied extras come next — the
559
+ // fixed per-child vars below always win so extraEnv can never clobber
560
+ // the seed/startup tokens or port wiring.
561
+ ...scrubbedParentEnv(),
562
+ ...opts.extraEnv,
563
+ CADRE_STARTUP_TOKEN: token,
564
+ CADRE_HEALTH_PORT: String(ports.health),
565
+ CADRE_METRICS_PORT: String(ports.metrics),
566
+ CADRE_LISTEN_ADDRS: `/ip4/0.0.0.0/tcp/${ports.p2p}`,
567
+ // NOTE: the scrub above drops every CADRE_* var and `extraEnv` is built only from
568
+ // pinnedOwnerKeys, so a managed child advertises only the port assigned to it here
569
+ // — `CADRE_ANNOUNCE_ADDRS`/`CADRE_APPEND_ANNOUNCE_ADDRS` cannot reach it. Fine while
570
+ // children are reached at that port or through a relay; if a host is ever fronted by
571
+ // a proxy or DNS name, plumb an announce var through from host config. The port is
572
+ // the CHILD'S CONTROL NODE's alone: each strand node the child runs binds the same
573
+ // entry with an OS-assigned port instead, since one port cannot be held twice
574
+ // (`cadre-core/src/strand-network-config.ts`). A NAT forward of this port therefore
575
+ // reaches the control node only, and strand nodes are reached through observed
576
+ // addresses or a relay.
577
+ CADRE_SEED_TOKEN: seedToken,
578
+ // Pin each child's node-local state (trusted-owner anchor, retained
579
+ // cold-start dial targets) to its OWN workdir. This is the same value the
580
+ // cli would derive by default (the config file lives here too), but stated
581
+ // explicitly rather than relying on the scrub above (belt and suspenders).
582
+ CADRE_NODE_STATE_DIR: workdir,
583
+ };
584
+ if (heapMB !== undefined) {
585
+ env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=${heapMB}`.trim();
586
+ }
587
+
588
+ // Rotate the on-disk log files BEFORE opening the fd — once the child
589
+ // is running it holds the inode open and any later rename would leave
590
+ // it writing to the rotated file. See class docstring.
591
+ const logPath = defaultLogPath(workdir);
592
+ maybeRotateAtSpawn(logPath, this.logMaxBytes, this.logMaxFiles);
593
+ const logFd = openSync(logPath, 'a');
594
+
595
+ const args = [
596
+ this.cliEntrypoint,
597
+ 'start',
598
+ '-c', configPath,
599
+ '--health-port', String(ports.health),
600
+ '--metrics-port', String(ports.metrics),
601
+ '--startup-token-file', tokenPath,
602
+ ...opts.extraArgs,
603
+ ];
604
+
605
+ let child: ChildProcess;
606
+ try {
607
+ child = spawn(process.execPath, args, {
608
+ cwd: workdir,
609
+ detached: true,
610
+ // Inherit the log fd directly. OS duplicates it into the child;
611
+ // closing our copy below is safe and severs the parent's only
612
+ // tie to the child's write path.
613
+ stdio: ['ignore', logFd, logFd],
614
+ env,
615
+ });
616
+ } catch (err) {
617
+ // Synchronous spawn failure — close the fd before propagating. This is
618
+ // the ONLY fd-leak guard on this path: nothing between the `openSync`
619
+ // above and the `spawn` can throw, so no other catch is needed. Keep it
620
+ // that way, or add one. (The ports are the caller's to release.)
621
+ closeSync(logFd);
622
+ throw err;
623
+ }
624
+ // Always release the parent's copy of the fd — the child has its own.
625
+ closeSync(logFd);
626
+
627
+ if (!child.pid) {
628
+ throw new Error(`Failed to spawn child for container ${containerId}`);
629
+ }
630
+
631
+ child.unref();
632
+
633
+ const dockerId = encodeDockerId(child.pid, token);
634
+ const handle: Handle = {
635
+ containerId,
636
+ dockerId,
637
+ pid: child.pid,
638
+ startupToken: token,
639
+ workdir,
640
+ ports,
641
+ spawnedAt: new Date().toISOString(),
642
+ partyId: opts.partyId,
643
+ profile: opts.profile,
644
+ ...(opts.owner ? { owner: true } : {}),
645
+ child,
646
+ alive: true,
647
+ };
648
+
649
+ child.once('exit', () => {
650
+ const h = this.handles.get(dockerId);
651
+ if (h && h.alive) {
652
+ h.alive = false;
653
+ this.emitStateChange(h);
654
+ }
655
+ });
656
+
657
+ this.handles.set(dockerId, handle);
658
+ // Past this line the launch has SUCCEEDED: the child is running and this
659
+ // handle owns its ports. So the state mirror is best-effort — a throw here
660
+ // would reach the caller's unwind, which would release ports the live child
661
+ // is bound to and restore the handle this spawn just replaced, leaving two
662
+ // handles for one container and one of them holding unreserved ports. A
663
+ // lost write self-heals: the next persist() (any stop/remove/spawn)
664
+ // rewrites the whole map. Until then a manager restart would fail to
665
+ // re-attach this child, hence the log.
666
+ try {
667
+ this.persist();
668
+ } catch (err) {
669
+ log('failed to persist state after launching container %s: %o', containerId, err);
670
+ }
671
+ this.emitStateChange(handle);
672
+
673
+ log('created container %s -> pid %d ports=%j', containerId, child.pid, handle.ports);
674
+
675
+ return {
676
+ dockerId,
677
+ healthEndpoint: `http://localhost:${ports.health}/health`,
678
+ metricsEndpoint: `http://localhost:${ports.metrics}/metrics`,
679
+ // The node's seed API (`POST /seed`) is bound to the same server/port as `/health`.
680
+ seedEndpoint: `http://localhost:${ports.health}/seed`,
681
+ seedToken,
682
+ p2pPort: ports.p2p,
683
+ };
684
+ }
685
+
686
+ /**
687
+ * Drop any handle left over from a previous spawn of the same `containerId`
688
+ * and release its ports. Handles are keyed by the per-spawn `dockerId`, so
689
+ * without this a re-spawn (donated-node respawn) would strand the prior
690
+ * handle in the map forever, leaking four ports from a bounded range each
691
+ * time. Mirrors the same cleanup in `ensureOwnerNode`.
692
+ *
693
+ * DO NOT delete the workdir: the identity key and node-local stores
694
+ * (trusted-owner anchor, retained cold-start dial targets) live there and are
695
+ * the whole reason a respawn comes back as the same node. `launchChild`
696
+ * reuses the same workdir.
697
+ *
698
+ * NOTE: releasing the ports hands them straight back to the allocator, so a
699
+ * re-spawn while the *previous* child is still listening can bind-clash. Every
700
+ * caller today re-spawns only a container it has established is not running
701
+ * (`DonationService.respawn`, `ensureOwnerNode`). If a caller ever needs to
702
+ * replace a live child, stop it first — or hold the ports until its exit.
703
+ *
704
+ * **The drop is reversible.** It happens before the launch can fail, so the
705
+ * dropped handles are returned and every caller restores them via
706
+ * {@link restoreDroppedHandles} when the launch throws — otherwise the caller
707
+ * would be left holding a dockerId this orchestrator no longer knows, and the
708
+ * node's workdir could never be reclaimed. On a *successful* spawn the drop
709
+ * stands: `DonationService.abandonRespawn` depends on the old handle being
710
+ * gone by then.
711
+ */
712
+ private dropStaleHandle(containerId: string): Handle[] {
713
+ const dropped: Handle[] = [];
714
+ for (const h of this.handles.values()) {
715
+ if (h.containerId !== containerId) continue;
716
+ log('dropping stale handle %s for container %s', h.dockerId, containerId);
717
+ this.releasePorts(h.ports);
718
+ this.handles.delete(h.dockerId);
719
+ dropped.push(h);
720
+ }
721
+ return dropped;
722
+ }
723
+
724
+ /**
725
+ * Inverse of {@link dropStaleHandle}, for a spawn that then failed: put each
726
+ * handle back in the map and re-reserve its four ports, leaving host state
727
+ * exactly as the failed attempt found it.
728
+ *
729
+ * **Precondition: the drop → launch window is synchronous.** Restoring is
730
+ * sound only because nothing can have taken those ports or run cleanup
731
+ * against the handle in between — `launchChild` contains no `await` and both
732
+ * callers resolve push credentials *before* their drop. `launchChild`'s half
733
+ * is enforced: its declared return type is not a Promise, so adding an
734
+ * `await` there is a compile error. The callers' half is not — an `await`
735
+ * slipped between a drop and its launch silently breaks this.
736
+ *
737
+ * No `persist()` here: `state.json` is rewritten only by `launchChild` and
738
+ * only *after* the new handle is stored, so throughout the failure window the
739
+ * on-disk state still lists the dropped handle — this restore realigns memory
740
+ * to disk rather than diverging from it.
741
+ *
742
+ * NOTE: a restored handle keeps `alive: true` even if its child exited while
743
+ * it was out of the map (`launchChild`'s exit listener looks the handle up by
744
+ * dockerId and finds nothing). Only reachable by re-spawning a container
745
+ * whose child is still live, which `dropStaleHandle`'s note above already
746
+ * forbids.
747
+ */
748
+ private restoreDroppedHandles(dropped: Handle[]): void {
749
+ for (const h of dropped) {
750
+ log('restoring dropped handle %s for container %s after failed launch', h.dockerId, h.containerId);
751
+ this.handles.set(h.dockerId, h);
752
+ this.reservePorts(h.ports);
753
+ }
754
+ }
755
+
756
+ /** A managed node's own directory under `rootDir` — config, log, storage, identity. */
757
+ private workdirFor(containerId: string): string {
758
+ return join(this.rootDir, containerId);
759
+ }
760
+
761
+ /** Locate the owner node's handle, if one has been spawned. */
762
+ private findOwnerHandle(): Handle | undefined {
763
+ for (const h of this.handles.values()) {
764
+ if (h.owner || h.containerId === OWNER_CONTAINER_ID) return h;
765
+ }
766
+ return undefined;
767
+ }
768
+
769
+ /**
770
+ * Inverse of {@link releasePorts} — hold a handle's whole port set against
771
+ * the allocator. Used both to rehydrate on `init` and to undo a drop whose
772
+ * launch then failed.
773
+ */
774
+ private reservePorts(ports: NodePorts): void {
775
+ this.portAllocator.markUsed(ports.health);
776
+ this.portAllocator.markUsed(ports.metrics);
777
+ this.portAllocator.markUsed(ports.p2p);
778
+ // `admin` is absent on handles persisted before this build; markUsed would
779
+ // otherwise put `undefined` in the used-set.
780
+ if (typeof ports.admin === 'number') this.portAllocator.markUsed(ports.admin);
781
+ }
782
+
783
+ private releasePorts(ports: NodePorts): void {
784
+ this.portAllocator.release(ports.health);
785
+ this.portAllocator.release(ports.metrics);
786
+ this.portAllocator.release(ports.p2p);
787
+ this.portAllocator.release(ports.admin);
788
+ }
789
+
790
+ async stopContainer(dockerId: string): Promise<void> {
791
+ const handle = this.requireHandle(dockerId);
792
+ if (!isPidAlive(handle.pid)) {
793
+ handle.alive = false;
794
+ this.persist();
795
+ return;
796
+ }
797
+
798
+ // Capture the exit event before we send signals so we can await full
799
+ // teardown — important on Windows where cwd handles release lazily.
800
+ const exitPromise = handle.child
801
+ ? new Promise<void>((resolve) => handle.child!.once('exit', () => resolve()))
802
+ : Promise.resolve();
803
+
804
+ log('SIGTERM container %s (pid=%d)', handle.containerId, handle.pid);
805
+ try {
806
+ process.kill(handle.pid, 'SIGTERM');
807
+ } catch (err) {
808
+ const code = (err as NodeJS.ErrnoException).code;
809
+ if (code !== 'ESRCH') {
810
+ throw err;
811
+ }
812
+ }
813
+
814
+ const deadline = Date.now() + this.stopTimeoutMs;
815
+ while (Date.now() < deadline) {
816
+ if (!isPidAlive(handle.pid)) break;
817
+ await sleep(LIVENESS_POLL_MS);
818
+ }
819
+
820
+ if (isPidAlive(handle.pid)) {
821
+ log('SIGKILL container %s (pid=%d)', handle.containerId, handle.pid);
822
+ try {
823
+ process.kill(handle.pid, 'SIGKILL');
824
+ } catch (err) {
825
+ const code = (err as NodeJS.ErrnoException).code;
826
+ if (code !== 'ESRCH') {
827
+ throw err;
828
+ }
829
+ }
830
+ const killDeadline = Date.now() + 2000;
831
+ while (Date.now() < killDeadline && isPidAlive(handle.pid)) {
832
+ await sleep(LIVENESS_POLL_MS);
833
+ }
834
+ }
835
+
836
+ // Wait briefly for the OS to fully tear down the child (Windows cwd lock).
837
+ await Promise.race([exitPromise, sleep(1000)]);
838
+
839
+ const wasAlive = handle.alive;
840
+ handle.alive = false;
841
+ this.persist();
842
+ if (wasAlive) this.emitStateChange(handle);
843
+ }
844
+
845
+ async removeContainer(dockerId: string): Promise<void> {
846
+ const handle = this.requireHandle(dockerId);
847
+ if (isPidAlive(handle.pid)) {
848
+ await this.stopContainer(dockerId);
849
+ }
850
+
851
+ this.releasePorts(handle.ports);
852
+
853
+ // Longer retries than the other callers': this is the one path that has
854
+ // just killed a child which held this directory as its cwd, and on Windows
855
+ // the OS releases that handle asynchronously.
856
+ this.discardWorkdir(handle.workdir, { maxRetries: 20, retryDelay: 200 });
857
+
858
+ this.handles.delete(dockerId);
859
+ this.persist();
860
+ }
861
+
862
+ async isRunning(dockerId: string): Promise<boolean> {
863
+ const handle: Handle | undefined = this.handles.get(dockerId);
864
+ let pid: number;
865
+ let token: string;
866
+ let workdir: string | undefined;
867
+
868
+ if (handle) {
869
+ pid = handle.pid;
870
+ token = handle.startupToken;
871
+ workdir = handle.workdir;
872
+ } else {
873
+ try {
874
+ const decoded = decodeDockerId(dockerId);
875
+ pid = decoded.pid;
876
+ token = decoded.token;
877
+ } catch {
878
+ return false;
879
+ }
880
+ }
881
+
882
+ if (!isPidAlive(pid)) {
883
+ if (handle) handle.alive = false;
884
+ return false;
885
+ }
886
+ if (workdir === undefined) {
887
+ // We have no record — without a workdir we can't verify the token,
888
+ // so we must treat this as unknown rather than running.
889
+ return false;
890
+ }
891
+ return tokenMatches(workdir, token);
892
+ }
893
+
894
+ async getStats(dockerId: string): Promise<OrchestratorStats> {
895
+ const handle = this.requireHandle(dockerId);
896
+ if (!isPidAlive(handle.pid)) {
897
+ return { cpuPercent: 0, memoryBytes: 0, networkRxBytes: 0, networkTxBytes: 0 };
898
+ }
899
+ try {
900
+ const usage = await pidusage(handle.pid);
901
+ return {
902
+ cpuPercent: usage.cpu,
903
+ memoryBytes: usage.memory,
904
+ networkRxBytes: 0,
905
+ networkTxBytes: 0,
906
+ };
907
+ } catch (err) {
908
+ log('pidusage failed for %d: %o', handle.pid, err);
909
+ return { cpuPercent: 0, memoryBytes: 0, networkRxBytes: 0, networkTxBytes: 0 };
910
+ }
911
+ }
912
+
913
+ async getLogs(dockerId: string, tail = 100): Promise<string> {
914
+ const handle = this.requireHandle(dockerId);
915
+ const logPath = defaultLogPath(handle.workdir);
916
+ return tailFile(logPath, tail);
917
+ }
918
+
919
+ private requireHandle(dockerId: string): Handle {
920
+ const handle = this.handles.get(dockerId);
921
+ if (!handle) {
922
+ throw new Error(`Container not found: ${dockerId}`);
923
+ }
924
+ return handle;
925
+ }
926
+
927
+ private buildChildConfig(
928
+ req: OrchestratorCreateRequest,
929
+ workdir: string,
930
+ push?: PushCredentials,
931
+ ): Record<string, unknown> {
932
+ const cfg: Record<string, unknown> = {
933
+ controlNetwork: {
934
+ partyId: req.partyId,
935
+ bootstrapNodes: req.bootstrapNodes,
936
+ },
937
+ profile: req.profile,
938
+ strandFilter: req.strandFilter ?? 'all',
939
+ };
940
+ if (req.profile === 'storage') {
941
+ const storage: Record<string, unknown> = {
942
+ type: 'file',
943
+ path: join(workdir, 'storage'),
944
+ };
945
+ if (req.resources?.storageQuotaBytes !== undefined) {
946
+ storage.quotaBytes = req.resources.storageQuotaBytes;
947
+ }
948
+ cfg.storage = storage;
949
+ }
950
+ // Push credentials land in the child's cadre.json on the host filesystem —
951
+ // the same trust boundary as the control-DB storage in this workdir. The
952
+ // private keys are NOT logged (the resolver redacts; we log only presence).
953
+ if (push) cfg.push = push;
954
+ return cfg;
955
+ }
956
+
957
+ /**
958
+ * Child config for the **host's own personal cadre** owner node — the opt-in
959
+ * "founder" persona spawned only when `ownCadre.enabled` (see
960
+ * docs/cadre-host.md § Two roles: donor and founder). It founds/joins the
961
+ * control network for the host's own `partyId` (no bootstrap peers — it is the
962
+ * founding node of the host's *own* cadre) and carries the host identity via
963
+ * `--identity-file` (passed as a spawn arg).
964
+ *
965
+ * This is NOT the node cadre-host donates to a requester. Donated nodes are
966
+ * generic (`createContainer` / `buildChildConfig`): they join the
967
+ * **requester's** cadre via bootstrap peers and pin the *requester's* owner
968
+ * key — they never run a host genesis.
969
+ */
970
+ private buildOwnerChildConfig(
971
+ cfg: OwnerSpawnConfig,
972
+ profile: 'storage' | 'transaction',
973
+ workdir: string,
974
+ push?: PushCredentials,
975
+ ): Record<string, unknown> {
976
+ const config: Record<string, unknown> = {
977
+ controlNetwork: {
978
+ partyId: cfg.partyId,
979
+ bootstrapNodes: [],
980
+ },
981
+ profile,
982
+ strandFilter: 'all',
983
+ };
984
+ if (profile === 'storage') {
985
+ config.storage = { type: 'file', path: join(workdir, 'storage') };
986
+ }
987
+ // The host-own-cadre owner/storage node participates in strands, so it owns
988
+ // the push-wake fan-out when credentials are configured. (Donated nodes
989
+ // belong to foreign cadres and get NO host push block — see the donor path.)
990
+ // Written into cadre.json (same host trust boundary as the workdir's
991
+ // control-DB); keys are never logged — only the redacted presence line in
992
+ // resolvePush.
993
+ if (push) config.push = push;
994
+ return config;
995
+ }
996
+
997
+ private persist(): void {
998
+ const state: PersistedState = {
999
+ version: 1,
1000
+ handles: [...this.handles.values()].map((h) => this.toPersisted(h)),
1001
+ ...(this.ownerConfig ? { ownerConfig: this.ownerConfig } : {}),
1002
+ };
1003
+ this.stateStore.save(state);
1004
+ }
1005
+
1006
+ private emitStateChange(handle: Handle): void {
1007
+ if (this.stateListeners.size === 0) return;
1008
+ const info = toNodeInfo(handle);
1009
+ for (const listener of this.stateListeners) {
1010
+ try {
1011
+ listener(info);
1012
+ } catch (err) {
1013
+ log('state listener threw for %s: %o', info.id, err);
1014
+ }
1015
+ }
1016
+ }
1017
+
1018
+ private toPersisted(h: Handle): PersistedHandle {
1019
+ return {
1020
+ containerId: h.containerId,
1021
+ dockerId: h.dockerId,
1022
+ pid: h.pid,
1023
+ startupToken: h.startupToken,
1024
+ workdir: h.workdir,
1025
+ ports: h.ports,
1026
+ spawnedAt: h.spawnedAt,
1027
+ partyId: h.partyId,
1028
+ profile: h.profile,
1029
+ ...(h.owner ? { owner: true } : {}),
1030
+ };
1031
+ }
1032
+ }
1033
+
1034
+ function toNodeInfo(h: Handle): ManagedNodeInfo {
1035
+ return {
1036
+ id: h.containerId,
1037
+ dockerId: h.dockerId,
1038
+ partyId: h.partyId,
1039
+ profile: h.profile,
1040
+ status: h.alive ? 'running' : 'stopped',
1041
+ spawnedAt: h.spawnedAt,
1042
+ workdir: h.workdir,
1043
+ ports: { ...h.ports },
1044
+ ...(h.owner ? { owner: true } : {}),
1045
+ };
1046
+ }
1047
+
1048
+ /**
1049
+ * Locate the cadre-cli bin via the consumer-style module resolver. Works for
1050
+ * both yarn-managed workspaces and traditional npm installs.
1051
+ */
1052
+ function resolveCadreCliBin(): string {
1053
+ const req = createRequire(import.meta.url);
1054
+ try {
1055
+ return req.resolve('@serfab/cadre-cli/bin/cadre.js');
1056
+ } catch (err) {
1057
+ throw new Error(
1058
+ `Unable to resolve @serfab/cadre-cli bin from cadre-host. ` +
1059
+ `Ensure cadre-cli is built (yarn workspace @serfab/cadre-cli build) ` +
1060
+ `and listed as a dependency. Underlying error: ${(err as Error).message}`,
1061
+ { cause: err },
1062
+ );
1063
+ }
1064
+ }
1065
+
1066
+ function tokenMatches(workdir: string, expected: string): boolean {
1067
+ const tokenPath = join(workdir, STARTUP_TOKEN_FILE);
1068
+ try {
1069
+ const contents = readFileSync(tokenPath, 'utf8').trim();
1070
+ return contents === expected;
1071
+ } catch {
1072
+ return false;
1073
+ }
1074
+ }
1075
+
1076
+ /**
1077
+ * Rotate the on-disk log files for a workdir if the active file is already
1078
+ * at or above the byte threshold. Called from `createContainer` before the
1079
+ * child's stdio fd is opened — the only safe rotation point because the
1080
+ * caller guarantees no live child is attached to this workdir.
1081
+ */
1082
+ function maybeRotateAtSpawn(logPath: string, maxBytes: number, maxFiles: number): void {
1083
+ if (maxBytes <= 0 || maxFiles < 1) return;
1084
+ let size: number;
1085
+ try {
1086
+ size = statSync(logPath).size;
1087
+ } catch {
1088
+ return;
1089
+ }
1090
+ if (size >= maxBytes) {
1091
+ rotateOnDisk(logPath, maxFiles);
1092
+ }
1093
+ }
1094
+
1095
+ function sleep(ms: number): Promise<void> {
1096
+ return new Promise((res) => {
1097
+ const t = setTimeout(res, ms);
1098
+ if (typeof t.unref === 'function') t.unref();
1099
+ });
1100
+ }
1101
+
1102
+ /**
1103
+ * Read the last `n` lines from a log file. Reads from the tail in chunks
1104
+ * rather than slurping the whole file.
1105
+ */
1106
+ function tailFile(path: string, n: number): string {
1107
+ if (!existsSync(path)) return '';
1108
+ const fd = openSync(path, 'r');
1109
+ try {
1110
+ const size = statSync(path).size;
1111
+ const chunkSize = 8192;
1112
+ let pos = size;
1113
+ const buffers: Buffer[] = [];
1114
+ let newlineCount = 0;
1115
+ while (pos > 0 && newlineCount <= n) {
1116
+ const readLen = Math.min(chunkSize, pos);
1117
+ pos -= readLen;
1118
+ const buf = Buffer.alloc(readLen);
1119
+ readSync(fd, buf, 0, readLen, pos);
1120
+ buffers.unshift(buf);
1121
+ for (let i = 0; i < buf.length; i++) {
1122
+ if (buf[i] === 0x0a) newlineCount++;
1123
+ }
1124
+ }
1125
+ const joined = Buffer.concat(buffers).toString('utf8');
1126
+ const lines = joined.split('\n');
1127
+ // Drop any trailing empty line from a terminating newline before counting.
1128
+ const trimmed = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
1129
+ return trimmed.slice(-n).join('\n');
1130
+ } finally {
1131
+ closeSync(fd);
1132
+ }
1133
+ }
1134
+
1135
+ function parseMemoryLimit(limit?: string): number | undefined {
1136
+ if (!limit) return undefined;
1137
+ const match = limit.match(/^(\d+(?:\.\d+)?)\s*(B|K|M|G|T)?$/i);
1138
+ if (!match) return undefined;
1139
+ const [, num, unit] = match;
1140
+ const multipliers: Record<string, number> = { B: 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
1141
+ return Math.floor(parseFloat(num!) * (multipliers[unit?.toUpperCase() ?? 'B'] ?? 1));
1142
+ }
1143
+