@serfab/cadre-host 0.12.0 → 1.0.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 (70) hide show
  1. package/README.md +6 -4
  2. package/dist/bin/host.js +9 -1
  3. package/dist/bin/host.js.map +1 -1
  4. package/dist/donation/__tests__/fake-orchestrator.js +1 -1
  5. package/dist/donation/__tests__/fake-orchestrator.js.map +1 -1
  6. package/dist/donation/donation-service.d.ts +7 -2
  7. package/dist/donation/donation-service.js +16 -2
  8. package/dist/donation/donation-service.js.map +1 -1
  9. package/dist/donation/donation-supervisor.d.ts +12 -2
  10. package/dist/donation/donation-supervisor.js +25 -2
  11. package/dist/donation/donation-supervisor.js.map +1 -1
  12. package/dist/donation/types.d.ts +2 -1
  13. package/dist/donation/types.js.map +1 -1
  14. package/dist/installer/index.d.ts +9 -1
  15. package/dist/installer/index.js +5 -1
  16. package/dist/installer/index.js.map +1 -1
  17. package/dist/orchestrator/host-process-orchestrator.d.ts +71 -11
  18. package/dist/orchestrator/host-process-orchestrator.js +173 -75
  19. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  20. package/dist/orchestrator/port-allocator.d.ts +44 -2
  21. package/dist/orchestrator/port-allocator.js +63 -5
  22. package/dist/orchestrator/port-allocator.js.map +1 -1
  23. package/dist/orchestrator/port-probe.d.ts +15 -0
  24. package/dist/orchestrator/port-probe.js +22 -0
  25. package/dist/orchestrator/port-probe.js.map +1 -0
  26. package/dist/orchestrator/types.d.ts +13 -1
  27. package/dist/orchestrator/types.js.map +1 -1
  28. package/dist/server/routes/bootstrap-node-validation.d.ts +115 -0
  29. package/dist/server/routes/bootstrap-node-validation.js +197 -0
  30. package/dist/server/routes/bootstrap-node-validation.js.map +1 -0
  31. package/dist/server/routes/grants.js +8 -25
  32. package/dist/server/routes/grants.js.map +1 -1
  33. package/dist/server/routes/provision-request-validation.d.ts +45 -0
  34. package/dist/server/routes/provision-request-validation.js +97 -0
  35. package/dist/server/routes/provision-request-validation.js.map +1 -0
  36. package/dist/server/server.d.ts +4 -1
  37. package/dist/server/server.js +36 -2
  38. package/dist/server/server.js.map +1 -1
  39. package/dist/ui/assets/{index-C-iks41M.js → index-CEoNRwM_.js} +3 -3
  40. package/dist/ui/assets/{index-C-iks41M.js.map → index-CEoNRwM_.js.map} +1 -1
  41. package/dist/ui/index.html +1 -1
  42. package/package.json +5 -4
  43. package/src/__tests__/orchestrator-env-scrub.test.ts +22 -0
  44. package/src/__tests__/orchestrator-node-identity.test.ts +4 -4
  45. package/src/__tests__/orchestrator-owner.test.ts +45 -2
  46. package/src/__tests__/orchestrator-ports.test.ts +172 -0
  47. package/src/__tests__/orchestrator.test.ts +1062 -951
  48. package/src/bin/host.ts +9 -1
  49. package/src/donation/__tests__/donation-service.test.ts +94 -7
  50. package/src/donation/__tests__/donation-supervisor.test.ts +50 -12
  51. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
  52. package/src/donation/__tests__/fake-orchestrator.ts +1 -1
  53. package/src/donation/donation-service.ts +22 -3
  54. package/src/donation/donation-supervisor.ts +25 -2
  55. package/src/donation/types.ts +2 -1
  56. package/src/installer/__tests__/installer.smoke.test.ts +263 -218
  57. package/src/installer/index.ts +327 -314
  58. package/src/orchestrator/host-process-orchestrator.ts +1250 -1138
  59. package/src/orchestrator/port-allocator.ts +148 -91
  60. package/src/orchestrator/port-probe.ts +31 -0
  61. package/src/orchestrator/types.ts +13 -1
  62. package/src/server/__tests__/bootstrap-node-validation.test.ts +138 -0
  63. package/src/server/__tests__/grants-route.test.ts +394 -263
  64. package/src/server/__tests__/nodes-route.test.ts +2 -2
  65. package/src/server/__tests__/publishers.test.ts +1 -1
  66. package/src/server/__tests__/status-route.test.ts +1 -1
  67. package/src/server/routes/bootstrap-node-validation.ts +206 -0
  68. package/src/server/routes/grants.ts +9 -26
  69. package/src/server/routes/provision-request-validation.ts +105 -0
  70. package/src/server/server.ts +157 -122
package/src/bin/host.ts CHANGED
@@ -102,6 +102,7 @@ program
102
102
  .option('--own-cadre', 'Also run the host\'s own personal cadre here (founder persona; default: donor-only)')
103
103
  .option('--system', 'System-wide install (not yet supported in v1)')
104
104
  .option('--node-path <path>', 'Override the node binary embedded in the service unit')
105
+ .option('--no-service', 'Write the data dir only; register no OS service (run the host with `cadre-host start`)')
105
106
  .action(async (opts: {
106
107
  nonInteractive?: boolean;
107
108
  dataDir?: string;
@@ -113,6 +114,7 @@ program
113
114
  ownCadre?: boolean;
114
115
  system?: boolean;
115
116
  nodePath?: string;
117
+ service?: boolean;
116
118
  }) => {
117
119
  const installer = new Installer();
118
120
  try {
@@ -127,11 +129,17 @@ program
127
129
  ownCadre: opts.ownCadre === true,
128
130
  system: Boolean(opts.system),
129
131
  ...(opts.nodePath ? { nodePath: opts.nodePath } : {}),
132
+ noService: opts.service === false,
130
133
  });
131
134
  console.log(`cadre-host installed.`);
132
135
  console.log(` Data dir: ${result.dataDir}`);
133
136
  console.log(` UI: ${result.uiUrl}`);
134
- console.log(` Service: ${result.serviceName}`);
137
+ if (result.serviceName) {
138
+ console.log(` Service: ${result.serviceName}`);
139
+ } else {
140
+ console.log(' Service: not registered (--no-service)');
141
+ console.log(` Run the host: cadre-host start --data-dir "${result.dataDir}"`);
142
+ }
135
143
  if (result.enrollmentInvite) {
136
144
  printEnrollmentInvite(result.enrollmentInvite);
137
145
  }
@@ -95,7 +95,7 @@ function makeGrants(opts?: { maxNodes?: number }): { grants: GrantService; token
95
95
  const baseRequest = (grantToken: string) => ({
96
96
  grantToken,
97
97
  partyId: 'party-P',
98
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
98
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
99
99
  ownerKeys: [OWNER_KEY],
100
100
  });
101
101
 
@@ -129,6 +129,21 @@ describe('DonationService.provision', () => {
129
129
  expect(store.liveNodeCount(token)).toBe(1);
130
130
  });
131
131
 
132
+ // A phone has no address to give: it sends no bootstrap nodes and dials the lent
133
+ // node itself. The empty list is a real spawn input, forwarded and persisted as given.
134
+ it('provisions with no bootstrap nodes, forwarding and persisting the empty list', async () => {
135
+ const orch = new FakeOrchestrator();
136
+ const store = new DonationStore(join(tmpRoot, 'donations'));
137
+ const { grants, token } = makeGrants();
138
+ const svc = new DonationService({ orchestrator: orch, grants, store });
139
+
140
+ const view = await svc.provision({ ...baseRequest(token), bootstrapNodes: [] });
141
+
142
+ expect(view.status).toBe('awaiting_seed');
143
+ expect(orch.createCalls[0].bootstrapNodes).toEqual([]);
144
+ expect(store.get(view.id)?.bootstrapNodes).toEqual([]);
145
+ });
146
+
132
147
  /**
133
148
  * The pins are checked before the grant is even validated, so a typo costs the
134
149
  * requester nothing: no orchestrator call, no record, and — the point — no quota
@@ -395,6 +410,31 @@ describe('DonationService.applySeed', () => {
395
410
  expect(store.get(provisioned.id)).toEqual(before);
396
411
  });
397
412
 
413
+ // A 401 is the node refusing the HOST's bearer — it never looked at the seed —
414
+ // so it must not read as the requester's seed being untrusted.
415
+ it('reports a node that refuses the host credential as a credential fault, not a seed rejection', async () => {
416
+ const orch = new FakeOrchestrator();
417
+ const store = new DonationStore(join(tmpRoot, 'donations'));
418
+ const { grants, token } = makeGrants();
419
+ const svc = new DonationService({ orchestrator: orch, grants, store });
420
+
421
+ const provisioned = await svc.provision(baseRequest(token));
422
+ const before = store.get(provisioned.id)!;
423
+ globalThis.fetch = (async () => new Response(
424
+ JSON.stringify({ success: false, error: 'unauthorized' }),
425
+ { status: 401, headers: { 'content-type': 'application/json' } },
426
+ )) as typeof globalThis.fetch;
427
+
428
+ const err = await svc.applySeed(provisioned.id, 'encoded-seed').catch((e: unknown) => e);
429
+
430
+ expect(err).toBeInstanceOf(DonationError);
431
+ expect(err).toMatchObject({
432
+ code: 'seed_failed',
433
+ message: 'Donated node rejected the host\'s seed credential (401)',
434
+ });
435
+ expect(store.get(provisioned.id)).toEqual(before);
436
+ });
437
+
398
438
  it('lets a borrower terminate that lands mid-seed win', async () => {
399
439
  const orch = new FakeOrchestrator();
400
440
  const store = new DonationStore(join(tmpRoot, 'donations'));
@@ -509,7 +549,7 @@ describe('DonationService.respawn', () => {
509
549
  expect(orch.createCalls[1]).toMatchObject({
510
550
  containerId: provisioned.id,
511
551
  partyId: 'party-P',
512
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
552
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
513
553
  pinnedOwnerKeys: [OWNER_KEY],
514
554
  profile: 'storage',
515
555
  });
@@ -564,6 +604,49 @@ describe('DonationService.respawn', () => {
564
604
  expect(store.get('grn_legacy')?.dockerId).toBe('dock_old');
565
605
  });
566
606
 
607
+ // The empty list a phone requester provisions with is a persisted spawn input like
608
+ // any other, so that loan must come back too.
609
+ it('respawns a record provisioned with no bootstrap nodes, replaying the empty list', async () => {
610
+ const orch = new FakeOrchestrator();
611
+ const store = new DonationStore(join(tmpRoot, 'donations'));
612
+ const { grants, token } = makeGrants();
613
+ const svc = new DonationService({ orchestrator: orch, grants, store });
614
+
615
+ const provisioned = await svc.provision({ ...baseRequest(token), bootstrapNodes: [] });
616
+ store.put({ ...store.get(provisioned.id)!, status: 'seeded' });
617
+
618
+ const result = await svc.respawn(provisioned.id);
619
+
620
+ expect(result).toMatchObject({ outcome: 'respawned' });
621
+ expect(orch.createCalls).toHaveLength(2);
622
+ expect(orch.createCalls[1].bootstrapNodes).toEqual([]);
623
+ expect(store.get(provisioned.id)?.dockerId).toBe('dock_2');
624
+ });
625
+
626
+ // Only the field's ABSENCE marks a record from before spawn inputs were persisted,
627
+ // so owner keys alone do not make a record replayable.
628
+ it('skips a record that has owner keys but no bootstrapNodes field', async () => {
629
+ const orch = new FakeOrchestrator();
630
+ const store = new DonationStore(join(tmpRoot, 'donations'));
631
+ const { grants, token } = makeGrants();
632
+ const svc = new DonationService({ orchestrator: orch, grants, store });
633
+
634
+ store.put({
635
+ id: 'grn_partial',
636
+ grantToken: token,
637
+ partyId: 'party-P',
638
+ ownerKeys: [OWNER_KEY],
639
+ profile: 'storage',
640
+ status: 'seeded',
641
+ dockerId: 'dock_old',
642
+ createdAt: '2025-01-01T00:00:00.000Z',
643
+ updatedAt: '2025-01-01T00:00:00.000Z',
644
+ });
645
+
646
+ await expect(svc.respawn('grn_partial')).resolves.toEqual({ outcome: 'not_respawnable' });
647
+ expect(orch.createCalls).toHaveLength(0);
648
+ });
649
+
567
650
  it('refuses to resurrect a terminated loan', async () => {
568
651
  const orch = new FakeOrchestrator();
569
652
  const store = new DonationStore(join(tmpRoot, 'donations'));
@@ -606,8 +689,12 @@ describe('DonationService.respawn', () => {
606
689
 
607
690
  const stored = store.get(provisioned.id)!;
608
691
  expect(stored.respawn?.attempts).toBe(1);
609
- // The old handles are untouched — the caller owns backoff, not cleanup.
692
+ // The old handles are untouched — the caller owns backoff, not cleanup. The
693
+ // seed token above all: the child still running was started with it, so a
694
+ // rotated token would make that child refuse every later seed.
610
695
  expect(stored.dockerId).toBe('dock_1');
696
+ expect(stored.seedEndpoint).toBe('http://127.0.0.1:9001/seed');
697
+ expect(stored.seedToken).toBe('seed-token-1');
611
698
  expect(stored.status).toBe('awaiting_seed');
612
699
  });
613
700
 
@@ -833,7 +920,7 @@ describe('DonationService.terminate', () => {
833
920
  id: 'grn_stuck',
834
921
  grantToken: token,
835
922
  partyId: 'party-P',
836
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
923
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
837
924
  ownerKeys: [OWNER_KEY],
838
925
  profile: 'storage',
839
926
  status: 'provisioning',
@@ -952,7 +1039,7 @@ describe('DonationService.reapStaleProvisioning', () => {
952
1039
  id: opts.id,
953
1040
  grantToken: opts.token,
954
1041
  partyId: 'party-P',
955
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
1042
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
956
1043
  ownerKeys: [OWNER_KEY],
957
1044
  profile: 'storage',
958
1045
  status: 'provisioning',
@@ -998,7 +1085,7 @@ describe('DonationService.reapStaleProvisioning', () => {
998
1085
  const spawn = await orch.createContainer({
999
1086
  containerId: 'grn_stuck',
1000
1087
  partyId: 'party-P',
1001
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
1088
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
1002
1089
  profile: 'storage',
1003
1090
  pinnedOwnerKeys: [OWNER_KEY],
1004
1091
  });
@@ -1041,7 +1128,7 @@ describe('DonationService.reapStaleProvisioning', () => {
1041
1128
  const firstSpawn = await orch.createContainer({
1042
1129
  containerId: 'grn_first',
1043
1130
  partyId: 'party-P',
1044
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
1131
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
1045
1132
  profile: 'storage',
1046
1133
  pinnedOwnerKeys: [OWNER_KEY],
1047
1134
  });
@@ -84,7 +84,7 @@ function makeHarness(): Harness {
84
84
  provision: () => service.provision({
85
85
  grantToken: token,
86
86
  partyId: 'party-P',
87
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
87
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
88
88
  ownerKeys: [OWNER_KEY],
89
89
  }),
90
90
  };
@@ -229,6 +229,43 @@ describe('DonationSupervisor.reconcile', () => {
229
229
  expect(h.store.liveNodeCount(stored.grantToken)).toBe(0);
230
230
  });
231
231
 
232
+ // A spawn that succeeds and then dies at once (a crash on boot, a port clash)
233
+ // never throws out of `respawn`, so a give-up checked only on a throw would
234
+ // respawn such a node forever, rotating its seed token every time.
235
+ it('gives up on a node whose every respawn spawns but does not stay up', async () => {
236
+ const h = makeHarness();
237
+ const view = await h.provision();
238
+ setStatus(h.store, view.id, 'seeded');
239
+ h.orch.crash(dockerIdOf(view));
240
+ h.orch.onSpawned = (dockerId) => { h.orch.crash(dockerId); };
241
+
242
+ for (let i = 0; i < DONATION_RESPAWN_MAX_ATTEMPTS; i++) {
243
+ await expect(h.supervisor.reconcile()).resolves.toEqual([view.id]);
244
+ // Well past the longest backoff, so every pass gets to act.
245
+ h.advance(10 * 60_000);
246
+ }
247
+ expect(h.orch.createCalls).toHaveLength(1 + DONATION_RESPAWN_MAX_ATTEMPTS);
248
+ expect(requireDonation(h.store, view.id).status).toBe('seeded');
249
+
250
+ // The budget is spent and the last respawn died too: this pass gives up
251
+ // instead of spawning again.
252
+ await expect(h.supervisor.reconcile()).resolves.toEqual([]);
253
+ const stored = requireDonation(h.store, view.id);
254
+ expect(h.orch.createCalls).toHaveLength(1 + DONATION_RESPAWN_MAX_ATTEMPTS);
255
+ expect(stored.status).toBe('error');
256
+ expect(stored.respawn?.attempts).toBe(DONATION_RESPAWN_MAX_ATTEMPTS);
257
+ expect(stored.error).toContain(`${DONATION_RESPAWN_MAX_ATTEMPTS} attempts`);
258
+ expect(stored.error).toContain('did not stay running');
259
+ // The child the record names is stopped, never reclaimed.
260
+ expect(h.orch.stopped).toEqual([stored.dockerId]);
261
+ expect(h.orch.removed).toEqual([]);
262
+ expect(h.store.liveNodeCount(stored.grantToken)).toBe(0);
263
+
264
+ h.advance(10 * 60_000);
265
+ await expect(h.supervisor.reconcile()).resolves.toEqual([]);
266
+ expect(h.orch.createCalls).toHaveLength(1 + DONATION_RESPAWN_MAX_ATTEMPTS);
267
+ });
268
+
232
269
  it('refills the attempt budget once a respawned node has stayed up', async () => {
233
270
  const h = makeHarness();
234
271
  const view = await h.provision();
@@ -284,7 +321,7 @@ describe('DonationSupervisor.reconcile', () => {
284
321
  id: 'grn_inflight',
285
322
  grantToken: 'tok',
286
323
  partyId: 'party-P',
287
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
324
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
288
325
  ownerKeys: [OWNER_KEY],
289
326
  profile: 'storage',
290
327
  status: 'provisioning',
@@ -304,7 +341,7 @@ describe('DonationSupervisor.reconcile', () => {
304
341
  id: 'grn_nohandle',
305
342
  grantToken: 'tok',
306
343
  partyId: 'party-P',
307
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
344
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
308
345
  ownerKeys: [OWNER_KEY],
309
346
  profile: 'storage',
310
347
  status: 'awaiting_seed',
@@ -349,10 +386,11 @@ describe('DonationSupervisor.reconcile', () => {
349
386
  h.store.put({
350
387
  ...requireDonation(h.store, view.id),
351
388
  status: 'seeded',
352
- // Already at the cap, and long past the longest backoff — so the next
353
- // failure lands straight in the give-up path.
389
+ // One attempt short of the cap (at the cap, the pass would give up before
390
+ // attempting), and long past the longest backoff — so the next failure
391
+ // lands straight in the give-up path.
354
392
  respawn: {
355
- attempts: DONATION_RESPAWN_MAX_ATTEMPTS,
393
+ attempts: DONATION_RESPAWN_MAX_ATTEMPTS - 1,
356
394
  lastAttemptAt: new Date(h.nowMs() - 10 * 60_000).toISOString(),
357
395
  },
358
396
  });
@@ -370,7 +408,7 @@ describe('DonationSupervisor.reconcile', () => {
370
408
  expect(stored.status).toBe('terminated');
371
409
  expect(stored.error).toBeUndefined();
372
410
  // The attempt counter is the one thing the failed attempt may still record.
373
- expect(stored.respawn?.attempts).toBe(DONATION_RESPAWN_MAX_ATTEMPTS + 1);
411
+ expect(stored.respawn?.attempts).toBe(DONATION_RESPAWN_MAX_ATTEMPTS);
374
412
  expect(h.orch.stopped).toEqual([]);
375
413
  });
376
414
 
@@ -380,10 +418,10 @@ describe('DonationSupervisor.reconcile', () => {
380
418
  h.store.put({
381
419
  ...requireDonation(h.store, view.id),
382
420
  status: 'seeded',
383
- // Already at the cap and long past the longest backoff, so a *failed*
384
- // attempt here would land straight in the give-up path.
421
+ // One attempt short of the cap and long past the longest backoff, so a
422
+ // *failed* attempt here would land straight in the give-up path.
385
423
  respawn: {
386
- attempts: DONATION_RESPAWN_MAX_ATTEMPTS,
424
+ attempts: DONATION_RESPAWN_MAX_ATTEMPTS - 1,
387
425
  lastAttemptAt: new Date(h.nowMs() - 10 * 60_000).toISOString(),
388
426
  },
389
427
  });
@@ -400,7 +438,7 @@ describe('DonationSupervisor.reconcile', () => {
400
438
  // Nothing threw, so no attempt was persisted and give-up never ran.
401
439
  expect(stored.status).toBe('terminated');
402
440
  expect(stored.error).toBeUndefined();
403
- expect(stored.respawn?.attempts).toBe(DONATION_RESPAWN_MAX_ATTEMPTS);
441
+ expect(stored.respawn?.attempts).toBe(DONATION_RESPAWN_MAX_ATTEMPTS - 1);
404
442
  expect(stored.dockerId).toBe(view.dockerId);
405
443
  // The abandoned child is stopped and reclaimed, not left holding ports.
406
444
  expect(h.orch.stopped).toContain('dock_2');
@@ -480,7 +518,7 @@ describe('DonationSupervisor start/stop', () => {
480
518
  status: 'stopped',
481
519
  spawnedAt: new Date(START_MS).toISOString(),
482
520
  workdir: '/fake/owner',
483
- ports: { health: 0, metrics: 0, p2p: 0, admin: 0 },
521
+ ports: { health: 0, metrics: 0, p2p: 0, admin: 0, ws: 0 },
484
522
  owner: true,
485
523
  };
486
524
  h.orch.emit(ownerExit);