@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.
- package/package.json +1 -1
- package/src/__tests__/admin-module-token.test.ts +40 -3
- package/src/__tests__/api-modules-ops.test.ts +8 -3
- package/src/__tests__/api-modules.test.ts +26 -18
- package/src/__tests__/connections-store.test.ts +84 -0
- package/src/__tests__/doctor.test.ts +131 -0
- package/src/__tests__/git-notify.test.ts +29 -1
- package/src/__tests__/grants-store.test.ts +33 -1
- package/src/__tests__/hub-instance.test.ts +297 -0
- package/src/__tests__/hub-server.test.ts +169 -0
- package/src/__tests__/install.test.ts +28 -0
- package/src/__tests__/serve-boot.test.ts +60 -0
- package/src/__tests__/service-spec-discovery.test.ts +32 -9
- package/src/__tests__/setup.test.ts +64 -16
- package/src/__tests__/stale-module-units.test.ts +1 -1
- package/src/__tests__/status-supervisor.test.ts +112 -0
- package/src/admin-connections.ts +5 -1
- package/src/admin-module-token.ts +2 -2
- package/src/api-modules-ops.ts +3 -3
- package/src/api-modules.ts +13 -13
- package/src/commands/doctor.ts +167 -4
- package/src/commands/install.ts +29 -3
- package/src/commands/migrate.ts +5 -0
- package/src/commands/serve.ts +52 -0
- package/src/commands/setup.ts +10 -9
- package/src/commands/status.ts +42 -1
- package/src/connections-store.ts +15 -2
- package/src/git-notify.ts +34 -5
- package/src/grants-store.ts +15 -2
- package/src/help.ts +3 -3
- package/src/hub-instance.ts +365 -0
- package/src/hub-server.ts +89 -1
- package/src/install-source.ts +1 -1
- package/src/service-spec.ts +36 -44
- package/src/services-manifest.ts +1 -1
- package/src/stale-module-units.ts +2 -2
- 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", "
|
|
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)
|
|
59
|
-
//
|
|
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
|
|
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
|
-
|
|
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", "
|
|
111
|
+
for (const s of ["vault", "scribe", "surface", "agent", "notes"]) {
|
|
89
112
|
expect(isKnownModuleShort(s)).toBe(true);
|
|
90
113
|
}
|
|
91
114
|
});
|