@openparachute/hub 0.6.2 → 0.6.3-rc.2

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 (58) hide show
  1. package/README.md +87 -35
  2. package/package.json +1 -1
  3. package/src/__tests__/api-hub-upgrade.test.ts +690 -0
  4. package/src/__tests__/api-modules-ops.test.ts +359 -3
  5. package/src/__tests__/api-modules.test.ts +54 -0
  6. package/src/__tests__/expose-cloudflare.test.ts +163 -72
  7. package/src/__tests__/expose-off-auto.test.ts +26 -1
  8. package/src/__tests__/expose.test.ts +260 -240
  9. package/src/__tests__/hub-control.test.ts +1 -242
  10. package/src/__tests__/hub-server.test.ts +64 -0
  11. package/src/__tests__/hub-unit.test.ts +574 -0
  12. package/src/__tests__/init.test.ts +219 -2
  13. package/src/__tests__/lifecycle.test.ts +416 -1448
  14. package/src/__tests__/managed-unit.test.ts +575 -0
  15. package/src/__tests__/migrate-cutover.test.ts +840 -0
  16. package/src/__tests__/migrate-offer.test.ts +240 -0
  17. package/src/__tests__/migrate.test.ts +132 -0
  18. package/src/__tests__/module-ops-client.test.ts +556 -0
  19. package/src/__tests__/port-probe.test.ts +23 -0
  20. package/src/__tests__/setup-wizard.test.ts +130 -0
  21. package/src/__tests__/status-supervisor.test.ts +504 -0
  22. package/src/__tests__/status.test.ts +157 -708
  23. package/src/__tests__/supervisor.test.ts +471 -6
  24. package/src/__tests__/upgrade.test.ts +351 -5
  25. package/src/api-hub-upgrade.ts +384 -0
  26. package/src/api-hub.ts +2 -1
  27. package/src/api-modules-ops.ts +221 -0
  28. package/src/api-modules.ts +18 -2
  29. package/src/cli.ts +97 -12
  30. package/src/cloudflare/connector-service.ts +117 -322
  31. package/src/commands/expose-cloudflare.ts +63 -71
  32. package/src/commands/expose-supervisor.ts +247 -0
  33. package/src/commands/expose.ts +59 -48
  34. package/src/commands/init.ts +225 -12
  35. package/src/commands/lifecycle.ts +455 -816
  36. package/src/commands/migrate-cutover.ts +837 -0
  37. package/src/commands/migrate.ts +71 -2
  38. package/src/commands/serve-boot.ts +71 -25
  39. package/src/commands/status.ts +535 -235
  40. package/src/commands/upgrade.ts +100 -2
  41. package/src/help.ts +128 -68
  42. package/src/hub-control.ts +23 -162
  43. package/src/hub-server.ts +39 -0
  44. package/src/hub-unit.ts +735 -0
  45. package/src/hub-upgrade-helper.ts +306 -0
  46. package/src/hub-upgrade-mode.ts +209 -0
  47. package/src/hub-upgrade-status.ts +150 -0
  48. package/src/managed-unit.ts +692 -0
  49. package/src/migrate-offer.ts +186 -0
  50. package/src/module-ops-client.ts +457 -0
  51. package/src/port-probe.ts +50 -0
  52. package/src/process-state.ts +19 -3
  53. package/src/setup-wizard.ts +80 -1
  54. package/src/supervisor.ts +389 -38
  55. package/web/ui/dist/assets/index-D_6AFvZy.js +61 -0
  56. package/web/ui/dist/assets/{index-BiBlvEaj.css → index-mz8XcVPP.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-CIN3mnmf.js +0 -61
@@ -2,11 +2,12 @@ import { describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import type { ExposeSupervisorOpts } from "../commands/expose-supervisor.ts";
5
6
  import { exposePublic, exposeTailnet } from "../commands/expose.ts";
6
7
  import { readEnvFileValues } from "../env-file.ts";
7
8
  import { readExposeState, writeExposeState } from "../expose-state.ts";
8
- import type { EnsureHubOpts, HubSpawner, StopHubOpts } from "../hub-control.ts";
9
- import { writePid } from "../process-state.ts";
9
+ import type { EnsureHubUnitOpts } from "../hub-unit.ts";
10
+ import { type ModuleOp, ModuleOpHttpError } from "../module-ops-client.ts";
10
11
  import { upsertService } from "../services-manifest.ts";
11
12
  import type { Runner } from "../tailscale/run.ts";
12
13
 
@@ -54,36 +55,16 @@ function makeRunner(): { runner: Runner; calls: string[][] } {
54
55
  return { runner, calls };
55
56
  }
56
57
 
57
- function makeHubSpawner(pid: number): { spawner: HubSpawner; calls: string[][] } {
58
- const calls: string[][] = [];
59
- const spawner: HubSpawner = {
60
- spawn(cmd) {
61
- calls.push([...cmd]);
62
- return pid;
63
- },
64
- };
65
- return { spawner, calls };
66
- }
67
-
68
- /** Default hub overrides for expose tests — no real subprocess, no sleep. */
69
- function hubEnsureOpts(
70
- spawner: HubSpawner,
71
- ): Omit<EnsureHubOpts, "configDir" | "wellKnownDir" | "log"> {
72
- return {
73
- spawner,
74
- alive: () => true,
75
- probe: async () => true,
76
- readyWaitMs: 0,
77
- };
78
- }
79
-
80
- function hubStopOpts(): Omit<StopHubOpts, "configDir" | "log"> {
81
- return {
82
- kill: () => {},
83
- alive: () => false,
84
- sleep: async () => {},
85
- now: () => 0,
86
- };
58
+ /**
59
+ * A minimal expose `supervisor` seam that forces the unit-installed arm with a
60
+ * benign `ensureHubUnit → already-up` + no-op `driveModuleOp` / self-heal. Most
61
+ * expose tests just need the hub-bringup to succeed so they can assert the
62
+ * tailscale plan / expose-state / well-known behavior; this stub provides that
63
+ * without a real launchd/systemd or hub. (Phase 5b retired the detached hub
64
+ * spawner that the old `makeHubSpawner` / `hubEnsureOpts` stubs simulated.)
65
+ */
66
+ function supervisorUp(): ExposeSupervisorOpts {
67
+ return makeExposeSupervisorStub().opts;
87
68
  }
88
69
 
89
70
  function seedServices(path: string): void {
@@ -122,7 +103,6 @@ describe("expose tailnet up", () => {
122
103
  try {
123
104
  seedServices(h.manifestPath);
124
105
  const { runner, calls } = makeRunner();
125
- const { spawner } = makeHubSpawner(1111);
126
106
  const logs: string[] = [];
127
107
  const code = await exposeTailnet("up", {
128
108
  runner,
@@ -130,9 +110,8 @@ describe("expose tailnet up", () => {
130
110
  statePath: h.statePath,
131
111
  wellKnownPath: h.wellKnownPath,
132
112
  hubPath: h.hubPath,
133
- wellKnownDir: h.wellKnownDir,
134
113
  configDir: h.configDir,
135
- hubEnsureOpts: hubEnsureOpts(spawner),
114
+ supervisor: supervisorUp(),
136
115
  servicePortProbe: allServicesUp,
137
116
  log: (l) => logs.push(l),
138
117
  });
@@ -172,31 +151,31 @@ describe("expose tailnet up", () => {
172
151
  }
173
152
  });
174
153
 
175
- test("spawns hub server with --port + --well-known-dir", async () => {
154
+ test("ensures the hub UNIT (not a detached spawn) before exposing", async () => {
155
+ // Phase 5b: "ensure the hub" = ensure the hub UNIT is up (§4.3a). The old
156
+ // detached `bun hub-server.ts --port …` spawn is retired — its bringup is now
157
+ // init's `installAndStartHubUnit` (asserted in init.test.ts / hub-unit.test.ts).
158
+ // Here we just confirm expose drives the unit-ensure seam, then proceeds.
176
159
  const h = makeHarness();
177
160
  try {
178
161
  seedServices(h.manifestPath);
179
162
  const { runner } = makeRunner();
180
- const { spawner, calls: hubCalls } = makeHubSpawner(7777);
163
+ const sup = makeExposeSupervisorStub();
164
+ const logs: string[] = [];
181
165
  const code = await exposeTailnet("up", {
182
166
  runner,
183
167
  manifestPath: h.manifestPath,
184
168
  statePath: h.statePath,
185
169
  wellKnownPath: h.wellKnownPath,
186
170
  hubPath: h.hubPath,
187
- wellKnownDir: h.wellKnownDir,
188
171
  configDir: h.configDir,
189
- hubEnsureOpts: hubEnsureOpts(spawner),
172
+ supervisor: sup.opts,
190
173
  servicePortProbe: allServicesUp,
191
- log: () => {},
174
+ log: (l) => logs.push(l),
192
175
  });
193
176
  expect(code).toBe(0);
194
- expect(hubCalls).toHaveLength(1);
195
- const cmd = hubCalls[0] ?? [];
196
- expect(cmd[0]).toBe("bun");
197
- expect(cmd).toContain("--port");
198
- expect(cmd).toContain("--well-known-dir");
199
- expect(cmd).toContain(h.wellKnownDir);
177
+ expect(sup.ensureCalls).toHaveLength(1);
178
+ expect(logs.join("\n")).toMatch(/hub unit up/);
200
179
  } finally {
201
180
  h.cleanup();
202
181
  }
@@ -220,16 +199,14 @@ describe("expose tailnet up", () => {
220
199
  h.manifestPath,
221
200
  );
222
201
  const { runner, calls } = makeRunner();
223
- const { spawner } = makeHubSpawner(1111);
224
202
  const code = await exposeTailnet("up", {
225
203
  runner,
226
204
  manifestPath: h.manifestPath,
227
205
  statePath: h.statePath,
228
206
  wellKnownPath: h.wellKnownPath,
229
207
  hubPath: h.hubPath,
230
- wellKnownDir: h.wellKnownDir,
231
208
  configDir: h.configDir,
232
- hubEnsureOpts: hubEnsureOpts(spawner),
209
+ supervisor: supervisorUp(),
233
210
  servicePortProbe: allServicesUp,
234
211
  log: () => {},
235
212
  });
@@ -258,7 +235,6 @@ describe("expose tailnet up", () => {
258
235
  h.manifestPath,
259
236
  );
260
237
  const { runner, calls } = makeRunner();
261
- const { spawner } = makeHubSpawner(1111);
262
238
  const logs: string[] = [];
263
239
  const code = await exposeTailnet("up", {
264
240
  runner,
@@ -266,9 +242,8 @@ describe("expose tailnet up", () => {
266
242
  statePath: h.statePath,
267
243
  wellKnownPath: h.wellKnownPath,
268
244
  hubPath: h.hubPath,
269
- wellKnownDir: h.wellKnownDir,
270
245
  configDir: h.configDir,
271
- hubEnsureOpts: hubEnsureOpts(spawner),
246
+ supervisor: supervisorUp(),
272
247
  servicePortProbe: allServicesUp,
273
248
  log: (l) => logs.push(l),
274
249
  });
@@ -291,7 +266,6 @@ describe("expose tailnet up", () => {
291
266
  const h = makeHarness();
292
267
  try {
293
268
  const { runner } = makeRunner();
294
- const { spawner } = makeHubSpawner(1111);
295
269
  const logs: string[] = [];
296
270
  const code = await exposeTailnet("up", {
297
271
  runner,
@@ -299,9 +273,8 @@ describe("expose tailnet up", () => {
299
273
  statePath: h.statePath,
300
274
  wellKnownPath: h.wellKnownPath,
301
275
  hubPath: h.hubPath,
302
- wellKnownDir: h.wellKnownDir,
303
276
  configDir: h.configDir,
304
- hubEnsureOpts: hubEnsureOpts(spawner),
277
+ supervisor: supervisorUp(),
305
278
  servicePortProbe: allServicesUp,
306
279
  log: (l) => logs.push(l),
307
280
  });
@@ -319,7 +292,6 @@ describe("expose tailnet up", () => {
319
292
  const runner: Runner = async () => {
320
293
  throw new Error("spawn tailscale ENOENT");
321
294
  };
322
- const { spawner } = makeHubSpawner(1111);
323
295
  const logs: string[] = [];
324
296
  const code = await exposeTailnet("up", {
325
297
  runner,
@@ -327,9 +299,8 @@ describe("expose tailnet up", () => {
327
299
  statePath: h.statePath,
328
300
  wellKnownPath: h.wellKnownPath,
329
301
  hubPath: h.hubPath,
330
- wellKnownDir: h.wellKnownDir,
331
302
  configDir: h.configDir,
332
- hubEnsureOpts: hubEnsureOpts(spawner),
303
+ supervisor: supervisorUp(),
333
304
  servicePortProbe: allServicesUp,
334
305
  log: (l) => logs.push(l),
335
306
  });
@@ -364,16 +335,14 @@ describe("expose tailnet up", () => {
364
335
  h.statePath,
365
336
  );
366
337
  const { runner, calls } = makeRunner();
367
- const { spawner } = makeHubSpawner(1111);
368
338
  const code = await exposeTailnet("up", {
369
339
  runner,
370
340
  manifestPath: h.manifestPath,
371
341
  statePath: h.statePath,
372
342
  wellKnownPath: h.wellKnownPath,
373
343
  hubPath: h.hubPath,
374
- wellKnownDir: h.wellKnownDir,
375
344
  configDir: h.configDir,
376
- hubEnsureOpts: hubEnsureOpts(spawner),
345
+ supervisor: supervisorUp(),
377
346
  servicePortProbe: allServicesUp,
378
347
  log: () => {},
379
348
  });
@@ -395,7 +364,6 @@ describe("expose tailnet up", () => {
395
364
  try {
396
365
  seedServices(h.manifestPath);
397
366
  const { runner, calls } = makeRunner();
398
- const { spawner } = makeHubSpawner(1111);
399
367
  const logs: string[] = [];
400
368
  const code = await exposeTailnet("up", {
401
369
  runner,
@@ -403,9 +371,8 @@ describe("expose tailnet up", () => {
403
371
  statePath: h.statePath,
404
372
  wellKnownPath: h.wellKnownPath,
405
373
  hubPath: h.hubPath,
406
- wellKnownDir: h.wellKnownDir,
407
374
  configDir: h.configDir,
408
- hubEnsureOpts: hubEnsureOpts(spawner),
375
+ supervisor: supervisorUp(),
409
376
  // vault is up; notes is down.
410
377
  servicePortProbe: async (port) => port === 1940,
411
378
  log: (l) => logs.push(l),
@@ -444,7 +411,6 @@ describe("expose tailnet up", () => {
444
411
  }
445
412
  return { code: 0, stdout: "", stderr: "" };
446
413
  };
447
- const { spawner } = makeHubSpawner(1111);
448
414
  const logs: string[] = [];
449
415
  const code = await exposeTailnet("up", {
450
416
  runner,
@@ -452,9 +418,8 @@ describe("expose tailnet up", () => {
452
418
  statePath: h.statePath,
453
419
  wellKnownPath: h.wellKnownPath,
454
420
  hubPath: h.hubPath,
455
- wellKnownDir: h.wellKnownDir,
456
421
  configDir: h.configDir,
457
- hubEnsureOpts: hubEnsureOpts(spawner),
422
+ supervisor: supervisorUp(),
458
423
  servicePortProbe: allServicesUp,
459
424
  log: (l) => logs.push(l),
460
425
  });
@@ -473,16 +438,14 @@ describe("expose tailnet up", () => {
473
438
  try {
474
439
  seedServices(h.manifestPath);
475
440
  const { runner, calls } = makeRunner();
476
- const { spawner } = makeHubSpawner(1111);
477
441
  const code = await exposeTailnet("up", {
478
442
  runner,
479
443
  manifestPath: h.manifestPath,
480
444
  statePath: h.statePath,
481
445
  wellKnownPath: h.wellKnownPath,
482
446
  hubPath: h.hubPath,
483
- wellKnownDir: h.wellKnownDir,
484
447
  configDir: h.configDir,
485
- hubEnsureOpts: hubEnsureOpts(spawner),
448
+ supervisor: supervisorUp(),
486
449
  servicePortProbe: allServicesUp,
487
450
  log: () => {},
488
451
  });
@@ -520,16 +483,14 @@ describe("expose tailnet up", () => {
520
483
  h.manifestPath,
521
484
  );
522
485
  const { runner, calls } = makeRunner();
523
- const { spawner } = makeHubSpawner(1111);
524
486
  const code = await exposeTailnet("up", {
525
487
  runner,
526
488
  manifestPath: h.manifestPath,
527
489
  statePath: h.statePath,
528
490
  wellKnownPath: h.wellKnownPath,
529
491
  hubPath: h.hubPath,
530
- wellKnownDir: h.wellKnownDir,
531
492
  configDir: h.configDir,
532
- hubEnsureOpts: hubEnsureOpts(spawner),
493
+ supervisor: supervisorUp(),
533
494
  servicePortProbe: allServicesUp,
534
495
  log: () => {},
535
496
  });
@@ -549,16 +510,14 @@ describe("expose tailnet up", () => {
549
510
  try {
550
511
  seedServices(h.manifestPath);
551
512
  const { runner } = makeRunner();
552
- const { spawner } = makeHubSpawner(1111);
553
513
  const code = await exposeTailnet("up", {
554
514
  runner,
555
515
  manifestPath: h.manifestPath,
556
516
  statePath: h.statePath,
557
517
  wellKnownPath: h.wellKnownPath,
558
518
  hubPath: h.hubPath,
559
- wellKnownDir: h.wellKnownDir,
560
519
  configDir: h.configDir,
561
- hubEnsureOpts: hubEnsureOpts(spawner),
520
+ supervisor: supervisorUp(),
562
521
  servicePortProbe: allServicesUp,
563
522
  hubOrigin: "https://hub.example.com/",
564
523
  log: () => {},
@@ -580,7 +539,6 @@ describe("expose tailnet up", () => {
580
539
  try {
581
540
  seedServices(h.manifestPath);
582
541
  const { runner } = makeRunner();
583
- const { spawner } = makeHubSpawner(1111);
584
542
  const logs: string[] = [];
585
543
  const code = await exposeTailnet("up", {
586
544
  runner,
@@ -588,9 +546,8 @@ describe("expose tailnet up", () => {
588
546
  statePath: h.statePath,
589
547
  wellKnownPath: h.wellKnownPath,
590
548
  hubPath: h.hubPath,
591
- wellKnownDir: h.wellKnownDir,
592
549
  configDir: h.configDir,
593
- hubEnsureOpts: hubEnsureOpts(spawner),
550
+ supervisor: supervisorUp(),
594
551
  servicePortProbe: allServicesUp,
595
552
  log: (l) => logs.push(l),
596
553
  vaultAuthStatus: {
@@ -619,9 +576,7 @@ describe("expose tailnet off", () => {
619
576
  statePath: h.statePath,
620
577
  wellKnownPath: h.wellKnownPath,
621
578
  hubPath: h.hubPath,
622
- wellKnownDir: h.wellKnownDir,
623
579
  configDir: h.configDir,
624
- hubStopOpts: hubStopOpts(),
625
580
  log: (l) => logs.push(l),
626
581
  });
627
582
  expect(code).toBe(0);
@@ -632,7 +587,7 @@ describe("expose tailnet off", () => {
632
587
  }
633
588
  });
634
589
 
635
- test("tears down every tracked entry, stops hub, and clears state", async () => {
590
+ test("tears down every tracked entry + clears state, leaves the hub running (D3)", async () => {
636
591
  const h = makeHarness();
637
592
  try {
638
593
  writeExposeState(
@@ -662,27 +617,15 @@ describe("expose tailnet off", () => {
662
617
  );
663
618
  await Bun.write(h.wellKnownPath, "{}\n");
664
619
  await Bun.write(h.hubPath, "<html/>\n");
665
- writePid("hub", 4242, h.configDir);
666
620
  const { runner, calls } = makeRunner();
667
- const signals: NodeJS.Signals[] = [];
668
- let aliveNow = true;
621
+ const logs: string[] = [];
669
622
  const code = await exposeTailnet("off", {
670
623
  runner,
671
624
  statePath: h.statePath,
672
625
  wellKnownPath: h.wellKnownPath,
673
626
  hubPath: h.hubPath,
674
- wellKnownDir: h.wellKnownDir,
675
627
  configDir: h.configDir,
676
- hubStopOpts: {
677
- kill: (_pid, sig) => {
678
- signals.push(sig as NodeJS.Signals);
679
- aliveNow = false;
680
- },
681
- alive: () => aliveNow,
682
- sleep: async () => {},
683
- now: () => 0,
684
- },
685
- log: () => {},
628
+ log: (l) => logs.push(l),
686
629
  });
687
630
  expect(code).toBe(0);
688
631
  expect(calls.every((c) => c[c.length - 1] === "off")).toBe(true);
@@ -690,8 +633,10 @@ describe("expose tailnet off", () => {
690
633
  expect(existsSync(h.statePath)).toBe(false);
691
634
  expect(existsSync(h.wellKnownPath)).toBe(false);
692
635
  expect(existsSync(h.hubPath)).toBe(false);
693
- // Hub was running and got stopped.
694
- expect(signals).toContain("SIGTERM");
636
+ // D3 (Phase 5b): the hub is a persistent platform unit — `expose off` tears
637
+ // down only the exposure and leaves the hub running. No "hub stopped" line.
638
+ expect(logs.join("\n")).not.toMatch(/hub stopped/);
639
+ expect(logs.join("\n")).toMatch(/exposure removed/);
695
640
  } finally {
696
641
  h.cleanup();
697
642
  }
@@ -729,7 +674,6 @@ describe("expose tailnet off", () => {
729
674
  statePath: h.statePath,
730
675
  wellKnownPath: h.wellKnownPath,
731
676
  hubPath: h.hubPath,
732
- wellKnownDir: h.wellKnownDir,
733
677
  configDir: h.configDir,
734
678
  skipHub: true,
735
679
  log: () => {},
@@ -773,9 +717,7 @@ describe("expose tailnet off", () => {
773
717
  statePath: h.statePath,
774
718
  wellKnownPath: h.wellKnownPath,
775
719
  hubPath: h.hubPath,
776
- wellKnownDir: h.wellKnownDir,
777
720
  configDir: h.configDir,
778
- hubStopOpts: hubStopOpts(),
779
721
  log: (l) => logs.push(l),
780
722
  });
781
723
  expect(code).toBe(5);
@@ -807,31 +749,19 @@ describe("expose tailnet off", () => {
807
749
  },
808
750
  h.statePath,
809
751
  );
810
- writePid("hub", 4242, h.configDir);
811
752
  const { runner, calls } = makeRunner();
812
- let killCalled = false;
813
753
  const logs: string[] = [];
814
754
  const code = await exposeTailnet("off", {
815
755
  runner,
816
756
  statePath: h.statePath,
817
757
  wellKnownPath: h.wellKnownPath,
818
758
  hubPath: h.hubPath,
819
- wellKnownDir: h.wellKnownDir,
820
759
  configDir: h.configDir,
821
- hubStopOpts: {
822
- kill: () => {
823
- killCalled = true;
824
- },
825
- alive: () => false,
826
- sleep: async () => {},
827
- now: () => 0,
828
- },
829
760
  log: (l) => logs.push(l),
830
761
  });
831
762
  expect(code).toBe(0);
832
763
  expect(calls).toHaveLength(0);
833
764
  expect(existsSync(h.statePath)).toBe(true);
834
- expect(killCalled).toBe(false);
835
765
  expect(logs.join("\n")).toMatch(/Current exposure is Public/);
836
766
  } finally {
837
767
  h.cleanup();
@@ -845,7 +775,6 @@ describe("expose public up", () => {
845
775
  try {
846
776
  seedServices(h.manifestPath);
847
777
  const { runner, calls } = makeRunner();
848
- const { spawner } = makeHubSpawner(1111);
849
778
  const logs: string[] = [];
850
779
  const code = await exposePublic("up", {
851
780
  runner,
@@ -853,9 +782,8 @@ describe("expose public up", () => {
853
782
  statePath: h.statePath,
854
783
  wellKnownPath: h.wellKnownPath,
855
784
  hubPath: h.hubPath,
856
- wellKnownDir: h.wellKnownDir,
857
785
  configDir: h.configDir,
858
- hubEnsureOpts: hubEnsureOpts(spawner),
786
+ supervisor: supervisorUp(),
859
787
  servicePortProbe: allServicesUp,
860
788
  log: (l) => logs.push(l),
861
789
  });
@@ -908,16 +836,14 @@ describe("expose public up", () => {
908
836
  h.statePath,
909
837
  );
910
838
  const { runner, calls } = makeRunner();
911
- const { spawner } = makeHubSpawner(1111);
912
839
  const code = await exposeTailnet("up", {
913
840
  runner,
914
841
  manifestPath: h.manifestPath,
915
842
  statePath: h.statePath,
916
843
  wellKnownPath: h.wellKnownPath,
917
844
  hubPath: h.hubPath,
918
- wellKnownDir: h.wellKnownDir,
919
845
  configDir: h.configDir,
920
- hubEnsureOpts: hubEnsureOpts(spawner),
846
+ supervisor: supervisorUp(),
921
847
  servicePortProbe: allServicesUp,
922
848
  log: () => {},
923
849
  });
@@ -956,16 +882,14 @@ describe("expose public up", () => {
956
882
  h.statePath,
957
883
  );
958
884
  const { runner, calls } = makeRunner();
959
- const { spawner } = makeHubSpawner(1111);
960
885
  const code = await exposePublic("up", {
961
886
  runner,
962
887
  manifestPath: h.manifestPath,
963
888
  statePath: h.statePath,
964
889
  wellKnownPath: h.wellKnownPath,
965
890
  hubPath: h.hubPath,
966
- wellKnownDir: h.wellKnownDir,
967
891
  configDir: h.configDir,
968
- hubEnsureOpts: hubEnsureOpts(spawner),
892
+ supervisor: supervisorUp(),
969
893
  servicePortProbe: allServicesUp,
970
894
  log: () => {},
971
895
  });
@@ -987,7 +911,6 @@ describe("expose public up", () => {
987
911
  try {
988
912
  seedServices(h.manifestPath);
989
913
  const { runner } = makeRunner();
990
- const { spawner } = makeHubSpawner(1111);
991
914
  const logs: string[] = [];
992
915
  const code = await exposePublic("up", {
993
916
  runner,
@@ -995,9 +918,8 @@ describe("expose public up", () => {
995
918
  statePath: h.statePath,
996
919
  wellKnownPath: h.wellKnownPath,
997
920
  hubPath: h.hubPath,
998
- wellKnownDir: h.wellKnownDir,
999
921
  configDir: h.configDir,
1000
- hubEnsureOpts: hubEnsureOpts(spawner),
922
+ supervisor: supervisorUp(),
1001
923
  servicePortProbe: allServicesUp,
1002
924
  log: (l) => logs.push(l),
1003
925
  vaultAuthStatus: {
@@ -1025,7 +947,6 @@ describe("expose public up", () => {
1025
947
  try {
1026
948
  seedServices(h.manifestPath);
1027
949
  const { runner } = makeRunner();
1028
- const { spawner } = makeHubSpawner(1111);
1029
950
  const logs: string[] = [];
1030
951
  const code = await exposePublic("up", {
1031
952
  runner,
@@ -1033,9 +954,8 @@ describe("expose public up", () => {
1033
954
  statePath: h.statePath,
1034
955
  wellKnownPath: h.wellKnownPath,
1035
956
  hubPath: h.hubPath,
1036
- wellKnownDir: h.wellKnownDir,
1037
957
  configDir: h.configDir,
1038
- hubEnsureOpts: hubEnsureOpts(spawner),
958
+ supervisor: supervisorUp(),
1039
959
  servicePortProbe: allServicesUp,
1040
960
  log: (l) => logs.push(l),
1041
961
  vaultAuthStatus: {
@@ -1082,9 +1002,7 @@ describe("expose public off", () => {
1082
1002
  statePath: h.statePath,
1083
1003
  wellKnownPath: h.wellKnownPath,
1084
1004
  hubPath: h.hubPath,
1085
- wellKnownDir: h.wellKnownDir,
1086
1005
  configDir: h.configDir,
1087
- hubStopOpts: hubStopOpts(),
1088
1006
  log: () => {},
1089
1007
  });
1090
1008
  expect(code).toBe(0);
@@ -1127,9 +1045,7 @@ describe("expose public off", () => {
1127
1045
  statePath: h.statePath,
1128
1046
  wellKnownPath: h.wellKnownPath,
1129
1047
  hubPath: h.hubPath,
1130
- wellKnownDir: h.wellKnownDir,
1131
1048
  configDir: h.configDir,
1132
- hubStopOpts: hubStopOpts(),
1133
1049
  log: (l) => logs.push(l),
1134
1050
  });
1135
1051
  expect(code).toBe(0);
@@ -1180,16 +1096,14 @@ describe("expose plan is layer-agnostic — gating moved to hub", () => {
1180
1096
  h.manifestPath,
1181
1097
  );
1182
1098
  const { runner, calls } = makeRunner();
1183
- const { spawner } = makeHubSpawner(1111);
1184
1099
  const code = await exposeTailnet("up", {
1185
1100
  runner,
1186
1101
  manifestPath: h.manifestPath,
1187
1102
  statePath: h.statePath,
1188
1103
  wellKnownPath: h.wellKnownPath,
1189
1104
  hubPath: h.hubPath,
1190
- wellKnownDir: h.wellKnownDir,
1191
1105
  configDir: h.configDir,
1192
- hubEnsureOpts: hubEnsureOpts(spawner),
1106
+ supervisor: supervisorUp(),
1193
1107
  servicePortProbe: allServicesUp,
1194
1108
  log: () => {},
1195
1109
  });
@@ -1237,16 +1151,14 @@ describe("expose plan is layer-agnostic — gating moved to hub", () => {
1237
1151
  h.manifestPath,
1238
1152
  );
1239
1153
  const { runner, calls } = makeRunner();
1240
- const { spawner } = makeHubSpawner(1111);
1241
1154
  const code = await exposeTailnet("up", {
1242
1155
  runner,
1243
1156
  manifestPath: h.manifestPath,
1244
1157
  statePath: h.statePath,
1245
1158
  wellKnownPath: h.wellKnownPath,
1246
1159
  hubPath: h.hubPath,
1247
- wellKnownDir: h.wellKnownDir,
1248
1160
  configDir: h.configDir,
1249
- hubEnsureOpts: hubEnsureOpts(spawner),
1161
+ supervisor: supervisorUp(),
1250
1162
  servicePortProbe: allServicesUp,
1251
1163
  log: () => {},
1252
1164
  });
@@ -1265,114 +1177,49 @@ describe("expose auto-restart of hub-dependent services", () => {
1265
1177
  // Launch-day bug (2026-04-23): `expose public` updated hubOrigin in
1266
1178
  // expose-state.json, but a vault already running kept its stale
1267
1179
  // PARACHUTE_HUB_ORIGIN in memory, so the OAuth issuer didn't match what
1268
- // clients saw and claude.ai MCP failed to reach the server. The CLI used
1269
- // to print a "Restart vault to pick up…" hint that got lost in the wall
1270
- // of expose output. Auto-restart the service instead.
1271
- test("restarts vault when vault is running", async () => {
1272
- const h = makeHarness();
1273
- try {
1274
- seedServices(h.manifestPath);
1275
- writePid("vault", 4242, h.configDir);
1276
- const { runner } = makeRunner();
1277
- const { spawner } = makeHubSpawner(1111);
1278
- const restarted: string[] = [];
1279
- const code = await exposePublic("up", {
1280
- runner,
1281
- manifestPath: h.manifestPath,
1282
- statePath: h.statePath,
1283
- wellKnownPath: h.wellKnownPath,
1284
- hubPath: h.hubPath,
1285
- wellKnownDir: h.wellKnownDir,
1286
- configDir: h.configDir,
1287
- hubEnsureOpts: hubEnsureOpts(spawner),
1288
- servicePortProbe: allServicesUp,
1289
- alive: () => true,
1290
- restartService: async (short) => {
1291
- restarted.push(short);
1292
- return 0;
1293
- },
1294
- log: () => {},
1295
- });
1296
- expect(code).toBe(0);
1297
- expect(restarted).toEqual(["vault"]);
1298
- } finally {
1299
- h.cleanup();
1300
- }
1301
- });
1302
-
1303
- test("skips restart when vault is not running", async () => {
1180
+ // clients saw and claude.ai MCP failed to reach the server. Auto-restart the
1181
+ // service so it picks up the new origin.
1182
+ //
1183
+ // Phase 5b: the restart goes through the running supervisor
1184
+ // (`driveModuleOp("vault", "restart")`), NOT a pidfile-gated detached
1185
+ // `lifecycle.restart`. So the restart is unconditional (the supervisor decides
1186
+ // whether the module is live); the old "skips when not running / stale pidfile"
1187
+ // cases no longer apply — a not-supervised vault surfaces as a 404 the helper
1188
+ // treats as "nothing to restart" (asserted in the Phase 4 dual-dispatch suite).
1189
+ test("restarts vault via the supervisor after expose", async () => {
1304
1190
  const h = makeHarness();
1305
1191
  try {
1306
1192
  seedServices(h.manifestPath);
1307
- // No writePid → vault has no pidfile → processState returns "unknown".
1308
1193
  const { runner } = makeRunner();
1309
- const { spawner } = makeHubSpawner(1111);
1310
- const restarted: string[] = [];
1194
+ const sup = makeExposeSupervisorStub();
1311
1195
  const code = await exposePublic("up", {
1312
1196
  runner,
1313
1197
  manifestPath: h.manifestPath,
1314
1198
  statePath: h.statePath,
1315
1199
  wellKnownPath: h.wellKnownPath,
1316
1200
  hubPath: h.hubPath,
1317
- wellKnownDir: h.wellKnownDir,
1318
1201
  configDir: h.configDir,
1319
- hubEnsureOpts: hubEnsureOpts(spawner),
1202
+ supervisor: sup.opts,
1320
1203
  servicePortProbe: allServicesUp,
1321
- alive: () => true,
1322
- restartService: async (short) => {
1323
- restarted.push(short);
1324
- return 0;
1325
- },
1326
1204
  log: () => {},
1327
1205
  });
1328
1206
  expect(code).toBe(0);
1329
- expect(restarted).toEqual([]);
1207
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
1330
1208
  } finally {
1331
1209
  h.cleanup();
1332
1210
  }
1333
1211
  });
1334
1212
 
1335
- test("skips restart when pidfile is stale (process dead)", async () => {
1213
+ test("restart failure logs a warning but expose still succeeds", async () => {
1336
1214
  const h = makeHarness();
1337
1215
  try {
1338
1216
  seedServices(h.manifestPath);
1339
- writePid("vault", 4242, h.configDir);
1340
1217
  const { runner } = makeRunner();
1341
- const { spawner } = makeHubSpawner(1111);
1342
- const restarted: string[] = [];
1343
- const code = await exposePublic("up", {
1344
- runner,
1345
- manifestPath: h.manifestPath,
1346
- statePath: h.statePath,
1347
- wellKnownPath: h.wellKnownPath,
1348
- hubPath: h.hubPath,
1349
- wellKnownDir: h.wellKnownDir,
1350
- configDir: h.configDir,
1351
- hubEnsureOpts: hubEnsureOpts(spawner),
1352
- servicePortProbe: allServicesUp,
1353
- // Simulate pid-file-present-but-process-dead. processState returns
1354
- // "stopped", not "running", so we should skip.
1355
- alive: () => false,
1356
- restartService: async (short) => {
1357
- restarted.push(short);
1358
- return 0;
1359
- },
1360
- log: () => {},
1218
+ // A non-404 module-op error → surfaced as a warning, non-zero rcode mapped
1219
+ // to a warn-and-continue (expose still exits 0).
1220
+ const sup = makeExposeSupervisorStub({
1221
+ driveThrows: () => new ModuleOpHttpError(500, "internal", "vault restart blew up"),
1361
1222
  });
1362
- expect(code).toBe(0);
1363
- expect(restarted).toEqual([]);
1364
- } finally {
1365
- h.cleanup();
1366
- }
1367
- });
1368
-
1369
- test("restart failure logs warning but expose still succeeds", async () => {
1370
- const h = makeHarness();
1371
- try {
1372
- seedServices(h.manifestPath);
1373
- writePid("vault", 4242, h.configDir);
1374
- const { runner } = makeRunner();
1375
- const { spawner } = makeHubSpawner(1111);
1376
1223
  const logs: string[] = [];
1377
1224
  const code = await exposePublic("up", {
1378
1225
  runner,
@@ -1380,12 +1227,9 @@ describe("expose auto-restart of hub-dependent services", () => {
1380
1227
  statePath: h.statePath,
1381
1228
  wellKnownPath: h.wellKnownPath,
1382
1229
  hubPath: h.hubPath,
1383
- wellKnownDir: h.wellKnownDir,
1384
1230
  configDir: h.configDir,
1385
- hubEnsureOpts: hubEnsureOpts(spawner),
1231
+ supervisor: sup.opts,
1386
1232
  servicePortProbe: allServicesUp,
1387
- alive: () => true,
1388
- restartService: async () => 1,
1389
1233
  log: (l) => logs.push(l),
1390
1234
  });
1391
1235
  expect(code).toBe(0);
@@ -1446,9 +1290,7 @@ describe("expose teardown tolerance for already-gone entries", () => {
1446
1290
  statePath: h.statePath,
1447
1291
  wellKnownPath: h.wellKnownPath,
1448
1292
  hubPath: h.hubPath,
1449
- wellKnownDir: h.wellKnownDir,
1450
1293
  configDir: h.configDir,
1451
- hubStopOpts: hubStopOpts(),
1452
1294
  log: (l) => logs.push(l),
1453
1295
  });
1454
1296
  expect(code).toBe(0);
@@ -1485,9 +1327,7 @@ describe("expose teardown tolerance for already-gone entries", () => {
1485
1327
  statePath: h.statePath,
1486
1328
  wellKnownPath: h.wellKnownPath,
1487
1329
  hubPath: h.hubPath,
1488
- wellKnownDir: h.wellKnownDir,
1489
1330
  configDir: h.configDir,
1490
- hubStopOpts: hubStopOpts(),
1491
1331
  log: () => {},
1492
1332
  });
1493
1333
  expect(code).toBe(0);
@@ -1517,9 +1357,7 @@ describe("expose teardown tolerance for already-gone entries", () => {
1517
1357
  statePath: h.statePath,
1518
1358
  wellKnownPath: h.wellKnownPath,
1519
1359
  hubPath: h.hubPath,
1520
- wellKnownDir: h.wellKnownDir,
1521
1360
  configDir: h.configDir,
1522
- hubStopOpts: hubStopOpts(),
1523
1361
  log: (l) => logs.push(l),
1524
1362
  });
1525
1363
  expect(code).toBe(1);
@@ -1538,7 +1376,6 @@ describe("expose teardown tolerance for already-gone entries", () => {
1538
1376
  try {
1539
1377
  seedServices(h.manifestPath);
1540
1378
  makePublicPriorState(h.statePath, 2);
1541
- const { spawner } = makeHubSpawner(1111);
1542
1379
  const bringupCalls: string[][] = [];
1543
1380
  const runner: Runner = async (cmd) => {
1544
1381
  if (cmd[0] === "tailscale" && cmd[1] === "version") {
@@ -1569,9 +1406,8 @@ describe("expose teardown tolerance for already-gone entries", () => {
1569
1406
  statePath: h.statePath,
1570
1407
  wellKnownPath: h.wellKnownPath,
1571
1408
  hubPath: h.hubPath,
1572
- wellKnownDir: h.wellKnownDir,
1573
1409
  configDir: h.configDir,
1574
- hubEnsureOpts: hubEnsureOpts(spawner),
1410
+ supervisor: supervisorUp(),
1575
1411
  servicePortProbe: allServicesUp,
1576
1412
  log: () => {},
1577
1413
  });
@@ -1607,16 +1443,14 @@ describe("expose: vault routing fully internal to hub", () => {
1607
1443
  h.manifestPath,
1608
1444
  );
1609
1445
  const { runner, calls } = makeRunner();
1610
- const { spawner } = makeHubSpawner(1111);
1611
1446
  const code = await exposeTailnet("up", {
1612
1447
  runner,
1613
1448
  manifestPath: h.manifestPath,
1614
1449
  statePath: h.statePath,
1615
1450
  wellKnownPath: h.wellKnownPath,
1616
1451
  hubPath: h.hubPath,
1617
- wellKnownDir: h.wellKnownDir,
1618
1452
  configDir: h.configDir,
1619
- hubEnsureOpts: hubEnsureOpts(spawner),
1453
+ supervisor: supervisorUp(),
1620
1454
  servicePortProbe: allServicesUp,
1621
1455
  log: () => {},
1622
1456
  });
@@ -1657,16 +1491,14 @@ describe("expose: vault routing fully internal to hub", () => {
1657
1491
  h.manifestPath,
1658
1492
  );
1659
1493
  const { runner, calls } = makeRunner();
1660
- const { spawner } = makeHubSpawner(1111);
1661
1494
  const code = await exposeTailnet("up", {
1662
1495
  runner,
1663
1496
  manifestPath: h.manifestPath,
1664
1497
  statePath: h.statePath,
1665
1498
  wellKnownPath: h.wellKnownPath,
1666
1499
  hubPath: h.hubPath,
1667
- wellKnownDir: h.wellKnownDir,
1668
1500
  configDir: h.configDir,
1669
- hubEnsureOpts: hubEnsureOpts(spawner),
1501
+ supervisor: supervisorUp(),
1670
1502
  servicePortProbe: allServicesUp,
1671
1503
  log: () => {},
1672
1504
  });
@@ -1680,3 +1512,191 @@ describe("expose: vault routing fully internal to hub", () => {
1680
1512
  }
1681
1513
  });
1682
1514
  });
1515
+
1516
+ // ---------------------------------------------------------------------------
1517
+ // Phase 4 dual-dispatch (design §4.3): when a hub UNIT is installed, `expose`
1518
+ // ensures the hub UNIT (not a detached spawn), the post-expose vault restart
1519
+ // drives the running Supervisor over the loopback module-ops API (firing the
1520
+ // operator-token + vault `.env` self-heal), and `expose off` leaves the hub
1521
+ // RUNNING (D3 — a managed hub with Restart=always/KeepAlive would just respawn
1522
+ // a stopped one). The no-unit arm keeps today's behavior unchanged.
1523
+ // ---------------------------------------------------------------------------
1524
+
1525
+ interface ExposeSupervisorStub {
1526
+ opts: ExposeSupervisorOpts;
1527
+ ensureCalls: Array<{ port?: number }>;
1528
+ driveCalls: Array<{ short: string; op: ModuleOp }>;
1529
+ selfHealCalls: Array<{ issuer: string }>;
1530
+ }
1531
+
1532
+ /**
1533
+ * Build an expose `supervisor` seam that forces the unit-installed arm and
1534
+ * records the `ensureHubUnit` / `driveModuleOp` / operator-token-self-heal
1535
+ * calls. `driveThrows` makes a module-op throw a chosen error; `ensureOutcome`
1536
+ * controls the ensure-hub result.
1537
+ */
1538
+ function makeExposeSupervisorStub(opts?: {
1539
+ ensureOutcome?: "already-up" | "started" | "no-unit" | "no-manager" | "timeout" | "start-failed";
1540
+ ensurePort?: number;
1541
+ driveThrows?: (short: string, op: ModuleOp) => unknown;
1542
+ }): ExposeSupervisorStub {
1543
+ const ensureCalls: Array<{ port?: number }> = [];
1544
+ const driveCalls: Array<{ short: string; op: ModuleOp }> = [];
1545
+ const selfHealCalls: Array<{ issuer: string }> = [];
1546
+ return {
1547
+ ensureCalls,
1548
+ driveCalls,
1549
+ selfHealCalls,
1550
+ opts: {
1551
+ // openDb is opened+closed around the drive/self-heal — hand back a no-op closer.
1552
+ openDb: () => ({ close() {} }) as unknown as import("bun:sqlite").Database,
1553
+ ensureHubUnit: async (o: EnsureHubUnitOpts) => {
1554
+ ensureCalls.push({ port: o.port });
1555
+ return {
1556
+ outcome: opts?.ensureOutcome ?? "already-up",
1557
+ port: opts?.ensurePort ?? o.port ?? 1939,
1558
+ messages: [],
1559
+ };
1560
+ },
1561
+ driveModuleOp: async (short, op) => {
1562
+ driveCalls.push({ short, op });
1563
+ if (opts?.driveThrows) throw opts.driveThrows(short, op);
1564
+ return { status: 200, body: { short, state: { status: "running" } } };
1565
+ },
1566
+ selfHealOperatorTokenIssuer: async (_db, o) => {
1567
+ selfHealCalls.push({ issuer: o.issuer });
1568
+ return { kind: "fresh" };
1569
+ },
1570
+ },
1571
+ };
1572
+ }
1573
+
1574
+ describe("Phase 4 expose dual-dispatch — unit-managed", () => {
1575
+ test("expose up unit-managed → ensureHubUnit (not detached spawn) + driveModuleOp(restart) for vault", async () => {
1576
+ const h = makeHarness();
1577
+ try {
1578
+ seedServices(h.manifestPath);
1579
+ const { runner } = makeRunner();
1580
+ const sup = makeExposeSupervisorStub();
1581
+ const logs: string[] = [];
1582
+ const code = await exposeTailnet("up", {
1583
+ runner,
1584
+ manifestPath: h.manifestPath,
1585
+ statePath: h.statePath,
1586
+ wellKnownPath: h.wellKnownPath,
1587
+ hubPath: h.hubPath,
1588
+ configDir: h.configDir,
1589
+ servicePortProbe: allServicesUp,
1590
+ supervisor: sup.opts,
1591
+ log: (l) => logs.push(l),
1592
+ });
1593
+ expect(code).toBe(0);
1594
+ // Ensured the hub UNIT (the detached hub spawner was never invoked).
1595
+ expect(sup.ensureCalls).toHaveLength(1);
1596
+ // Post-expose vault restart drove the supervisor, not lifecycle.restart.
1597
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
1598
+ // The operator-token issuer self-heal fired toward the public origin.
1599
+ expect(sup.selfHealCalls).toHaveLength(1);
1600
+ expect(sup.selfHealCalls[0]?.issuer).toMatch(/^https:\/\//);
1601
+ expect(logs.join("\n")).toMatch(/hub unit up/);
1602
+ } finally {
1603
+ h.cleanup();
1604
+ }
1605
+ });
1606
+
1607
+ test("expose up unit-managed: ensureHubUnit failure aborts before exposing", async () => {
1608
+ const h = makeHarness();
1609
+ try {
1610
+ seedServices(h.manifestPath);
1611
+ const { runner, calls } = makeRunner();
1612
+ const sup = makeExposeSupervisorStub({ ensureOutcome: "no-manager" });
1613
+ const code = await exposeTailnet("up", {
1614
+ runner,
1615
+ manifestPath: h.manifestPath,
1616
+ statePath: h.statePath,
1617
+ wellKnownPath: h.wellKnownPath,
1618
+ hubPath: h.hubPath,
1619
+ configDir: h.configDir,
1620
+ servicePortProbe: allServicesUp,
1621
+ supervisor: sup.opts,
1622
+ log: () => {},
1623
+ });
1624
+ expect(code).toBe(1);
1625
+ // Never ran the tailscale bringup — we bailed on the failed ensure.
1626
+ expect(calls.filter((c) => c[0] === "tailscale" && c[1] === "serve")).toHaveLength(0);
1627
+ expect(sup.driveCalls).toHaveLength(0);
1628
+ } finally {
1629
+ h.cleanup();
1630
+ }
1631
+ });
1632
+
1633
+ test("expose up unit-managed: a not_supervised vault (404) is not a failure", async () => {
1634
+ const h = makeHarness();
1635
+ try {
1636
+ seedServices(h.manifestPath);
1637
+ const { runner } = makeRunner();
1638
+ const sup = makeExposeSupervisorStub({
1639
+ driveThrows: () => new ModuleOpHttpError(404, "not_supervised", "vault is not supervised"),
1640
+ });
1641
+ const code = await exposeTailnet("up", {
1642
+ runner,
1643
+ manifestPath: h.manifestPath,
1644
+ statePath: h.statePath,
1645
+ wellKnownPath: h.wellKnownPath,
1646
+ hubPath: h.hubPath,
1647
+ configDir: h.configDir,
1648
+ servicePortProbe: allServicesUp,
1649
+ supervisor: sup.opts,
1650
+ log: () => {},
1651
+ });
1652
+ expect(code).toBe(0);
1653
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
1654
+ } finally {
1655
+ h.cleanup();
1656
+ }
1657
+ });
1658
+
1659
+ test("expose off unit-managed → exposure torn down, hub NOT stopped, no 'hub stopped' line", async () => {
1660
+ const h = makeHarness();
1661
+ try {
1662
+ writeExposeState(
1663
+ {
1664
+ version: 1,
1665
+ layer: "tailnet",
1666
+ mode: "path",
1667
+ canonicalFqdn: "parachute.taildf9ce2.ts.net",
1668
+ port: 443,
1669
+ funnel: false,
1670
+ entries: [{ kind: "proxy", mount: "/", target: "http://127.0.0.1:1939", service: "hub" }],
1671
+ },
1672
+ h.statePath,
1673
+ );
1674
+ const { runner, calls } = makeRunner();
1675
+ const sup = makeExposeSupervisorStub();
1676
+ const logs: string[] = [];
1677
+ const code = await exposeTailnet("off", {
1678
+ runner,
1679
+ statePath: h.statePath,
1680
+ wellKnownPath: h.wellKnownPath,
1681
+ hubPath: h.hubPath,
1682
+ configDir: h.configDir,
1683
+ supervisor: sup.opts,
1684
+ log: (l) => logs.push(l),
1685
+ });
1686
+ expect(code).toBe(0);
1687
+ // The exposure layer was torn down…
1688
+ expect(calls.every((c) => c[c.length - 1] === "off" || c[1] === "version")).toBe(true);
1689
+ expect(existsSync(h.statePath)).toBe(false);
1690
+ // …but the hub was left running: no stop, no "hub stopped" line.
1691
+ expect(logs.join("\n")).not.toMatch(/hub stopped/);
1692
+ expect(logs.join("\n")).toMatch(/exposure removed/);
1693
+ } finally {
1694
+ h.cleanup();
1695
+ }
1696
+ });
1697
+
1698
+ // The "expose off NO unit → hub stopped (detached arm)" test was removed: Phase
1699
+ // 5b retired the detached arm, so `expose off` NEVER stops the hub (D3). The
1700
+ // "exposure torn down, hub NOT stopped" invariant is the test above + the
1701
+ // "leaves the hub running (D3)" test in the `expose tailnet off` suite.
1702
+ });