@openparachute/hub 0.6.4-rc.6 → 0.6.4-rc.8

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.
@@ -40,6 +40,9 @@ describe("install", () => {
40
40
  const calls: string[][] = [];
41
41
  const logs: string[] = [];
42
42
  const code = await install("vault", {
43
+ // --interactive: opt back into vault's full `init` (#579). The light
44
+ // default skips it; this test exercises the interactive path.
45
+ interactive: true,
43
46
  runner: async (cmd) => {
44
47
  calls.push([...cmd]);
45
48
  return 0;
@@ -67,6 +70,8 @@ describe("install", () => {
67
70
  try {
68
71
  const logs: string[] = [];
69
72
  const code = await install("vault", {
73
+ // --interactive: this test asserts init wrote the authoritative entry.
74
+ interactive: true,
70
75
  runner: async (cmd) => {
71
76
  if (cmd[0] === "parachute-vault") {
72
77
  upsertService(
@@ -133,6 +138,8 @@ describe("install", () => {
133
138
  const calls: string[][] = [];
134
139
  const logs: string[] = [];
135
140
  const code = await install("vault", {
141
+ // --interactive: this test asserts init still ran after the bun quirk.
142
+ interactive: true,
136
143
  runner: async (cmd) => {
137
144
  calls.push([...cmd]);
138
145
  // `bun add -g` exits 1; `parachute-vault init` succeeds.
@@ -796,12 +803,14 @@ describe("install", () => {
796
803
  }
797
804
  });
798
805
 
799
- test("linked vault still runs init and defers to init's manifest write", async () => {
806
+ test("linked vault still runs init and defers to init's manifest write (--interactive)", async () => {
800
807
  const { path, cleanup } = makeTempPath();
801
808
  try {
802
809
  const calls: string[][] = [];
803
810
  const logs: string[] = [];
804
811
  const code = await install("vault", {
812
+ // --interactive: this test asserts vault's own init wrote the entry.
813
+ interactive: true,
805
814
  runner: async (cmd) => {
806
815
  calls.push([...cmd]);
807
816
  if (cmd[0] === "parachute-vault") {
@@ -1722,6 +1731,330 @@ describe("install", () => {
1722
1731
  });
1723
1732
  });
1724
1733
 
1734
+ describe("#579 / #580 item 1 — light manual install + guidance", () => {
1735
+ test("default vault install skips the interactive init (no parachute-vault init runs)", async () => {
1736
+ const { path, cleanup } = makeTempPath();
1737
+ try {
1738
+ const calls: string[][] = [];
1739
+ const logs: string[] = [];
1740
+ const code = await install("vault", {
1741
+ // No `interactive` flag → the light default path.
1742
+ runner: async (cmd) => {
1743
+ calls.push([...cmd]);
1744
+ return 0;
1745
+ },
1746
+ manifestPath: path,
1747
+ startService: async () => 0,
1748
+ isLinked: () => false,
1749
+ portProbe: async () => false,
1750
+ log: (l) => logs.push(l),
1751
+ });
1752
+ expect(code).toBe(0);
1753
+ // bun add ran; vault's interactive init did NOT.
1754
+ expect(calls).toEqual([["bun", "add", "-g", "@openparachute/vault"]]);
1755
+ expect(calls).not.toContainEqual(["parachute-vault", "init"]);
1756
+ // The skip is announced + points at the admin UI / --interactive.
1757
+ expect(logs.join("\n")).toMatch(/skipping parachute-vault init/);
1758
+ } finally {
1759
+ cleanup();
1760
+ }
1761
+ });
1762
+
1763
+ test("default vault install still starts the module under the supervisor", async () => {
1764
+ const { path, cleanup } = makeTempPath();
1765
+ try {
1766
+ const startCalls: string[] = [];
1767
+ const code = await install("vault", {
1768
+ runner: async () => 0,
1769
+ manifestPath: path,
1770
+ startService: async (short) => {
1771
+ startCalls.push(short);
1772
+ return 0;
1773
+ },
1774
+ isLinked: () => false,
1775
+ portProbe: async () => false,
1776
+ log: () => {},
1777
+ });
1778
+ expect(code).toBe(0);
1779
+ // Light ≠ no-start: the supervisor owns the lifecycle; vault is started.
1780
+ expect(startCalls).toEqual(["vault"]);
1781
+ } finally {
1782
+ cleanup();
1783
+ }
1784
+ });
1785
+
1786
+ test("guidance block prints the admin URL + extras on a supervised box (loopback)", async () => {
1787
+ const { path, cleanup } = makeTempPath();
1788
+ try {
1789
+ const logs: string[] = [];
1790
+ const code = await install("vault", {
1791
+ runner: async () => 0,
1792
+ manifestPath: path,
1793
+ startService: async () => 0,
1794
+ isLinked: () => false,
1795
+ portProbe: async () => false,
1796
+ log: (l) => logs.push(l),
1797
+ // Deterministic supervised-box context: hub unit installed, not exposed.
1798
+ guidanceCtx: { hubUnitInstalled: true, exposeState: undefined, hubPort: 1939 },
1799
+ });
1800
+ expect(code).toBe(0);
1801
+ const joined = logs.join("\n");
1802
+ expect(joined).toMatch(/Manage \+ create vaults in the admin UI/);
1803
+ expect(joined).toMatch(/http:\/\/127\.0\.0\.1:1939\/admin\//);
1804
+ expect(joined).toMatch(/parachute-vault mcp-install/);
1805
+ expect(joined).toMatch(/--interactive/);
1806
+ // It does NOT mint a token or wire MCP — just points there.
1807
+ expect(joined).toMatch(/Mint an API token.*admin UI/);
1808
+ } finally {
1809
+ cleanup();
1810
+ }
1811
+ });
1812
+
1813
+ test("guidance uses the exposed public FQDN when the hub is exposed", async () => {
1814
+ const { path, cleanup } = makeTempPath();
1815
+ try {
1816
+ const logs: string[] = [];
1817
+ await install("vault", {
1818
+ runner: async () => 0,
1819
+ manifestPath: path,
1820
+ startService: async () => 0,
1821
+ isLinked: () => false,
1822
+ portProbe: async () => false,
1823
+ log: (l) => logs.push(l),
1824
+ guidanceCtx: {
1825
+ hubUnitInstalled: true,
1826
+ exposeState: {
1827
+ version: 1,
1828
+ layer: "public",
1829
+ mode: "path",
1830
+ canonicalFqdn: "friends.parachute.computer",
1831
+ port: 1939,
1832
+ funnel: false,
1833
+ entries: [],
1834
+ },
1835
+ hubPort: 1939,
1836
+ },
1837
+ });
1838
+ const joined = logs.join("\n");
1839
+ expect(joined).toMatch(/https:\/\/friends\.parachute\.computer\/admin\//);
1840
+ expect(joined).not.toMatch(/127\.0\.0\.1/);
1841
+ } finally {
1842
+ cleanup();
1843
+ }
1844
+ });
1845
+
1846
+ test("no guidance block on a non-supervised box (no hub unit)", async () => {
1847
+ const { path, cleanup } = makeTempPath();
1848
+ try {
1849
+ const logs: string[] = [];
1850
+ await install("vault", {
1851
+ runner: async () => 0,
1852
+ manifestPath: path,
1853
+ startService: async () => 0,
1854
+ isLinked: () => false,
1855
+ portProbe: async () => false,
1856
+ log: (l) => logs.push(l),
1857
+ guidanceCtx: { hubUnitInstalled: false, exposeState: undefined, hubPort: 1939 },
1858
+ });
1859
+ const joined = logs.join("\n");
1860
+ expect(joined).not.toMatch(/Manage \+ create vaults in the admin UI/);
1861
+ } finally {
1862
+ cleanup();
1863
+ }
1864
+ });
1865
+
1866
+ test("--interactive runs vault init and suppresses the guidance block", async () => {
1867
+ const { path, cleanup } = makeTempPath();
1868
+ try {
1869
+ const calls: string[][] = [];
1870
+ const logs: string[] = [];
1871
+ const code = await install("vault", {
1872
+ interactive: true,
1873
+ runner: async (cmd) => {
1874
+ calls.push([...cmd]);
1875
+ return 0;
1876
+ },
1877
+ manifestPath: path,
1878
+ startService: async () => 0,
1879
+ isLinked: () => false,
1880
+ portProbe: async () => false,
1881
+ log: (l) => logs.push(l),
1882
+ // Even on a supervised box, --interactive means the service's own
1883
+ // init owns the next-steps surface — no light guidance block.
1884
+ guidanceCtx: { hubUnitInstalled: true, exposeState: undefined, hubPort: 1939 },
1885
+ });
1886
+ expect(code).toBe(0);
1887
+ expect(calls).toContainEqual(["parachute-vault", "init"]);
1888
+ expect(logs.join("\n")).not.toMatch(/Manage \+ create vaults in the admin UI/);
1889
+ } finally {
1890
+ cleanup();
1891
+ }
1892
+ });
1893
+
1894
+ test("scribe (no interactive init) is unaffected — no skip log, no vault guidance", async () => {
1895
+ const { path, cleanup } = makeTempPath();
1896
+ const configDir = join(path, "..");
1897
+ try {
1898
+ const logs: string[] = [];
1899
+ const code = await install("scribe", {
1900
+ runner: async () => 0,
1901
+ manifestPath: path,
1902
+ configDir,
1903
+ startService: async () => 0,
1904
+ isLinked: () => false,
1905
+ portProbe: async () => false,
1906
+ scribeAvailability: { kind: "not-tty" },
1907
+ log: (l) => logs.push(l),
1908
+ guidanceCtx: { hubUnitInstalled: true, exposeState: undefined, hubPort: 1939 },
1909
+ });
1910
+ expect(code).toBe(0);
1911
+ const joined = logs.join("\n");
1912
+ expect(joined).not.toMatch(/skipping/);
1913
+ expect(joined).not.toMatch(/Manage \+ create vaults in the admin UI/);
1914
+ } finally {
1915
+ cleanup();
1916
+ }
1917
+ });
1918
+ });
1919
+
1920
+ describe("#580 item 3 — install-time stale-unit sweep", () => {
1921
+ test("sweeps stale per-module units before starting on a supervised box", async () => {
1922
+ const { path, cleanup } = makeTempPath();
1923
+ try {
1924
+ const logs: string[] = [];
1925
+ let sweepCalls = 0;
1926
+ const code = await install("vault", {
1927
+ runner: async () => 0,
1928
+ manifestPath: path,
1929
+ startService: async () => 0,
1930
+ isLinked: () => false,
1931
+ portProbe: async () => false,
1932
+ log: (l) => logs.push(l),
1933
+ guidanceCtx: { hubUnitInstalled: true, exposeState: undefined, hubPort: 1939 },
1934
+ disableStaleModuleUnits: () => {
1935
+ sweepCalls += 1;
1936
+ return {
1937
+ actions: [
1938
+ {
1939
+ short: "vault",
1940
+ kind: "launchd",
1941
+ unit: "computer.parachute.vault",
1942
+ result: "disabled",
1943
+ messages: [" ✓ Disabled stale computer.parachute.vault"],
1944
+ },
1945
+ ],
1946
+ };
1947
+ },
1948
+ });
1949
+ expect(code).toBe(0);
1950
+ expect(sweepCalls).toBe(1);
1951
+ expect(logs.join("\n")).toMatch(
1952
+ /Swept 1 stale per-module autostart unit\(s\).*computer\.parachute\.vault/,
1953
+ );
1954
+ } finally {
1955
+ cleanup();
1956
+ }
1957
+ });
1958
+
1959
+ test("does NOT sweep on a non-supervised box (no hub unit)", async () => {
1960
+ const { path, cleanup } = makeTempPath();
1961
+ try {
1962
+ let sweepCalls = 0;
1963
+ await install("vault", {
1964
+ runner: async () => 0,
1965
+ manifestPath: path,
1966
+ startService: async () => 0,
1967
+ isLinked: () => false,
1968
+ portProbe: async () => false,
1969
+ log: () => {},
1970
+ guidanceCtx: { hubUnitInstalled: false, exposeState: undefined, hubPort: 1939 },
1971
+ disableStaleModuleUnits: () => {
1972
+ sweepCalls += 1;
1973
+ return { actions: [] };
1974
+ },
1975
+ });
1976
+ // No supervised hub → the per-module unit is the legitimate lifecycle;
1977
+ // the sweep must not run.
1978
+ expect(sweepCalls).toBe(0);
1979
+ } finally {
1980
+ cleanup();
1981
+ }
1982
+ });
1983
+
1984
+ test("does NOT sweep under --no-start (caller owns the process model)", async () => {
1985
+ const { path, cleanup } = makeTempPath();
1986
+ try {
1987
+ let sweepCalls = 0;
1988
+ await install("vault", {
1989
+ runner: async () => 0,
1990
+ manifestPath: path,
1991
+ startService: async () => 0,
1992
+ isLinked: () => false,
1993
+ portProbe: async () => false,
1994
+ noStart: true,
1995
+ log: () => {},
1996
+ guidanceCtx: { hubUnitInstalled: true, exposeState: undefined, hubPort: 1939 },
1997
+ disableStaleModuleUnits: () => {
1998
+ sweepCalls += 1;
1999
+ return { actions: [] };
2000
+ },
2001
+ });
2002
+ expect(sweepCalls).toBe(0);
2003
+ } finally {
2004
+ cleanup();
2005
+ }
2006
+ });
2007
+
2008
+ test("does NOT sweep under --no-create (wizard defers the start; N2)", async () => {
2009
+ // Parallel to the --no-start guard above. `noCreate` (the wizard's
2010
+ // install path) also suppresses the start — and the sweep touches real
2011
+ // launchctl/systemctl on a live box, so it must NOT fire when we're not
2012
+ // about to start the module. A silent regression here would have the
2013
+ // wizard disabling operator units mid-init.
2014
+ const { path, cleanup } = makeTempPath();
2015
+ try {
2016
+ let sweepCalls = 0;
2017
+ await install("vault", {
2018
+ runner: async () => 0,
2019
+ manifestPath: path,
2020
+ startService: async () => 0,
2021
+ isLinked: () => false,
2022
+ portProbe: async () => false,
2023
+ noCreate: true,
2024
+ log: () => {},
2025
+ guidanceCtx: { hubUnitInstalled: true, exposeState: undefined, hubPort: 1939 },
2026
+ disableStaleModuleUnits: () => {
2027
+ sweepCalls += 1;
2028
+ return { actions: [] };
2029
+ },
2030
+ });
2031
+ expect(sweepCalls).toBe(0);
2032
+ } finally {
2033
+ cleanup();
2034
+ }
2035
+ });
2036
+
2037
+ test("a clean no-op sweep (nothing stale) logs nothing extra", async () => {
2038
+ const { path, cleanup } = makeTempPath();
2039
+ try {
2040
+ const logs: string[] = [];
2041
+ await install("vault", {
2042
+ runner: async () => 0,
2043
+ manifestPath: path,
2044
+ startService: async () => 0,
2045
+ isLinked: () => false,
2046
+ portProbe: async () => false,
2047
+ log: (l) => logs.push(l),
2048
+ guidanceCtx: { hubUnitInstalled: true, exposeState: undefined, hubPort: 1939 },
2049
+ disableStaleModuleUnits: () => ({ actions: [] }),
2050
+ });
2051
+ expect(logs.join("\n")).not.toMatch(/Swept .* stale per-module/);
2052
+ } finally {
2053
+ cleanup();
2054
+ }
2055
+ });
2056
+ });
2057
+
1725
2058
  describe("hub#573 — install auto-start converges on supervised detection", () => {
1726
2059
  test("the default start opts opt into real supervisor detection + the migrate offer", () => {
1727
2060
  const log = () => {};
@@ -4051,6 +4051,116 @@ describe("setup-wizard JSON surface (hub#168 Cuts 2/3)", () => {
4051
4051
  }
4052
4052
  });
4053
4053
 
4054
+ test("JSON probe hands the bootstrap token VALUE to a loopback caller (hub#576)", async () => {
4055
+ const { generateBootstrapToken, _resetBootstrapTokenForTests } = await import(
4056
+ "../bootstrap-token.ts"
4057
+ );
4058
+ _resetBootstrapTokenForTests();
4059
+ const token = generateBootstrapToken();
4060
+ const db = openHubDb(hubDbPath(h.dir));
4061
+ try {
4062
+ const res = handleSetupGet(req("/admin/setup", { headers: { accept: "application/json" } }), {
4063
+ db,
4064
+ manifestPath: h.manifestPath,
4065
+ configDir: h.dir,
4066
+ readExposeStateFn: h.readExposeStateFn,
4067
+ issuer: "http://127.0.0.1:1939",
4068
+ registry: getDefaultOperationsRegistry(),
4069
+ requestIsLoopback: true,
4070
+ });
4071
+ const body = (await res.json()) as {
4072
+ requireBootstrapToken: boolean;
4073
+ bootstrapToken?: string;
4074
+ };
4075
+ expect(body.requireBootstrapToken).toBe(true);
4076
+ expect(body.bootstrapToken).toBe(token);
4077
+ } finally {
4078
+ _resetBootstrapTokenForTests();
4079
+ db.close();
4080
+ }
4081
+ });
4082
+
4083
+ test("JSON probe withholds the token VALUE from a non-loopback caller (hub#576)", async () => {
4084
+ const { generateBootstrapToken, _resetBootstrapTokenForTests } = await import(
4085
+ "../bootstrap-token.ts"
4086
+ );
4087
+ _resetBootstrapTokenForTests();
4088
+ generateBootstrapToken();
4089
+ const db = openHubDb(hubDbPath(h.dir));
4090
+ try {
4091
+ const res = handleSetupGet(req("/admin/setup", { headers: { accept: "application/json" } }), {
4092
+ db,
4093
+ manifestPath: h.manifestPath,
4094
+ configDir: h.dir,
4095
+ readExposeStateFn: h.readExposeStateFn,
4096
+ issuer: "http://127.0.0.1:1939",
4097
+ registry: getDefaultOperationsRegistry(),
4098
+ requestIsLoopback: false,
4099
+ });
4100
+ const body = (await res.json()) as {
4101
+ requireBootstrapToken: boolean;
4102
+ bootstrapToken?: string;
4103
+ };
4104
+ // The boolean still tells a public browser a token is required...
4105
+ expect(body.requireBootstrapToken).toBe(true);
4106
+ // ...but the VALUE never leaks to it.
4107
+ expect(body.bootstrapToken).toBeUndefined();
4108
+ } finally {
4109
+ _resetBootstrapTokenForTests();
4110
+ db.close();
4111
+ }
4112
+ });
4113
+
4114
+ test("JSON probe fails CLOSED when loopback is unknown (hub#576)", async () => {
4115
+ const { generateBootstrapToken, _resetBootstrapTokenForTests } = await import(
4116
+ "../bootstrap-token.ts"
4117
+ );
4118
+ _resetBootstrapTokenForTests();
4119
+ generateBootstrapToken();
4120
+ const db = openHubDb(hubDbPath(h.dir));
4121
+ try {
4122
+ // `requestIsLoopback` omitted entirely — must be treated as non-loopback.
4123
+ const res = handleSetupGet(req("/admin/setup", { headers: { accept: "application/json" } }), {
4124
+ db,
4125
+ manifestPath: h.manifestPath,
4126
+ configDir: h.dir,
4127
+ readExposeStateFn: h.readExposeStateFn,
4128
+ issuer: "http://127.0.0.1:1939",
4129
+ registry: getDefaultOperationsRegistry(),
4130
+ });
4131
+ const body = (await res.json()) as { bootstrapToken?: string };
4132
+ expect(body.bootstrapToken).toBeUndefined();
4133
+ } finally {
4134
+ _resetBootstrapTokenForTests();
4135
+ db.close();
4136
+ }
4137
+ });
4138
+
4139
+ test("JSON probe omits the token when no admin gate is active (hub#576)", async () => {
4140
+ const { _resetBootstrapTokenForTests } = await import("../bootstrap-token.ts");
4141
+ _resetBootstrapTokenForTests(); // no token minted → not in wizard mode
4142
+ const db = openHubDb(hubDbPath(h.dir));
4143
+ try {
4144
+ const res = handleSetupGet(req("/admin/setup", { headers: { accept: "application/json" } }), {
4145
+ db,
4146
+ manifestPath: h.manifestPath,
4147
+ configDir: h.dir,
4148
+ readExposeStateFn: h.readExposeStateFn,
4149
+ issuer: "http://127.0.0.1:1939",
4150
+ registry: getDefaultOperationsRegistry(),
4151
+ requestIsLoopback: true,
4152
+ });
4153
+ const body = (await res.json()) as {
4154
+ requireBootstrapToken: boolean;
4155
+ bootstrapToken?: string;
4156
+ };
4157
+ expect(body.requireBootstrapToken).toBe(false);
4158
+ expect(body.bootstrapToken).toBeUndefined();
4159
+ } finally {
4160
+ db.close();
4161
+ }
4162
+ });
4163
+
4054
4164
  test("vault step skip mode short-circuits + persists setup_vault_skipped", async () => {
4055
4165
  const db = openHubDb(hubDbPath(h.dir));
4056
4166
  try {
@@ -178,6 +178,203 @@ describe("Supervisor.start + status transitions", () => {
178
178
  });
179
179
  });
180
180
 
181
+ describe("Supervisor port-squatter detection (#580 item 4)", () => {
182
+ test("foreign pid on the module port → port_squatter error, no spawn", async () => {
183
+ const spawner = makeQueueSpawner();
184
+ // Note: NOTHING enqueued — if `start` tried to spawn, the spawner throws.
185
+ const sup = new Supervisor({
186
+ spawnFn: spawner.spawn,
187
+ killFn: noopKill,
188
+ // A rogue pid 1921 holds :1940; it is NOT one of our children.
189
+ pidOnPort: (port) => (port === 1940 ? 1921 : undefined),
190
+ ownerOfPid: (pid) => (pid === 1921 ? "bun /x/vault/src/server.ts" : undefined),
191
+ });
192
+
193
+ const state = await sup.start({
194
+ short: "vault",
195
+ cmd: ["bun", "vault.ts"],
196
+ env: { PORT: "1940" },
197
+ });
198
+
199
+ // No spawn attempted (spawner.calls empty), module is `crashed` with the
200
+ // structured, actionable squatter error.
201
+ expect(spawner.calls).toHaveLength(0);
202
+ expect(state.status).toBe("crashed");
203
+ expect(state.pid).toBeUndefined();
204
+ expect(state.startError?.error_type).toBe("port_squatter");
205
+ expect(state.startError?.error_description).toContain("port 1940 is held by pid 1921");
206
+ expect(state.startError?.error_description).toContain("bun /x/vault/src/server.ts");
207
+ expect(state.startError?.error_description).toContain("kill 1921 && parachute start vault");
208
+ });
209
+
210
+ test("squatter message omits cmdline when ownerOfPid can't read it", async () => {
211
+ const spawner = makeQueueSpawner();
212
+ const sup = new Supervisor({
213
+ spawnFn: spawner.spawn,
214
+ killFn: noopKill,
215
+ pidOnPort: () => 4242,
216
+ ownerOfPid: () => undefined,
217
+ });
218
+
219
+ const state = await sup.start({
220
+ short: "vault",
221
+ cmd: ["bun", "vault.ts"],
222
+ env: { PORT: "1940" },
223
+ });
224
+ expect(state.startError?.error_type).toBe("port_squatter");
225
+ expect(state.startError?.error_description).toContain("port 1940 is held by pid 4242");
226
+ expect(state.startError?.error_description).toContain("kill 4242 && parachute start vault");
227
+ // No parenthetical cmdline.
228
+ expect(state.startError?.error_description).not.toContain("(");
229
+ });
230
+
231
+ test("free port → no squatter error, module spawns normally", async () => {
232
+ const proc = makeFakeProc(500);
233
+ const spawner = makeQueueSpawner();
234
+ spawner.enqueue(proc);
235
+ const sup = new Supervisor({
236
+ spawnFn: spawner.spawn,
237
+ killFn: noopKill,
238
+ pidOnPort: () => undefined, // port free / detection unavailable
239
+ });
240
+
241
+ const state = await sup.start({
242
+ short: "vault",
243
+ cmd: ["bun", "vault.ts"],
244
+ env: { PORT: "1940" },
245
+ });
246
+ expect(spawner.calls).toHaveLength(1);
247
+ expect(state.status).toBe("running");
248
+ expect(state.startError).toBeUndefined();
249
+
250
+ proc.closeStreams();
251
+ sup.stop("vault");
252
+ proc.resolveExit(0);
253
+ });
254
+
255
+ test("port held by one of OUR OWN children is not a squatter", async () => {
256
+ // vault is up on pid 700 holding :1940; starting a sibling (scribe) that
257
+ // somehow reports the same holder pid must NOT be flagged — the holder is
258
+ // a supervised child, not a foreign rogue.
259
+ const vaultProc = makeFakeProc(700);
260
+ const scribeProc = makeFakeProc(701);
261
+ const spawner = makeQueueSpawner();
262
+ spawner.enqueue(vaultProc);
263
+ spawner.enqueue(scribeProc);
264
+ const sup = new Supervisor({
265
+ spawnFn: spawner.spawn,
266
+ killFn: noopKill,
267
+ // vault's port (1940) is free so vault spawns (pid 700). scribe's port
268
+ // (1943) then reports vault's pid 700 as the holder — a supervised child,
269
+ // NOT a foreign rogue, so scribe must still spawn.
270
+ pidOnPort: (port) => (port === 1943 ? 700 : undefined),
271
+ });
272
+
273
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"], env: { PORT: "1940" } });
274
+ const scribe = await sup.start({
275
+ short: "scribe",
276
+ cmd: ["bun", "scribe.ts"],
277
+ env: { PORT: "1943" },
278
+ });
279
+ // scribe spawned (no false-positive squatter); both children spawned.
280
+ expect(spawner.calls).toHaveLength(2);
281
+ expect(scribe.status).toBe("running");
282
+ expect(scribe.startError).toBeUndefined();
283
+
284
+ vaultProc.closeStreams();
285
+ scribeProc.closeStreams();
286
+ sup.stop("vault");
287
+ sup.stop("scribe");
288
+ vaultProc.resolveExit(0);
289
+ scribeProc.resolveExit(0);
290
+ });
291
+
292
+ test("a CRASHED child's stale pid does NOT vouch for a port holder (N1 liveness)", async () => {
293
+ // vault spawns (pid 800), then crashes for good (maxRestarts: 1). Its entry
294
+ // keeps `proc.pid === 800` (never cleared on exit) but status is `crashed`.
295
+ // A fresh `start` where pid 800 now holds :1940 must be flagged as a
296
+ // SQUATTER — the stale pid of a dead child must not excuse the holder.
297
+ const first = makeFakeProc(800);
298
+ const spawner = makeQueueSpawner();
299
+ spawner.enqueue(first);
300
+ let portHeld = false;
301
+ const sup = new Supervisor({
302
+ spawnFn: spawner.spawn,
303
+ killFn: noopKill,
304
+ maxRestarts: 1,
305
+ restartDelayMs: 0,
306
+ sleep: () => Promise.resolve(),
307
+ // Free before the crash; pid 800 "holds" :1940 after we flip portHeld.
308
+ pidOnPort: (port) => (portHeld && port === 1940 ? 800 : undefined),
309
+ ownerOfPid: () => "bun /x/vault/src/server.ts",
310
+ });
311
+
312
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"], env: { PORT: "1940" } });
313
+ // Crash past the budget → status `crashed`, entry.proc.pid still 800.
314
+ first.closeStreams();
315
+ first.resolveExit(1);
316
+ await tick();
317
+ expect(sup.get("vault")?.status).toBe("crashed");
318
+
319
+ // Now pid 800 holds the port. A re-start must NOT treat 800 as "ours".
320
+ portHeld = true;
321
+ const restarted = await sup.start({
322
+ short: "vault",
323
+ cmd: ["bun", "vault.ts"],
324
+ env: { PORT: "1940" },
325
+ });
326
+ expect(restarted.status).toBe("crashed");
327
+ expect(restarted.startError?.error_type).toBe("port_squatter");
328
+ expect(restarted.startError?.error_description).toContain("port 1940 is held by pid 800");
329
+ // No second spawn — the squatter check aborted before re-spawning.
330
+ expect(spawner.calls).toHaveLength(1);
331
+ });
332
+
333
+ test("no declared PORT → squatter check skipped (request without env.PORT)", async () => {
334
+ const proc = makeFakeProc(900);
335
+ const spawner = makeQueueSpawner();
336
+ spawner.enqueue(proc);
337
+ let probed = false;
338
+ const sup = new Supervisor({
339
+ spawnFn: spawner.spawn,
340
+ killFn: noopKill,
341
+ pidOnPort: () => {
342
+ probed = true;
343
+ return 1;
344
+ },
345
+ });
346
+
347
+ const state = await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
348
+ // No PORT in the request → we never probe a port, and the module spawns.
349
+ expect(probed).toBe(false);
350
+ expect(state.status).toBe("running");
351
+
352
+ proc.closeStreams();
353
+ sup.stop("vault");
354
+ proc.resolveExit(0);
355
+ });
356
+
357
+ test("stub-spawner path defaults to no squatter (existing fake-proc tests unaffected)", async () => {
358
+ const proc = makeFakeProc(123);
359
+ const spawner = makeQueueSpawner();
360
+ spawner.enqueue(proc);
361
+ // No pidOnPort injected → on the stub-spawner (test) path it defaults to
362
+ // "no squatter", so a request carrying a PORT still spawns.
363
+ const sup = new Supervisor({ spawnFn: spawner.spawn, killFn: noopKill });
364
+ const state = await sup.start({
365
+ short: "vault",
366
+ cmd: ["bun", "vault.ts"],
367
+ env: { PORT: "1940" },
368
+ });
369
+ expect(spawner.calls).toHaveLength(1);
370
+ expect(state.status).toBe("running");
371
+
372
+ proc.closeStreams();
373
+ sup.stop("vault");
374
+ proc.resolveExit(0);
375
+ });
376
+ });
377
+
181
378
  describe("Supervisor restart-on-crash", () => {
182
379
  test("restarts a crashed module within the budget", async () => {
183
380
  const first = makeFakeProc(101);