@serfab/cadre-host 1.14.0 → 1.16.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 (107) hide show
  1. package/README.md +19 -4
  2. package/dist/bin/host.js +32 -20
  3. package/dist/bin/host.js.map +1 -1
  4. package/dist/bin/nat-output.d.ts +5 -0
  5. package/dist/bin/nat-output.js +12 -1
  6. package/dist/bin/nat-output.js.map +1 -1
  7. package/dist/bin/ui-access.d.ts +22 -0
  8. package/dist/bin/ui-access.js +44 -0
  9. package/dist/bin/ui-access.js.map +1 -0
  10. package/dist/hosted/claim-details.d.ts +25 -3
  11. package/dist/hosted/claim-details.js +31 -37
  12. package/dist/hosted/claim-details.js.map +1 -1
  13. package/dist/hosted/hosted-node-service.d.ts +7 -0
  14. package/dist/hosted/hosted-node-service.js +16 -4
  15. package/dist/hosted/hosted-node-service.js.map +1 -1
  16. package/dist/hosted/hosted-node-watcher.d.ts +16 -4
  17. package/dist/hosted/hosted-node-watcher.js +45 -9
  18. package/dist/hosted/hosted-node-watcher.js.map +1 -1
  19. package/dist/hosted/node-status.d.ts +19 -3
  20. package/dist/hosted/node-status.js +21 -5
  21. package/dist/hosted/node-status.js.map +1 -1
  22. package/dist/hosted/types.d.ts +12 -5
  23. package/dist/hosted/types.js.map +1 -1
  24. package/dist/installer/config.d.ts +23 -0
  25. package/dist/installer/config.js +46 -0
  26. package/dist/installer/config.js.map +1 -1
  27. package/dist/installer/index.d.ts +2 -0
  28. package/dist/installer/index.js +7 -16
  29. package/dist/installer/index.js.map +1 -1
  30. package/dist/installer/wizard.d.ts +12 -2
  31. package/dist/installer/wizard.js +15 -7
  32. package/dist/installer/wizard.js.map +1 -1
  33. package/dist/nat/index.d.ts +2 -1
  34. package/dist/nat/index.js +1 -0
  35. package/dist/nat/index.js.map +1 -1
  36. package/dist/nat/nat-service.d.ts +22 -5
  37. package/dist/nat/nat-service.js +75 -26
  38. package/dist/nat/nat-service.js.map +1 -1
  39. package/dist/nat/nat-store.js +11 -0
  40. package/dist/nat/nat-store.js.map +1 -1
  41. package/dist/nat/network-mode.d.ts +19 -0
  42. package/dist/nat/network-mode.js +32 -0
  43. package/dist/nat/network-mode.js.map +1 -0
  44. package/dist/nat/reachability.d.ts +5 -4
  45. package/dist/nat/reachability.js +19 -6
  46. package/dist/nat/reachability.js.map +1 -1
  47. package/dist/nat/types.d.ts +26 -6
  48. package/dist/nat/types.js.map +1 -1
  49. package/dist/orchestrator/host-process-orchestrator.d.ts +3 -3
  50. package/dist/orchestrator/host-process-orchestrator.js +6 -15
  51. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  52. package/dist/package-version.d.ts +1 -0
  53. package/dist/package-version.js +4 -0
  54. package/dist/package-version.js.map +1 -0
  55. package/dist/server/routes/settings.d.ts +1 -0
  56. package/dist/server/routes/settings.js +15 -1
  57. package/dist/server/routes/settings.js.map +1 -1
  58. package/dist/server/routes/status.js +2 -32
  59. package/dist/server/routes/status.js.map +1 -1
  60. package/dist/ui/assets/index-BHd8CWZA.css +1 -0
  61. package/dist/ui/assets/index-Ccr2GjPT.js +23 -0
  62. package/dist/ui/assets/{index-4FWuU8ye.js.map → index-Ccr2GjPT.js.map} +1 -1
  63. package/dist/ui/index.html +2 -2
  64. package/package.json +4 -5
  65. package/src/__tests__/cli.smoke.test.ts +1 -1
  66. package/src/__tests__/orchestrator-claim-secret.test.ts +1 -2
  67. package/src/__tests__/orchestrator-env-scrub.test.ts +160 -163
  68. package/src/__tests__/orchestrator-handle-contract.ts +1 -1
  69. package/src/__tests__/orchestrator-node-identity.test.ts +0 -5
  70. package/src/__tests__/orchestrator-push.test.ts +3 -3
  71. package/src/__tests__/orchestrator.test.ts +3 -5
  72. package/src/__tests__/ui-access.test.ts +30 -0
  73. package/src/bin/host.ts +30 -20
  74. package/src/bin/nat-output.ts +12 -1
  75. package/src/bin/ui-access.ts +50 -0
  76. package/src/hosted/__tests__/fake-orchestrator.test.ts +0 -1
  77. package/src/hosted/__tests__/fake-orchestrator.ts +263 -265
  78. package/src/hosted/__tests__/hosted-node-service.test.ts +708 -609
  79. package/src/hosted/__tests__/hosted-node-supervisor.test.ts +475 -474
  80. package/src/hosted/claim-details.ts +44 -37
  81. package/src/hosted/hosted-node-service.ts +23 -4
  82. package/src/hosted/hosted-node-watcher.ts +288 -250
  83. package/src/hosted/node-status.ts +31 -5
  84. package/src/hosted/types.ts +160 -153
  85. package/src/installer/__tests__/installer.smoke.test.ts +184 -182
  86. package/src/installer/__tests__/wizard.test.ts +76 -56
  87. package/src/installer/config.ts +66 -0
  88. package/src/installer/index.ts +252 -259
  89. package/src/installer/wizard.ts +101 -87
  90. package/src/nat/__tests__/nat-service.test.ts +608 -540
  91. package/src/nat/__tests__/nat-store.test.ts +130 -127
  92. package/src/nat/__tests__/network-mode.test.ts +38 -0
  93. package/src/nat/index.ts +91 -87
  94. package/src/nat/nat-service.ts +79 -26
  95. package/src/nat/nat-store.ts +249 -237
  96. package/src/nat/network-mode.ts +36 -0
  97. package/src/nat/reachability.ts +155 -143
  98. package/src/nat/types.ts +232 -209
  99. package/src/orchestrator/host-process-orchestrator.ts +1089 -1098
  100. package/src/package-version.ts +4 -0
  101. package/src/server/__tests__/fakes.ts +41 -40
  102. package/src/server/__tests__/hosted-nodes-route.test.ts +161 -160
  103. package/src/server/__tests__/settings-route.test.ts +31 -0
  104. package/src/server/routes/settings.ts +128 -115
  105. package/src/server/routes/status.ts +72 -102
  106. package/dist/ui/assets/index-4FWuU8ye.js +0 -19
  107. package/dist/ui/assets/index-Di9Q4d01.css +0 -1
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <meta name="color-scheme" content="light dark" />
7
7
  <title>cadre-host</title>
8
- <script type="module" crossorigin src="./assets/index-4FWuU8ye.js"></script>
9
- <link rel="stylesheet" crossorigin href="./assets/index-Di9Q4d01.css">
8
+ <script type="module" crossorigin src="./assets/index-Ccr2GjPT.js"></script>
9
+ <link rel="stylesheet" crossorigin href="./assets/index-BHd8CWZA.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serfab/cadre-host",
3
- "version": "1.14.0",
3
+ "version": "1.16.0",
4
4
  "type": "module",
5
5
  "description": "Self-hosted cadre node manager for basement-PC deployments",
6
6
  "main": "dist/index.js",
@@ -63,10 +63,9 @@
63
63
  "@libp2p/crypto": "^5.1.23",
64
64
  "@libp2p/interface": "^3.3.0",
65
65
  "@libp2p/peer-id": "^6.0.15",
66
- "@multiformats/multiaddr": "^13.0.3",
67
- "@serfab/cadre-cli": "^1.14.0",
68
- "@serfab/cadre-core": "^1.14.0",
69
- "@serfab/cadre-provider": "^1.14.0",
66
+ "@serfab/cadre-cli": "^1.16.0",
67
+ "@serfab/cadre-core": "^1.16.0",
68
+ "@serfab/cadre-provider": "^1.16.0",
70
69
  "commander": "^14.0.2",
71
70
  "debug": "^4.4.3",
72
71
  "fastify": "^5.7.1",
@@ -42,7 +42,7 @@ describe('cadre-host CLI smoke test', () => {
42
42
  encoding: 'utf8',
43
43
  });
44
44
  // The built CLI reads package.json at runtime, so this holds even against a
45
- // stale dist — it catches the version falling back to '0.0.0-unknown'.
45
+ // stale dist — it catches dist/package-version.js resolving the wrong path.
46
46
  const pkg = JSON.parse(
47
47
  readFileSync(resolve(here, '../../package.json'), 'utf8'),
48
48
  ) as { version: string };
@@ -93,7 +93,6 @@ describe('HostProcessOrchestrator claim secret', () => {
93
93
  await orch.createContainer({
94
94
  containerId: 'hn_claim',
95
95
  partyId: 'unclaimed',
96
- bootstrapNodes: [],
97
96
  profile: 'storage',
98
97
  claimSecret: SECRET,
99
98
  });
@@ -105,7 +104,7 @@ describe('HostProcessOrchestrator claim secret', () => {
105
104
 
106
105
  // No secret, no variable: `cadre-cli start` treats a set-but-empty value as unset,
107
106
  // but the child must not even see the name.
108
- await orch.createContainer({ containerId: 'hn_plain', partyId: 'p', bootstrapNodes: [], profile: 'storage' });
107
+ await orch.createContainer({ containerId: 'hn_plain', partyId: 'p', profile: 'storage' });
109
108
  const plain = JSON.parse(await waitForFile(join(rootDir, 'hn_plain', 'spawn-seen.json'))) as { secret: string | null };
110
109
  expect(plain.secret).toBeNull();
111
110
  });
@@ -1,163 +1,160 @@
1
- /**
2
- * HostProcessOrchestrator env-scrub test — the manager's own `process.env` must
3
- * not leak `CADRE_*` config-override vars into spawned children.
4
- *
5
- * `launchChild` used to build each child's environment as
6
- * `{ ...process.env, ...extraEnv, <fixed per-child vars> }`, so ANY `CADRE_*`
7
- * var set on the manager (not just the ones the orchestrator deliberately
8
- * sets) reached every child. Because `@serfab/cadre-cli` treats an inherited
9
- * `CADRE_*` var as a config override that beats the per-child config file, a
10
- * `CADRE_PARTY_ID` set on the manager could silently pull every child into
11
- * that party.
12
- *
13
- * A fake CLI records the vars under test from its environment to a file so
14
- * the scrub is observable without a real cadre node.
15
- *
16
- * The same record carries `CADRE_LISTEN_ADDRS`, one of the vars the orchestrator
17
- * deliberately SETS: a child must be handed a WebSocket listener beside its TCP one,
18
- * on the ports its handle holds, or a phone cannot reach it.
19
- */
20
-
21
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
22
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
23
- import { tmpdir } from 'node:os';
24
- import { join } from 'node:path';
25
-
26
- import { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js';
27
- import { removeAllNodes } from './orchestrator-teardown.js';
28
-
29
- // Writes the vars under test (as seen in the child env) next to the startup token.
30
- const FAKE_CLI = `
31
- import fs from 'node:fs';
32
- import path from 'node:path';
33
- const args = process.argv.slice(2);
34
- const get = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
35
- const tokenPath = get('--startup-token-file');
36
- const token = process.env.CADRE_STARTUP_TOKEN ?? '';
37
- if (tokenPath) {
38
- const dir = path.dirname(tokenPath);
39
- const record = {
40
- CADRE_PARTY_ID: process.env.CADRE_PARTY_ID ?? null,
41
- CADRE_STORAGE_PATH: process.env.CADRE_STORAGE_PATH ?? null,
42
- CADRE_ADMIN_PORT: process.env.CADRE_ADMIN_PORT ?? null,
43
- CADRE_LISTEN_ADDRS: process.env.CADRE_LISTEN_ADDRS ?? null,
44
- DEBUG: process.env.DEBUG ?? null,
45
- };
46
- // Write then rename: the test polls for the file's existence, and must never read it half-written.
47
- const seenPath = path.join(dir, 'env-seen.json');
48
- try { fs.writeFileSync(seenPath + '.tmp', JSON.stringify(record), 'utf8'); fs.renameSync(seenPath + '.tmp', seenPath); } catch (e) { console.error(e); }
49
- if (token) { try { fs.writeFileSync(tokenPath, token, 'utf8'); } catch (e) { console.error(e); } }
50
- }
51
- process.on('SIGTERM', () => process.exit(0));
52
- setInterval(() => {}, 1 << 30);
53
- `;
54
-
55
- let tmpRoot: string;
56
- let scriptPath: string;
57
- const orchestrators: HostProcessOrchestrator[] = [];
58
- let savedEnv: Record<string, string | undefined>;
59
-
60
- beforeEach(() => {
61
- tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-env-scrub-'));
62
- scriptPath = join(tmpRoot, 'fake-cli.mjs');
63
- writeFileSync(scriptPath, FAKE_CLI, 'utf8');
64
- savedEnv = {
65
- CADRE_PARTY_ID: process.env.CADRE_PARTY_ID,
66
- CADRE_STORAGE_PATH: process.env.CADRE_STORAGE_PATH,
67
- CADRE_ADMIN_PORT: process.env.CADRE_ADMIN_PORT,
68
- DEBUG: process.env.DEBUG,
69
- };
70
- });
71
-
72
- afterEach(async () => {
73
- try {
74
- await removeAllNodes(orchestrators);
75
- } finally {
76
- await sleep(50);
77
- try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
78
- for (const [key, value] of Object.entries(savedEnv)) {
79
- if (value === undefined) delete process.env[key];
80
- else process.env[key] = value;
81
- }
82
- }
83
- });
84
-
85
- function makeOrchestrator(rootDir: string): HostProcessOrchestrator {
86
- mkdirSync(rootDir, { recursive: true });
87
- const orch = new HostProcessOrchestrator({
88
- rootDir,
89
- portRange: { start: 18000, end: 18499 },
90
- stopTimeoutMs: 1500,
91
- spawn: { entrypoint: scriptPath },
92
- });
93
- orchestrators.push(orch);
94
- return orch;
95
- }
96
-
97
- async function waitForFile(path: string, timeoutMs = 5000): Promise<string> {
98
- const deadline = Date.now() + timeoutMs;
99
- while (Date.now() < deadline) {
100
- if (existsSync(path)) return readFileSync(path, 'utf8');
101
- await sleep(50);
102
- }
103
- throw new Error(`file never appeared: ${path}`);
104
- }
105
-
106
- function sleep(ms: number): Promise<void> {
107
- return new Promise((res) => setTimeout(res, ms));
108
- }
109
-
110
- describe('HostProcessOrchestrator child env scrub', () => {
111
- it('does not leak manager CADRE_* config-override vars into children, but passes through non-CADRE vars', async () => {
112
- process.env.CADRE_PARTY_ID = 'conflicting-party';
113
- process.env.CADRE_STORAGE_PATH = '/tmp/should-not-leak';
114
- // Not an ENV_MAPPINGS config override — read directly by the cli's `start`
115
- // command — so it only stays scrubbed if the scrub is prefix-wide.
116
- process.env.CADRE_ADMIN_PORT = '19999';
117
- process.env.DEBUG = 'some:value';
118
-
119
- const rootDir = join(tmpRoot, 'a');
120
- const orch = makeOrchestrator(rootDir);
121
- await orch.init();
122
-
123
- await orch.createContainer({
124
- containerId: 'child-1',
125
- partyId: 'party-1',
126
- bootstrapNodes: [],
127
- profile: 'storage',
128
- });
129
- await orch.createContainer({
130
- containerId: 'child-2',
131
- partyId: 'party-2',
132
- bootstrapNodes: [],
133
- profile: 'storage',
134
- });
135
-
136
- const seen1 = JSON.parse(await waitForFile(join(rootDir, 'child-1', 'env-seen.json')));
137
- const seen2 = JSON.parse(await waitForFile(join(rootDir, 'child-2', 'env-seen.json')));
138
-
139
- for (const seen of [seen1, seen2]) {
140
- expect(seen.CADRE_PARTY_ID).toBeNull();
141
- expect(seen.CADRE_STORAGE_PATH).toBeNull();
142
- expect(seen.CADRE_ADMIN_PORT).toBeNull();
143
- expect(seen.DEBUG).toBe('some:value');
144
- }
145
- });
146
-
147
- it('hands each child a TCP and a WebSocket listen address on its own ports', async () => {
148
- const rootDir = join(tmpRoot, 'b');
149
- const orch = makeOrchestrator(rootDir);
150
- await orch.init();
151
-
152
- await orch.createContainer({
153
- containerId: 'child-1',
154
- partyId: 'party-1',
155
- bootstrapNodes: [],
156
- profile: 'storage',
157
- });
158
-
159
- const seen = JSON.parse(await waitForFile(join(rootDir, 'child-1', 'env-seen.json')));
160
- const ports = orch.getNode('child-1')!.ports;
161
- expect(seen.CADRE_LISTEN_ADDRS).toBe(`/ip4/0.0.0.0/tcp/${ports.p2p},/ip4/0.0.0.0/tcp/${ports.ws}/ws`);
162
- });
163
- });
1
+ /**
2
+ * HostProcessOrchestrator env-scrub test — the manager's own `process.env` must
3
+ * not leak `CADRE_*` config-override vars into spawned children.
4
+ *
5
+ * `launchChild` used to build each child's environment as
6
+ * `{ ...process.env, ...extraEnv, <fixed per-child vars> }`, so ANY `CADRE_*`
7
+ * var set on the manager (not just the ones the orchestrator deliberately
8
+ * sets) reached every child. Because `@serfab/cadre-cli` treats an inherited
9
+ * `CADRE_*` var as a config override that beats the per-child config file, a
10
+ * `CADRE_PARTY_ID` set on the manager could silently pull every child into
11
+ * that party.
12
+ *
13
+ * A fake CLI records the vars under test from its environment to a file so
14
+ * the scrub is observable without a real cadre node.
15
+ *
16
+ * The same record carries `CADRE_LISTEN_ADDRS`, one of the vars the orchestrator
17
+ * deliberately SETS: a child must be handed a WebSocket listener beside its TCP one,
18
+ * on the ports its handle holds, or a phone cannot reach it.
19
+ */
20
+
21
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
22
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
23
+ import { tmpdir } from 'node:os';
24
+ import { join } from 'node:path';
25
+
26
+ import { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js';
27
+ import { removeAllNodes } from './orchestrator-teardown.js';
28
+
29
+ // Writes the vars under test (as seen in the child env) next to the startup token.
30
+ const FAKE_CLI = `
31
+ import fs from 'node:fs';
32
+ import path from 'node:path';
33
+ const args = process.argv.slice(2);
34
+ const get = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
35
+ const tokenPath = get('--startup-token-file');
36
+ const token = process.env.CADRE_STARTUP_TOKEN ?? '';
37
+ if (tokenPath) {
38
+ const dir = path.dirname(tokenPath);
39
+ const record = {
40
+ CADRE_PARTY_ID: process.env.CADRE_PARTY_ID ?? null,
41
+ CADRE_STORAGE_PATH: process.env.CADRE_STORAGE_PATH ?? null,
42
+ CADRE_ADMIN_PORT: process.env.CADRE_ADMIN_PORT ?? null,
43
+ CADRE_LISTEN_ADDRS: process.env.CADRE_LISTEN_ADDRS ?? null,
44
+ DEBUG: process.env.DEBUG ?? null,
45
+ };
46
+ // Write then rename: the test polls for the file's existence, and must never read it half-written.
47
+ const seenPath = path.join(dir, 'env-seen.json');
48
+ try { fs.writeFileSync(seenPath + '.tmp', JSON.stringify(record), 'utf8'); fs.renameSync(seenPath + '.tmp', seenPath); } catch (e) { console.error(e); }
49
+ if (token) { try { fs.writeFileSync(tokenPath, token, 'utf8'); } catch (e) { console.error(e); } }
50
+ }
51
+ process.on('SIGTERM', () => process.exit(0));
52
+ setInterval(() => {}, 1 << 30);
53
+ `;
54
+
55
+ let tmpRoot: string;
56
+ let scriptPath: string;
57
+ const orchestrators: HostProcessOrchestrator[] = [];
58
+ let savedEnv: Record<string, string | undefined>;
59
+
60
+ beforeEach(() => {
61
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-env-scrub-'));
62
+ scriptPath = join(tmpRoot, 'fake-cli.mjs');
63
+ writeFileSync(scriptPath, FAKE_CLI, 'utf8');
64
+ savedEnv = {
65
+ CADRE_PARTY_ID: process.env.CADRE_PARTY_ID,
66
+ CADRE_STORAGE_PATH: process.env.CADRE_STORAGE_PATH,
67
+ CADRE_ADMIN_PORT: process.env.CADRE_ADMIN_PORT,
68
+ DEBUG: process.env.DEBUG,
69
+ };
70
+ });
71
+
72
+ afterEach(async () => {
73
+ try {
74
+ await removeAllNodes(orchestrators);
75
+ } finally {
76
+ await sleep(50);
77
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
78
+ for (const [key, value] of Object.entries(savedEnv)) {
79
+ if (value === undefined) delete process.env[key];
80
+ else process.env[key] = value;
81
+ }
82
+ }
83
+ });
84
+
85
+ function makeOrchestrator(rootDir: string): HostProcessOrchestrator {
86
+ mkdirSync(rootDir, { recursive: true });
87
+ const orch = new HostProcessOrchestrator({
88
+ rootDir,
89
+ portRange: { start: 18000, end: 18499 },
90
+ stopTimeoutMs: 1500,
91
+ spawn: { entrypoint: scriptPath },
92
+ });
93
+ orchestrators.push(orch);
94
+ return orch;
95
+ }
96
+
97
+ async function waitForFile(path: string, timeoutMs = 5000): Promise<string> {
98
+ const deadline = Date.now() + timeoutMs;
99
+ while (Date.now() < deadline) {
100
+ if (existsSync(path)) return readFileSync(path, 'utf8');
101
+ await sleep(50);
102
+ }
103
+ throw new Error(`file never appeared: ${path}`);
104
+ }
105
+
106
+ function sleep(ms: number): Promise<void> {
107
+ return new Promise((res) => setTimeout(res, ms));
108
+ }
109
+
110
+ describe('HostProcessOrchestrator child env scrub', () => {
111
+ it('does not leak manager CADRE_* config-override vars into children, but passes through non-CADRE vars', async () => {
112
+ process.env.CADRE_PARTY_ID = 'conflicting-party';
113
+ process.env.CADRE_STORAGE_PATH = '/tmp/should-not-leak';
114
+ // Not an ENV_MAPPINGS config override — read directly by the cli's `start`
115
+ // command — so it only stays scrubbed if the scrub is prefix-wide.
116
+ process.env.CADRE_ADMIN_PORT = '19999';
117
+ process.env.DEBUG = 'some:value';
118
+
119
+ const rootDir = join(tmpRoot, 'a');
120
+ const orch = makeOrchestrator(rootDir);
121
+ await orch.init();
122
+
123
+ await orch.createContainer({
124
+ containerId: 'child-1',
125
+ partyId: 'party-1',
126
+ profile: 'storage',
127
+ });
128
+ await orch.createContainer({
129
+ containerId: 'child-2',
130
+ partyId: 'party-2',
131
+ profile: 'storage',
132
+ });
133
+
134
+ const seen1 = JSON.parse(await waitForFile(join(rootDir, 'child-1', 'env-seen.json')));
135
+ const seen2 = JSON.parse(await waitForFile(join(rootDir, 'child-2', 'env-seen.json')));
136
+
137
+ for (const seen of [seen1, seen2]) {
138
+ expect(seen.CADRE_PARTY_ID).toBeNull();
139
+ expect(seen.CADRE_STORAGE_PATH).toBeNull();
140
+ expect(seen.CADRE_ADMIN_PORT).toBeNull();
141
+ expect(seen.DEBUG).toBe('some:value');
142
+ }
143
+ });
144
+
145
+ it('hands each child a TCP and a WebSocket listen address on its own ports', async () => {
146
+ const rootDir = join(tmpRoot, 'b');
147
+ const orch = makeOrchestrator(rootDir);
148
+ await orch.init();
149
+
150
+ await orch.createContainer({
151
+ containerId: 'child-1',
152
+ partyId: 'party-1',
153
+ profile: 'storage',
154
+ });
155
+
156
+ const seen = JSON.parse(await waitForFile(join(rootDir, 'child-1', 'env-seen.json')));
157
+ const ports = orch.getNode('child-1')!.ports;
158
+ expect(seen.CADRE_LISTEN_ADDRS).toBe(`/ip4/0.0.0.0/tcp/${ports.p2p},/ip4/0.0.0.0/tcp/${ports.ws}/ws`);
159
+ });
160
+ });
@@ -87,7 +87,7 @@ export function describeHandleContract<O extends ContractOrchestrator>(
87
87
  });
88
88
 
89
89
  // Launching over a live child hands its ports to a second child that then
90
- // dies on them, while the caller records the new dockerId and seed token.
90
+ // dies on them, while the caller records the new dockerId.
91
91
  it('refuses to re-spawn a container whose previous child is still running', async () => {
92
92
  const orch = harness.make();
93
93
  const first = await spawn(orch, CONTAINER);
@@ -95,7 +95,6 @@ describe('HostProcessOrchestrator node identity', () => {
95
95
  await orch.createContainer({
96
96
  containerId: 'hosted-1',
97
97
  partyId: 'party-P',
98
- bootstrapNodes: [],
99
98
  profile: 'storage',
100
99
  });
101
100
 
@@ -114,7 +113,6 @@ describe('HostProcessOrchestrator node identity', () => {
114
113
  const first = await orch.createContainer({
115
114
  containerId: 'hosted-2',
116
115
  partyId: 'party-P',
117
- bootstrapNodes: [],
118
116
  profile: 'storage',
119
117
  });
120
118
  const workdir = join(rootDir, 'hosted-2');
@@ -127,7 +125,6 @@ describe('HostProcessOrchestrator node identity', () => {
127
125
  await orch.createContainer({
128
126
  containerId: 'hosted-2',
129
127
  partyId: 'party-P',
130
- bootstrapNodes: [],
131
128
  profile: 'storage',
132
129
  });
133
130
 
@@ -143,7 +140,6 @@ describe('HostProcessOrchestrator node identity', () => {
143
140
  const created = await orch.createContainer({
144
141
  containerId: 'hosted-3',
145
142
  partyId: 'party-P',
146
- bootstrapNodes: [],
147
143
  profile: 'storage',
148
144
  });
149
145
  const identityPath = join(rootDir, 'hosted-3', 'identity.key');
@@ -170,7 +166,6 @@ describe('HostProcessOrchestrator node identity', () => {
170
166
 
171
167
  const request = {
172
168
  partyId: 'party-P',
173
- bootstrapNodes: [],
174
169
  profile: 'storage' as const,
175
170
  };
176
171
  await expect(orch.createContainer({ ...request, containerId: 'hosted-bad' })).rejects.toThrow();
@@ -30,7 +30,7 @@ setInterval(() => {}, 1 << 30);
30
30
  const FCM = { projectId: 'proj', clientEmail: 'svc@proj.iam', privateKey: 'FCM-SECRET-PEM' };
31
31
  const APNS = { keyId: 'KID', teamId: 'TEAM', bundleId: 'com.example.app', privateKey: 'APNS-SECRET-P8', production: false };
32
32
  /** A storage node — the one profile whose spawn carries push. */
33
- const NODE = { containerId: 'n1', partyId: 'p', bootstrapNodes: [], profile: 'storage' as const };
33
+ const NODE = { containerId: 'n1', partyId: 'p', profile: 'storage' as const };
34
34
 
35
35
  let tmpRoot: string;
36
36
  let scriptPath: string;
@@ -129,10 +129,10 @@ describe('HostProcessOrchestrator push injection', () => {
129
129
  const orch = makeOrchestrator(rootDir, async () => ({ fcm: FCM, apns: APNS, cooldownMs: 1000 }));
130
130
  await orch.init();
131
131
 
132
- await orch.createContainer({ containerId: 'storage-1', partyId: 'p', bootstrapNodes: [], profile: 'storage' });
132
+ await orch.createContainer({ containerId: 'storage-1', partyId: 'p', profile: 'storage' });
133
133
  expect(readChildConfig(rootDir, 'storage-1').push).toEqual({ fcm: FCM, apns: APNS, cooldownMs: 1000 });
134
134
 
135
- await orch.createContainer({ containerId: 'txn-1', partyId: 'p', bootstrapNodes: [], profile: 'transaction' });
135
+ await orch.createContainer({ containerId: 'txn-1', partyId: 'p', profile: 'transaction' });
136
136
  expect(readChildConfig(rootDir, 'txn-1').push).toBeUndefined();
137
137
  });
138
138
  });
@@ -119,7 +119,6 @@ function makeRequest(containerId: string, opts: { profile?: 'storage' | 'transac
119
119
  return {
120
120
  containerId,
121
121
  partyId: 'party-' + containerId,
122
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
123
122
  profile: opts.profile ?? 'transaction' as 'storage' | 'transaction',
124
123
  };
125
124
  }
@@ -556,9 +555,9 @@ describe('HostProcessOrchestrator.isRunning', () => {
556
555
  *
557
556
  * The incident this guards against: the respawn supervisor asked `isRunning`
558
557
  * during that window, got `false`, and re-spawned the container. The re-spawn
559
- * released and reused the same ports, rotated the seed token, and returned
560
- * success; the second child then died on the port the first still held, leaving
561
- * the host's records naming a dead child and the live one refusing every seed.
558
+ * released and reused the same ports and returned success; the second child
559
+ * then died on the port the first still held, leaving the host's records naming
560
+ * a dead child while the live one ran with no handle naming it.
562
561
  */
563
562
  describe('HostProcessOrchestrator re-spawn over a child that is still starting', () => {
564
563
  const TOKEN_DELAY_MS = 3000;
@@ -911,7 +910,6 @@ describe('child survives orchestrator exit', () => {
911
910
  const request = {
912
911
  containerId: 'cross1',
913
912
  partyId: 'party-cross1',
914
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
915
913
  profile: 'transaction',
916
914
  };
917
915
  const payload = {
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { currentLogin, isHeadless, uiAccessLines } from '../bin/ui-access.js';
4
+
5
+ describe('isHeadless', () => {
6
+ it('is true over SSH on any platform, and on Linux without a display', () => {
7
+ expect(isHeadless('darwin', { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' })).toBe(true);
8
+ expect(isHeadless('linux', {})).toBe(true);
9
+ expect(isHeadless('linux', { DISPLAY: ':0' })).toBe(false);
10
+ expect(isHeadless('linux', { WAYLAND_DISPLAY: 'wayland-0' })).toBe(false);
11
+ expect(isHeadless('darwin', {})).toBe(false);
12
+ });
13
+ });
14
+
15
+ describe('uiAccessLines', () => {
16
+ it('gives the URL on a desktop, and the SSH forward (same port both sides) when headless', () => {
17
+ expect(uiAccessLines(8765, false)).toEqual(['Open the UI: http://127.0.0.1:8765']);
18
+ const lines = uiAccessLines(8765, true, { user: 'kyle', host: 'kjeib' });
19
+ expect(lines).toContain(' ssh -L 8765:127.0.0.1:8765 kyle@kjeib');
20
+ expect(lines.join('\n')).toContain('http://127.0.0.1:8765');
21
+ });
22
+ });
23
+
24
+ describe('currentLogin', () => {
25
+ it('over SSH, names the server address and port the client connected to', () => {
26
+ const who = currentLogin({ SSH_CONNECTION: '198.51.100.20 51234 203.0.113.7 2222' });
27
+ expect(who).toMatchObject({ host: '203.0.113.7', sshPort: 2222 });
28
+ expect(uiAccessLines(8765, true, who)[1]).toBe(` ssh -p 2222 -L 8765:127.0.0.1:8765 ${who.user}@203.0.113.7`);
29
+ });
30
+ });