@openparachute/hub 0.7.5 → 0.7.6-rc.3

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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-module-token.test.ts +40 -3
  3. package/src/__tests__/api-modules-ops.test.ts +8 -3
  4. package/src/__tests__/api-modules.test.ts +26 -18
  5. package/src/__tests__/connections-store.test.ts +84 -0
  6. package/src/__tests__/doctor.test.ts +131 -0
  7. package/src/__tests__/git-notify.test.ts +29 -1
  8. package/src/__tests__/grants-store.test.ts +33 -1
  9. package/src/__tests__/hub-instance.test.ts +297 -0
  10. package/src/__tests__/hub-server.test.ts +169 -0
  11. package/src/__tests__/install.test.ts +28 -0
  12. package/src/__tests__/serve-boot.test.ts +60 -0
  13. package/src/__tests__/service-spec-discovery.test.ts +32 -9
  14. package/src/__tests__/setup.test.ts +64 -16
  15. package/src/__tests__/stale-module-units.test.ts +1 -1
  16. package/src/__tests__/status-supervisor.test.ts +112 -0
  17. package/src/admin-connections.ts +5 -1
  18. package/src/admin-module-token.ts +2 -2
  19. package/src/api-modules-ops.ts +3 -3
  20. package/src/api-modules.ts +13 -13
  21. package/src/commands/doctor.ts +167 -4
  22. package/src/commands/install.ts +29 -3
  23. package/src/commands/migrate.ts +5 -0
  24. package/src/commands/serve.ts +52 -0
  25. package/src/commands/setup.ts +10 -9
  26. package/src/commands/status.ts +42 -1
  27. package/src/connections-store.ts +15 -2
  28. package/src/git-notify.ts +34 -5
  29. package/src/grants-store.ts +15 -2
  30. package/src/help.ts +3 -3
  31. package/src/hub-instance.ts +365 -0
  32. package/src/hub-server.ts +89 -1
  33. package/src/install-source.ts +1 -1
  34. package/src/service-spec.ts +36 -44
  35. package/src/services-manifest.ts +1 -1
  36. package/src/stale-module-units.ts +2 -2
  37. package/src/well-known.ts +3 -3
@@ -0,0 +1,297 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ type HubInstanceRecord,
7
+ type LoopbackProbe,
8
+ type SelfProbeState,
9
+ armHubSelfProbe,
10
+ classifyLoopback,
11
+ clearHubInstanceFile,
12
+ generateInstanceNonce,
13
+ hijackAlertMessage,
14
+ hubInstancePath,
15
+ probeLoopbackInstance,
16
+ readHubInstanceFile,
17
+ writeHubInstanceFile,
18
+ } from "../hub-instance.ts";
19
+
20
+ /**
21
+ * hub#737 loopback-hijack detection. The nonce file is the linchpin — an
22
+ * external process learns THIS hub's true identity from disk, then compares it
23
+ * to what a (possibly hijacked) loopback /health returns. Every side effect
24
+ * (fs, network) is exercised against a tmp dir / injected fetch.
25
+ */
26
+
27
+ function makeDir(): { configDir: string; cleanup: () => void } {
28
+ const dir = mkdtempSync(join(tmpdir(), "parachute-instance-test-"));
29
+ return { configDir: dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
30
+ }
31
+
32
+ function record(over: Partial<HubInstanceRecord> = {}): HubInstanceRecord {
33
+ return {
34
+ instance: "nonce-1",
35
+ pid: 4242,
36
+ port: 1939,
37
+ startedAt: "2026-07-02T00:00:00.000Z",
38
+ ...over,
39
+ };
40
+ }
41
+
42
+ describe("nonce + instance file", () => {
43
+ test("generateInstanceNonce yields distinct UUID-shaped values", () => {
44
+ const a = generateInstanceNonce();
45
+ const b = generateInstanceNonce();
46
+ expect(a).not.toBe(b);
47
+ expect(a).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
48
+ });
49
+
50
+ test("write → read round-trips the record (incl. selfProbe)", () => {
51
+ const { configDir, cleanup } = makeDir();
52
+ try {
53
+ const sp: SelfProbeState = {
54
+ status: "ok",
55
+ checkedAt: "2026-07-02T00:01:00.000Z",
56
+ };
57
+ expect(writeHubInstanceFile(record({ selfProbe: sp }), { configDir })).toBe(true);
58
+ const back = readHubInstanceFile(configDir);
59
+ expect(back?.instance).toBe("nonce-1");
60
+ expect(back?.port).toBe(1939);
61
+ expect(back?.pid).toBe(4242);
62
+ expect(back?.selfProbe?.status).toBe("ok");
63
+ // Written 0644 (world-readable diagnostic aid, not a secret).
64
+ const raw = readFileSync(hubInstancePath(configDir), "utf8");
65
+ expect(raw.endsWith("\n")).toBe(true);
66
+ } finally {
67
+ cleanup();
68
+ }
69
+ });
70
+
71
+ test("read returns null on absent / malformed / instance-less files", () => {
72
+ const { configDir, cleanup } = makeDir();
73
+ try {
74
+ expect(readHubInstanceFile(configDir)).toBeNull(); // absent
75
+ writeFileSync(hubInstancePath(configDir), "not json{");
76
+ expect(readHubInstanceFile(configDir)).toBeNull(); // malformed
77
+ writeFileSync(hubInstancePath(configDir), JSON.stringify({ port: 1939 }));
78
+ expect(readHubInstanceFile(configDir)).toBeNull(); // no instance field
79
+ } finally {
80
+ cleanup();
81
+ }
82
+ });
83
+
84
+ test("clearHubInstanceFile removes the file (graceful-shutdown hygiene) + no-ops when absent", () => {
85
+ const { configDir, cleanup } = makeDir();
86
+ try {
87
+ writeHubInstanceFile(record(), { configDir });
88
+ expect(readHubInstanceFile(configDir)).not.toBeNull();
89
+ clearHubInstanceFile(configDir);
90
+ expect(readHubInstanceFile(configDir)).toBeNull();
91
+ // Idempotent — clearing an already-absent file must not throw.
92
+ expect(() => clearHubInstanceFile(configDir)).not.toThrow();
93
+ } finally {
94
+ cleanup();
95
+ }
96
+ });
97
+
98
+ test("write failure is swallowed (returns false, logs) — never throws", () => {
99
+ const logs: string[] = [];
100
+ // An un-writable path (a file where a dir must be) forces the mkdir/write to
101
+ // fail; the helper must degrade, not throw.
102
+ const { configDir, cleanup } = makeDir();
103
+ try {
104
+ const filePath = join(configDir, "blocker");
105
+ writeFileSync(filePath, "x");
106
+ const ok = writeHubInstanceFile(record(), {
107
+ configDir: join(filePath, "nested"),
108
+ log: (l) => logs.push(l),
109
+ });
110
+ expect(ok).toBe(false);
111
+ expect(logs.length).toBe(1);
112
+ } finally {
113
+ cleanup();
114
+ }
115
+ });
116
+ });
117
+
118
+ describe("classifyLoopback", () => {
119
+ test("unreachable when the probe didn't answer", () => {
120
+ expect(classifyLoopback("n1", { reachable: false })).toBe("unreachable");
121
+ });
122
+ test("ok when the returned instance matches ours", () => {
123
+ expect(classifyLoopback("n1", { reachable: true, status: 200, instance: "n1" })).toBe("ok");
124
+ });
125
+ test("hijacked when a different instance answers", () => {
126
+ expect(classifyLoopback("n1", { reachable: true, status: 200, instance: "n2" })).toBe(
127
+ "hijacked",
128
+ );
129
+ });
130
+ test("hijacked when a reachable process carries NO instance (old/foreign hub)", () => {
131
+ expect(classifyLoopback("n1", { reachable: true, status: 200 })).toBe("hijacked");
132
+ });
133
+ });
134
+
135
+ describe("probeLoopbackInstance", () => {
136
+ test("extracts instance + isHub from a well-formed /health body", async () => {
137
+ const fetchFn = (async () =>
138
+ new Response(JSON.stringify({ service: "parachute-hub", instance: "xyz" }), {
139
+ status: 200,
140
+ })) as unknown as typeof fetch;
141
+ const out = await probeLoopbackInstance(1939, { fetchFn });
142
+ expect(out).toEqual({ reachable: true, status: 200, instance: "xyz", isHub: true });
143
+ });
144
+
145
+ test("reachable-but-junk body → reachable with no instance (foreign server shape)", async () => {
146
+ const fetchFn = (async () =>
147
+ new Response("<html>not a hub</html>", { status: 200 })) as unknown as typeof fetch;
148
+ const out = await probeLoopbackInstance(1939, { fetchFn });
149
+ expect(out.reachable).toBe(true);
150
+ expect(out.instance).toBeUndefined();
151
+ expect(out.isHub).toBeUndefined();
152
+ });
153
+
154
+ test("network error → not reachable, never throws", async () => {
155
+ const fetchFn = (async () => {
156
+ throw new Error("ECONNREFUSED");
157
+ }) as unknown as typeof fetch;
158
+ const out = await probeLoopbackInstance(1939, { fetchFn });
159
+ expect(out).toEqual({ reachable: false });
160
+ });
161
+ });
162
+
163
+ describe("armHubSelfProbe", () => {
164
+ /** A probe stub that returns a fixed LoopbackProbe. */
165
+ function fixedProbe(p: LoopbackProbe): (port: number) => Promise<LoopbackProbe> {
166
+ return async () => p;
167
+ }
168
+
169
+ test("ok verdict: writes state, stays quiet (no loud log)", async () => {
170
+ const logs: string[] = [];
171
+ const states: SelfProbeState[] = [];
172
+ const probe = armHubSelfProbe(
173
+ { port: 1939, nonce: "n1", record: record() },
174
+ {
175
+ probe: fixedProbe({ reachable: true, status: 200, instance: "n1" }),
176
+ writeState: (s) => states.push(s),
177
+ log: (l) => logs.push(l),
178
+ setIntervalFn: () => 0,
179
+ },
180
+ );
181
+ const verdict = await probe.probeOnce();
182
+ expect(verdict).toBe("ok");
183
+ expect(logs).toEqual([]);
184
+ expect(states.at(-1)?.status).toBe("ok");
185
+ });
186
+
187
+ test("hijacked verdict: LOUD alert on EVERY probe + observedInstance persisted", async () => {
188
+ const logs: string[] = [];
189
+ const states: SelfProbeState[] = [];
190
+ const probe = armHubSelfProbe(
191
+ { port: 1939, nonce: "n1", record: record() },
192
+ {
193
+ probe: fixedProbe({ reachable: true, status: 200, instance: "rogue-9" }),
194
+ writeState: (s) => states.push(s),
195
+ log: (l) => logs.push(l),
196
+ setIntervalFn: () => 0,
197
+ },
198
+ );
199
+ expect(await probe.probeOnce()).toBe("hijacked");
200
+ expect(await probe.probeOnce()).toBe("hijacked");
201
+ // Repeated verbatim — a hijack is a standing emergency, not a one-shot.
202
+ expect(logs.length).toBe(2);
203
+ expect(logs[0]).toContain("LOOPBACK HIJACK");
204
+ expect(logs[0]).toContain("lsof -nP -iTCP:1939");
205
+ expect(states.at(-1)?.observedInstance).toBe("rogue-9");
206
+ expect(states.at(-1)?.status).toBe("hijacked");
207
+ });
208
+
209
+ test("unreachable verdict: logs ONCE per state change, not per tick", async () => {
210
+ const logs: string[] = [];
211
+ const probe = armHubSelfProbe(
212
+ { port: 1939, nonce: "n1", record: record() },
213
+ {
214
+ probe: fixedProbe({ reachable: false }),
215
+ writeState: () => {},
216
+ log: (l) => logs.push(l),
217
+ setIntervalFn: () => 0,
218
+ },
219
+ );
220
+ await probe.probeOnce();
221
+ await probe.probeOnce();
222
+ expect(logs.length).toBe(1); // second consecutive unreachable is silent
223
+ expect(logs[0]).toContain("did not answer");
224
+ });
225
+
226
+ test("recovery: hijacked → ok logs a single 'cleared' line", async () => {
227
+ const logs: string[] = [];
228
+ let current: LoopbackProbe = { reachable: true, status: 200, instance: "rogue" };
229
+ const probe = armHubSelfProbe(
230
+ { port: 1939, nonce: "n1", record: record() },
231
+ {
232
+ probe: async () => current,
233
+ writeState: () => {},
234
+ log: (l) => logs.push(l),
235
+ setIntervalFn: () => 0,
236
+ },
237
+ );
238
+ expect(await probe.probeOnce()).toBe("hijacked");
239
+ current = { reachable: true, status: 200, instance: "n1" };
240
+ expect(await probe.probeOnce()).toBe("ok");
241
+ expect(logs.length).toBe(2);
242
+ expect(logs[1]).toContain("Hijack cleared");
243
+ });
244
+
245
+ test("stop() clears the interval handle", () => {
246
+ let cleared: unknown;
247
+ const probe = armHubSelfProbe(
248
+ { port: 1939, nonce: "n1", record: record() },
249
+ {
250
+ probe: fixedProbe({ reachable: true, instance: "n1" }),
251
+ writeState: () => {},
252
+ setIntervalFn: () => "the-handle",
253
+ clearIntervalFn: (h) => {
254
+ cleared = h;
255
+ },
256
+ },
257
+ );
258
+ probe.stop();
259
+ expect(cleared).toBe("the-handle");
260
+ });
261
+
262
+ test("default writeState patches selfProbe into the real instance file", async () => {
263
+ const { configDir, cleanup } = makeDir();
264
+ try {
265
+ const rec = record();
266
+ writeHubInstanceFile(rec, { configDir });
267
+ const probe = armHubSelfProbe(
268
+ { port: 1939, nonce: "n1", record: rec, configDir },
269
+ {
270
+ probe: fixedProbe({ reachable: true, status: 200, instance: "someone-else" }),
271
+ log: () => {},
272
+ setIntervalFn: () => 0,
273
+ },
274
+ );
275
+ await probe.probeOnce();
276
+ const back = readHubInstanceFile(configDir);
277
+ expect(back?.selfProbe?.status).toBe("hijacked");
278
+ expect(back?.instance).toBe("nonce-1"); // base record preserved
279
+ } finally {
280
+ cleanup();
281
+ }
282
+ });
283
+ });
284
+
285
+ describe("hijackAlertMessage", () => {
286
+ test("names the observed instance + the diagnosis commands + incident ref", () => {
287
+ const msg = hijackAlertMessage(1939, "rogue-42");
288
+ expect(msg).toContain("instance=rogue-42");
289
+ expect(msg).toContain("lsof -nP -iTCP:1939 -sTCP:LISTEN");
290
+ expect(msg).toContain("orb list");
291
+ expect(msg).toContain("hub#737");
292
+ });
293
+ test("degrades gracefully when the foreign process carries no nonce", () => {
294
+ const msg = hijackAlertMessage(1939);
295
+ expect(msg).toContain("no hub instance nonce");
296
+ });
297
+ });
@@ -15,6 +15,7 @@ import {
15
15
  layerOf,
16
16
  parseArgs,
17
17
  resolveClientIp,
18
+ stripHopByHopHeaders,
18
19
  } from "../hub-server.ts";
19
20
  import { setNotesRedirectDisabled } from "../hub-settings.ts";
20
21
  import { clearNotesRedirectLogState } from "../notes-redirect.ts";
@@ -139,6 +140,27 @@ describe("hubFetch routing", () => {
139
140
  }
140
141
  });
141
142
 
143
+ test("/health echoes the instance nonce when set, omits it when absent (hub#737)", async () => {
144
+ const h = makeHarness();
145
+ try {
146
+ // With a nonce threaded in, /health carries it as `instance`.
147
+ const withNonce = (await (
148
+ await hubFetch(h.dir, { instanceNonce: "nonce-abc-123" })(req("/health"))
149
+ ).json()) as { service: string; instance?: string };
150
+ expect(withNonce.service).toBe("parachute-hub");
151
+ expect(withNonce.instance).toBe("nonce-abc-123");
152
+
153
+ // Without one (DB-less / dev entrypoint), the field is omitted — additive,
154
+ // so no strict /health consumer breaks.
155
+ const withoutNonce = (await (await hubFetch(h.dir, {})(req("/health"))).json()) as {
156
+ instance?: string;
157
+ };
158
+ expect(withoutNonce.instance).toBeUndefined();
159
+ } finally {
160
+ h.cleanup();
161
+ }
162
+ });
163
+
142
164
  test("/health surfaces db:error:path-gone when the proactive probe sees a wiped path (#610)", async () => {
143
165
  // The ghost-fd lie: SELECT 1 still succeeds against the unlinked inode, so
144
166
  // probeDbLiveness alone would report ok. probeDbPath stat()s the PATH and
@@ -5963,6 +5985,153 @@ describe("substrate trust headers — X-Parachute-Layer / X-Parachute-Client-IP
5963
5985
  });
5964
5986
  });
5965
5987
 
5988
+ describe("stripHopByHopHeaders (hub#738)", () => {
5989
+ test("drops the standard hop-by-hop set", () => {
5990
+ const h = new Headers({
5991
+ connection: "keep-alive",
5992
+ "keep-alive": "timeout=5",
5993
+ "proxy-authorization": "Basic xxx",
5994
+ te: "trailers",
5995
+ "transfer-encoding": "chunked",
5996
+ upgrade: "h2c",
5997
+ authorization: "Bearer keep-me",
5998
+ "content-type": "application/json",
5999
+ });
6000
+ stripHopByHopHeaders(h);
6001
+ expect(h.has("connection")).toBe(false);
6002
+ expect(h.has("keep-alive")).toBe(false);
6003
+ expect(h.has("proxy-authorization")).toBe(false);
6004
+ expect(h.has("te")).toBe(false);
6005
+ expect(h.has("transfer-encoding")).toBe(false);
6006
+ expect(h.has("upgrade")).toBe(false);
6007
+ // End-to-end headers survive.
6008
+ expect(h.get("authorization")).toBe("Bearer keep-me");
6009
+ expect(h.get("content-type")).toBe("application/json");
6010
+ });
6011
+
6012
+ test("drops headers NAMED in the Connection field-value (RFC 9110 §7.6.1)", () => {
6013
+ const h = new Headers({
6014
+ connection: "close, X-Custom-Hop",
6015
+ "x-custom-hop": "should-go",
6016
+ "x-real-header": "should-stay",
6017
+ });
6018
+ stripHopByHopHeaders(h);
6019
+ expect(h.has("connection")).toBe(false);
6020
+ expect(h.has("x-custom-hop")).toBe(false);
6021
+ expect(h.get("x-real-header")).toBe("should-stay");
6022
+ });
6023
+ });
6024
+
6025
+ describe("proxy upstream keep-alive (hub#738)", () => {
6026
+ const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
6027
+
6028
+ function writeUpstreamService(h: Harness, port: number): void {
6029
+ writeManifest(
6030
+ {
6031
+ services: [
6032
+ {
6033
+ name: "keepalive-svc",
6034
+ port,
6035
+ paths: ["/keepalive-svc"],
6036
+ health: "/keepalive-svc/health",
6037
+ version: "0.1.0",
6038
+ },
6039
+ ],
6040
+ },
6041
+ h.manifestPath,
6042
+ );
6043
+ }
6044
+
6045
+ test("200 proxied requests reuse a handful of upstream sockets even when the client sends Connection: close", async () => {
6046
+ // Regression for hub#738: the proxy forwarded the client's hop-by-hop
6047
+ // `Connection: close` verbatim, so Bun opened a FRESH ephemeral socket per
6048
+ // proxied request instead of reusing its per-origin pool → 16k+ TIME_WAIT
6049
+ // → host-wide port exhaustion (the 2026-07-02 incident, amplifying an agent
6050
+ // reconciler 401 loop). We count the distinct client (ephemeral) ports the
6051
+ // upstream sees: a working pool reuses one (occasionally a couple); the bug
6052
+ // produced ~N distinct ports.
6053
+ const seenPorts = new Set<number>();
6054
+ const upstream = Bun.serve({
6055
+ port: 0,
6056
+ hostname: "127.0.0.1",
6057
+ fetch: (r, server) => {
6058
+ const ip = server.requestIP(r);
6059
+ if (ip) seenPorts.add(ip.port);
6060
+ return new Response("unauthorized", { status: 401 }); // the incident's 401 loop
6061
+ },
6062
+ });
6063
+ const h = makeHarness();
6064
+ try {
6065
+ writeUpstreamService(h, upstream.port as number);
6066
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
6067
+ const N = 200;
6068
+ for (let i = 0; i < N; i++) {
6069
+ const res = await fetcher(
6070
+ req("/keepalive-svc/x", { headers: { connection: "close" } }),
6071
+ fakeServer("127.0.0.1"),
6072
+ );
6073
+ await res.arrayBuffer(); // fully drain so the socket returns to the pool
6074
+ }
6075
+ expect(seenPorts.size).toBeLessThanOrEqual(8);
6076
+ } finally {
6077
+ upstream.stop(true);
6078
+ h.cleanup();
6079
+ }
6080
+ });
6081
+
6082
+ test("a client hang-up mid-stream aborts the upstream fetch (socket released, not left streaming to nobody)", async () => {
6083
+ // Without forwarding req.signal, an aborted client left the upstream
6084
+ // streaming its whole body to a gone client and holding the socket. We
6085
+ // forward the signal so the upstream is torn down (cancel() fires or the
6086
+ // enqueue loop errors) instead of running to completion.
6087
+ let tornDown = false;
6088
+ const upstream = Bun.serve({
6089
+ port: 0,
6090
+ hostname: "127.0.0.1",
6091
+ fetch: () =>
6092
+ new Response(
6093
+ new ReadableStream({
6094
+ async start(controller) {
6095
+ try {
6096
+ for (let i = 0; i < 40; i++) {
6097
+ controller.enqueue(new TextEncoder().encode(`chunk ${i}\n`));
6098
+ await Bun.sleep(10);
6099
+ }
6100
+ controller.close();
6101
+ } catch {
6102
+ tornDown = true; // enqueue threw after the peer dropped
6103
+ }
6104
+ },
6105
+ cancel() {
6106
+ tornDown = true; // Bun cancelled the source on disconnect
6107
+ },
6108
+ }),
6109
+ { headers: { "content-type": "text/plain" } },
6110
+ ),
6111
+ });
6112
+ const h = makeHarness();
6113
+ try {
6114
+ writeUpstreamService(h, upstream.port as number);
6115
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
6116
+ const ac = new AbortController();
6117
+ const res = await fetcher(
6118
+ req("/keepalive-svc/stream", { signal: ac.signal }),
6119
+ fakeServer("127.0.0.1"),
6120
+ );
6121
+ const reader = res.body!.getReader();
6122
+ await reader.read(); // consume one chunk, then hang up
6123
+ ac.abort();
6124
+ // Full stream is 40 * 10ms = 400ms; wait past it. With the signal
6125
+ // forwarded the upstream tears down early; without, it runs to close.
6126
+ await Bun.sleep(700);
6127
+ expect(tornDown).toBe(true);
6128
+ } finally {
6129
+ upstream.stop(true);
6130
+ h.cleanup();
6131
+ }
6132
+ });
6133
+ });
6134
+
5966
6135
  describe("resolveClientIp (H2)", () => {
5967
6136
  test("CF-Connecting-IP wins over X-Forwarded-For and peer", () => {
5968
6137
  const r = req("/", {
@@ -34,6 +34,34 @@ describe("install", () => {
34
34
  }
35
35
  });
36
36
 
37
+ test("refuses `install runner` with a retirement message — never bun-adds npm's unrelated `runner` package (2026-07-01)", async () => {
38
+ const { path, cleanup } = makeTempPath();
39
+ try {
40
+ const calls: string[][] = [];
41
+ const logs: string[] = [];
42
+ const code = await install("runner", {
43
+ runner: async (cmd) => {
44
+ calls.push([...cmd]);
45
+ return 0;
46
+ },
47
+ manifestPath: path,
48
+ startService: async () => 0,
49
+ isLinked: () => false,
50
+ portProbe: async () => false,
51
+ log: (l) => logs.push(l),
52
+ });
53
+ expect(code).toBe(1);
54
+ // The load-bearing bit: no `bun add -g runner` ever fires. Without the
55
+ // retired-short guard the bare short falls through the npm arm and
56
+ // installs an unrelated package that happens to be named `runner`.
57
+ expect(calls).toEqual([]);
58
+ expect(logs.join("\n")).toMatch(/retired from the hub's module registry/);
59
+ expect(logs.join("\n")).toMatch(/@openparachute\/runner/);
60
+ } finally {
61
+ cleanup();
62
+ }
63
+ });
64
+
37
65
  test("runs bun add -g then init; seeds manifest when service didn't write one", async () => {
38
66
  const { path, cleanup } = makeTempPath();
39
67
  try {
@@ -333,6 +333,66 @@ describe("bootSupervisedModules", () => {
333
333
  expect(recorder.calls).toEqual([]);
334
334
  expect(logs.some((l) => l.includes("no startCmd resolvable"))).toBe(true);
335
335
  });
336
+
337
+ test("a LEGACY parachute-runner row boots gracefully post-registry-removal (2026-07-01)", async () => {
338
+ // runner left the bootstrap registries (decision: Aaron 2026-07-01 — the
339
+ // module set of record is vault / hub / agent / scribe / surface). An
340
+ // existing operator's services.json still carries the row, and it must
341
+ // take the unknown/third-party path, never crash the boot sweep:
342
+ // - with installDir + module.json → spawned via the module's own
343
+ // declared startCmd (third-party convention);
344
+ // - without installDir → logged + skipped (`no-spec`).
345
+ const installDir = join(h.dir, "runner-install");
346
+ mkdirSync(join(installDir, ".parachute"), { recursive: true });
347
+ writeFileSync(
348
+ join(installDir, ".parachute", "module.json"),
349
+ JSON.stringify({
350
+ name: "runner",
351
+ manifestName: "parachute-runner",
352
+ port: 1945,
353
+ paths: ["/runner", "/.parachute"],
354
+ health: "/runner/healthz",
355
+ startCmd: ["parachute-runner", "serve"],
356
+ }),
357
+ );
358
+ const withInstallDir: ServiceEntry = {
359
+ name: "parachute-runner",
360
+ port: 1945,
361
+ paths: ["/runner", "/.parachute"],
362
+ health: "/runner/healthz",
363
+ version: "0.2.0",
364
+ installDir,
365
+ };
366
+ writeManifest({ services: [withInstallDir] }, h.manifestPath);
367
+ const recorder = makeRecorder();
368
+ const sup = new Supervisor({ spawnFn: recorder.spawn });
369
+ const results = await bootSupervisedModules(sup, {
370
+ manifestPath: h.manifestPath,
371
+ configDir: h.dir,
372
+ });
373
+ expect(results).toHaveLength(1);
374
+ expect(results[0]?.status).toBe("started");
375
+ // The short is the row name now (no registry mapping survives), and the
376
+ // spawn cmd comes from the module's OWN module.json.
377
+ expect(results[0]?.short).toBe("parachute-runner");
378
+ expect(recorder.calls[0]?.cmd).toEqual(["parachute-runner", "serve"]);
379
+
380
+ // Same row WITHOUT installDir: skipped with reason, no crash.
381
+ const { installDir: _drop, ...bare } = withInstallDir;
382
+ writeManifest({ services: [bare] }, h.manifestPath);
383
+ const recorder2 = makeRecorder();
384
+ const sup2 = new Supervisor({ spawnFn: recorder2.spawn });
385
+ const logs: string[] = [];
386
+ const results2 = await bootSupervisedModules(sup2, {
387
+ manifestPath: h.manifestPath,
388
+ configDir: h.dir,
389
+ log: (l) => logs.push(l),
390
+ });
391
+ expect(results2).toHaveLength(1);
392
+ expect(results2[0]?.status).toBe("skipped");
393
+ expect(results2[0]?.reason).toBe("no-spec");
394
+ expect(recorder2.calls).toEqual([]);
395
+ });
336
396
  });
337
397
 
338
398
  // agent (then channel)#41 — a transiently-wrong (drifted) services.json port for a
@@ -6,6 +6,7 @@ import {
6
6
  findServiceByShort,
7
7
  focusForShort,
8
8
  isKnownModuleShort,
9
+ shortNameForManifest,
9
10
  } from "../service-spec.ts";
10
11
 
11
12
  // 2026-06-09 modular-UI architecture (P2): discovery is driven by the union of
@@ -26,7 +27,7 @@ describe("discoverableShorts", () => {
26
27
 
27
28
  test("includes agent (the module the whitelist used to hide) + the core set", () => {
28
29
  const shorts = discoverableShorts();
29
- for (const s of ["vault", "scribe", "surface", "runner", "agent", "notes"]) {
30
+ for (const s of ["vault", "scribe", "surface", "agent", "notes"]) {
30
31
  expect(shorts).toContain(s);
31
32
  }
32
33
  });
@@ -55,10 +56,8 @@ describe("focusForShort", () => {
55
56
  expect(focusForShort("surface")).toBe("core");
56
57
  // agent stays a legit experimental preview — still offered on a fresh install.
57
58
  expect(focusForShort("agent")).toBe("experimental");
58
- // notes (notes-daemon, deprecated 2026-05-22) + runner (per Aaron
59
- // 2026-06-25, not for new installs) are `deprecated`: still resolvable +
60
- // shown-if-installed, but NOT offered on a fresh setup.
61
- expect(focusForShort("runner")).toBe("deprecated");
59
+ // notes (notes-daemon, deprecated 2026-05-22) is `deprecated`: still
60
+ // resolvable + shown-if-installed, but NOT offered on a fresh setup.
62
61
  expect(focusForShort("notes")).toBe("deprecated");
63
62
  });
64
63
 
@@ -74,18 +73,42 @@ describe("focusForShort", () => {
74
73
  test("deprecated shorts stay resolvable (discoverable) — back-compat for existing installs", () => {
75
74
  // The deprecated tier de-emphasizes + drops the fresh-install OFFER; it does
76
75
  // NOT remove the short from the resolution surface, so an existing
77
- // notes/runner install keeps routing + lifecycle.
76
+ // notes install keeps routing + lifecycle.
78
77
  const shorts = discoverableShorts();
79
78
  expect(shorts).toContain("notes");
80
- expect(shorts).toContain("runner");
81
79
  expect(isKnownModuleShort("notes")).toBe(true);
82
- expect(isKnownModuleShort("runner")).toBe(true);
80
+ });
81
+ });
82
+
83
+ // Runner registry removal (decision: Aaron 2026-07-01 — the module set of
84
+ // record is vault / hub / agent / scribe / surface). Runner is fully out of
85
+ // the bootstrap registries: not discoverable, not a known short, and its
86
+ // manifest name no longer resolves. It is NOT in RETIRED_MODULES (that would
87
+ // GC a legacy operator's services.json row on load) — an existing install is
88
+ // handled as an unknown/third-party row instead (see serve-boot tests).
89
+ describe("runner registry removal (2026-07-01)", () => {
90
+ test("runner is no longer a known/discoverable short", () => {
91
+ expect(isKnownModuleShort("runner")).toBe(false);
92
+ expect(discoverableShorts()).not.toContain("runner");
93
+ expect("runner" in KNOWN_MODULES).toBe(false);
94
+ expect("runner" in FIRST_PARTY_FALLBACKS).toBe(false);
95
+ });
96
+
97
+ test("parachute-runner no longer resolves to a short — legacy rows are third-party-shaped", () => {
98
+ // Consumers (status / serve-boot / api-modules) fall back to the row's
99
+ // own name when this returns undefined, which is what keeps an existing
100
+ // runner install rendering + booting instead of crashing.
101
+ expect(shortNameForManifest("parachute-runner")).toBeUndefined();
102
+ });
103
+
104
+ test("an unlisted runner short defaults to the experimental tier, like any third-party", () => {
105
+ expect(focusForShort("runner")).toBe("experimental");
83
106
  });
84
107
  });
85
108
 
86
109
  describe("isKnownModuleShort", () => {
87
110
  test("true for every known module (the install/config gate)", () => {
88
- for (const s of ["vault", "scribe", "surface", "runner", "agent", "notes"]) {
111
+ for (const s of ["vault", "scribe", "surface", "agent", "notes"]) {
89
112
  expect(isKnownModuleShort(s)).toBe(true);
90
113
  }
91
114
  });