@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
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - 401 when the cookie names a deleted session.
|
|
8
8
|
* - 405 on POST.
|
|
9
9
|
* - 200 + JWT carrying `aud: "<short>"` and `<short>:admin` for known modules
|
|
10
|
-
* (scribe /
|
|
10
|
+
* (scribe / surface / agent).
|
|
11
11
|
* - 400 for `vault` (per-instance — points at /admin/vault-admin-token/<name>).
|
|
12
12
|
* - 404 for an unknown short.
|
|
13
13
|
* - First-admin gate: 403 for a signed-in non-first-admin (friend).
|
|
@@ -111,8 +111,10 @@ describe("handleModuleToken", () => {
|
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
// The known single-audience modules the generic mint serves. Each gets
|
|
114
|
-
// `<short>:admin` with `aud: <short>`.
|
|
115
|
-
|
|
114
|
+
// `<short>:admin` with `aud: <short>`. (runner left this set with its
|
|
115
|
+
// 2026-07-01 registry removal — a LEGACY install still mints via the
|
|
116
|
+
// self-registration gate, pinned below.)
|
|
117
|
+
for (const short of ["scribe", "surface", "agent"]) {
|
|
116
118
|
test(`200 mints a JWT carrying aud:${short} + ${short}:admin`, async () => {
|
|
117
119
|
const { cookie, userId } = await withSession();
|
|
118
120
|
rotateSigningKey(harness.db);
|
|
@@ -201,6 +203,41 @@ describe("handleModuleToken", () => {
|
|
|
201
203
|
return dir;
|
|
202
204
|
}
|
|
203
205
|
|
|
206
|
+
test("200 mints for a LEGACY runner install via the self-registration gate (registry removal 2026-07-01)", async () => {
|
|
207
|
+
// runner is no longer a known short, so path 2 (bootstrap registry) is
|
|
208
|
+
// gone — but an existing install that self-registered (row + module.json)
|
|
209
|
+
// keeps minting `runner:admin` through path 1, exactly like a third-party
|
|
210
|
+
// module. Its config UI keeps working post-removal.
|
|
211
|
+
const { cookie } = await withSession();
|
|
212
|
+
rotateSigningKey(harness.db);
|
|
213
|
+
const installDir = writeManifestDir("parachute-runner");
|
|
214
|
+
try {
|
|
215
|
+
const services: ServiceEntry[] = [
|
|
216
|
+
{
|
|
217
|
+
name: "parachute-runner",
|
|
218
|
+
port: 1945,
|
|
219
|
+
paths: ["/runner"],
|
|
220
|
+
health: "/runner/healthz",
|
|
221
|
+
version: "0.2.0",
|
|
222
|
+
installDir,
|
|
223
|
+
},
|
|
224
|
+
];
|
|
225
|
+
// The row matches by its literal services.json name (the third-party
|
|
226
|
+
// convention) — `runner` the bare short no longer resolves anywhere.
|
|
227
|
+
const req = new Request(urlFor("parachute-runner"), { headers: { cookie } });
|
|
228
|
+
const res = await handleModuleToken(req, "parachute-runner", depsWith(services));
|
|
229
|
+
expect(res.status).toBe(200);
|
|
230
|
+
const body = (await res.json()) as { scopes: string[] };
|
|
231
|
+
expect(body.scopes).toEqual(["parachute-runner:admin"]);
|
|
232
|
+
// The bare `runner` short 404s — nothing known + no row named "runner".
|
|
233
|
+
const bareReq = new Request(urlFor("runner"), { headers: { cookie } });
|
|
234
|
+
const bareRes = await handleModuleToken(bareReq, "runner", depsWith(services));
|
|
235
|
+
expect(bareRes.status).toBe(404);
|
|
236
|
+
} finally {
|
|
237
|
+
rmSync(installDir, { recursive: true, force: true });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
204
241
|
test("200 mints for a self-registered third-party module (row + readable module.json)", async () => {
|
|
205
242
|
const { cookie, userId } = await withSession();
|
|
206
243
|
rotateSigningKey(harness.db);
|
|
@@ -218,9 +218,9 @@ describe("parseModulesPath", () => {
|
|
|
218
218
|
short: "agent",
|
|
219
219
|
rest: "install",
|
|
220
220
|
});
|
|
221
|
-
// Other known modules (
|
|
222
|
-
expect(parseModulesPath("/api/modules/
|
|
223
|
-
short: "
|
|
221
|
+
// Other known modules (surface) resolve too.
|
|
222
|
+
expect(parseModulesPath("/api/modules/surface/install")).toEqual({
|
|
223
|
+
short: "surface",
|
|
224
224
|
rest: "install",
|
|
225
225
|
});
|
|
226
226
|
});
|
|
@@ -231,6 +231,11 @@ describe("parseModulesPath", () => {
|
|
|
231
231
|
// module-ops switch never acts on them.
|
|
232
232
|
expect(parseModulesPath("/api/modules/hub/install")).toBeUndefined();
|
|
233
233
|
expect(parseModulesPath("/api/modules/random/install")).toBeUndefined();
|
|
234
|
+
// runner left the registries on 2026-07-01 — module-ops no longer
|
|
235
|
+
// addresses it by short, same as any third-party module. (A legacy
|
|
236
|
+
// install still boots under `parachute serve` via its installDir; see
|
|
237
|
+
// serve-boot tests.)
|
|
238
|
+
expect(parseModulesPath("/api/modules/runner/install")).toBeUndefined();
|
|
234
239
|
});
|
|
235
240
|
|
|
236
241
|
test("rejects malformed paths", () => {
|
|
@@ -222,10 +222,11 @@ describe("GET /api/modules", () => {
|
|
|
222
222
|
// by the UNION of the bootstrap registries (KNOWN_MODULES ∪
|
|
223
223
|
// FIRST_PARTY_FALLBACKS), NOT a curated whitelist. Every known module
|
|
224
224
|
// surfaces — core (vault/scribe/surface) in the headline tier, agent as
|
|
225
|
-
// `experimental`, and notes
|
|
225
|
+
// `experimental`, and notes as `deprecated` (2026-06-25, still
|
|
226
226
|
// resolvable but not offered for fresh installs) — so the agent-not-installed
|
|
227
227
|
// class (running but invisible) can't recur while deprecated modules stop
|
|
228
|
-
// being pushed on a fresh box.
|
|
228
|
+
// being pushed on a fresh box. runner left the registries entirely on
|
|
229
|
+
// 2026-07-01 and must NOT surface on a fresh catalog.
|
|
229
230
|
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
230
231
|
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
231
232
|
db: h.db,
|
|
@@ -250,31 +251,31 @@ describe("GET /api/modules", () => {
|
|
|
250
251
|
// ahead of every experimental module, which lead the deprecated ones.
|
|
251
252
|
expect(shorts.indexOf("vault")).toBeLessThan(shorts.indexOf("scribe"));
|
|
252
253
|
expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("agent"));
|
|
253
|
-
// agent (experimental) sorts ahead of notes
|
|
254
|
-
expect(shorts.indexOf("agent")).toBeLessThan(shorts.indexOf("runner"));
|
|
254
|
+
// agent (experimental) sorts ahead of notes (deprecated).
|
|
255
255
|
expect(shorts.indexOf("agent")).toBeLessThan(shorts.indexOf("notes"));
|
|
256
256
|
// Every known module is discoverable — vault/scribe/surface (core),
|
|
257
|
-
// agent (experimental), notes
|
|
258
|
-
|
|
257
|
+
// agent (experimental), notes (deprecated). runner is gone (2026-07-01
|
|
258
|
+
// registry removal) — a fresh box never sees it.
|
|
259
|
+
for (const s of ["vault", "scribe", "surface", "agent", "notes"]) {
|
|
259
260
|
expect(shorts).toContain(s);
|
|
260
261
|
}
|
|
262
|
+
expect(shorts).not.toContain("runner");
|
|
263
|
+
expect(shorts).not.toContain("parachute-runner");
|
|
261
264
|
// Focus tier resolves from the default map.
|
|
262
265
|
const byShort = new Map(body.modules.map((m) => [m.short, m]));
|
|
263
266
|
expect(byShort.get("vault")?.focus).toBe("core");
|
|
264
267
|
expect(byShort.get("scribe")?.focus).toBe("core");
|
|
265
268
|
expect(byShort.get("surface")?.focus).toBe("core");
|
|
266
269
|
expect(byShort.get("agent")?.focus).toBe("experimental");
|
|
267
|
-
expect(byShort.get("runner")?.focus).toBe("deprecated");
|
|
268
270
|
expect(byShort.get("notes")?.focus).toBe("deprecated");
|
|
269
271
|
// `available` stays true for every known module (re-installable), but the
|
|
270
272
|
// fresh-install OFFER (`available_to_install`) drops the deprecated tier —
|
|
271
|
-
// notes
|
|
273
|
+
// notes isn't pushed on a fresh box; agent (experimental) still is.
|
|
272
274
|
expect(body.modules.every((m) => m.available)).toBe(true);
|
|
273
275
|
expect(byShort.get("vault")?.available_to_install).toBe(true);
|
|
274
276
|
expect(byShort.get("scribe")?.available_to_install).toBe(true);
|
|
275
277
|
expect(byShort.get("surface")?.available_to_install).toBe(true);
|
|
276
278
|
expect(byShort.get("agent")?.available_to_install).toBe(true);
|
|
277
|
-
expect(byShort.get("runner")?.available_to_install).toBe(false);
|
|
278
279
|
expect(byShort.get("notes")?.available_to_install).toBe(false);
|
|
279
280
|
expect(body.modules.every((m) => !m.installed)).toBe(true);
|
|
280
281
|
expect(body.modules.every((m) => m.latest_version === "0.9.9")).toBe(true);
|
|
@@ -282,11 +283,13 @@ describe("GET /api/modules", () => {
|
|
|
282
283
|
expect(body.supervisor_available).toBe(false);
|
|
283
284
|
});
|
|
284
285
|
|
|
285
|
-
test("an installed
|
|
286
|
-
// A legacy operator with runner on disk: the row
|
|
287
|
-
//
|
|
288
|
-
// `
|
|
289
|
-
//
|
|
286
|
+
test("an installed LEGACY runner row still surfaces as a third-party row post-registry-removal (2026-07-01)", async () => {
|
|
287
|
+
// A legacy operator with runner on disk: post-removal the row no longer
|
|
288
|
+
// resolves to a known short, so it surfaces under its own manifest name
|
|
289
|
+
// (`parachute-runner`) exactly like a third-party module — visible
|
|
290
|
+
// (installed: true), never offered (`available` false: no install package
|
|
291
|
+
// known to the hub), and NEVER crashes the catalog. This is the graceful
|
|
292
|
+
// existing-install posture the removal preserves.
|
|
290
293
|
writeManifest(h.manifestPath, [
|
|
291
294
|
{
|
|
292
295
|
name: "parachute-runner",
|
|
@@ -313,13 +316,18 @@ describe("GET /api/modules", () => {
|
|
|
313
316
|
available_to_install: boolean;
|
|
314
317
|
}>;
|
|
315
318
|
};
|
|
316
|
-
|
|
319
|
+
// No known short resolves anymore — the row surfaces under its own
|
|
320
|
+
// manifest name, the third-party fallback convention.
|
|
321
|
+
expect(body.modules.find((m) => m.short === "runner")).toBeUndefined();
|
|
322
|
+
const runner = body.modules.find((m) => m.short === "parachute-runner");
|
|
317
323
|
expect(runner).toBeDefined();
|
|
318
324
|
expect(runner?.installed).toBe(true);
|
|
319
325
|
expect(runner?.installed_version).toBe("0.2.0");
|
|
320
|
-
|
|
321
|
-
//
|
|
322
|
-
expect(runner?.
|
|
326
|
+
// Unlisted shorts default to the experimental tier (no special-casing
|
|
327
|
+
// survives the removal).
|
|
328
|
+
expect(runner?.focus).toBe("experimental");
|
|
329
|
+
// No install package known to the hub → not installable, never offered.
|
|
330
|
+
expect(runner?.available).toBe(false);
|
|
323
331
|
expect(runner?.available_to_install).toBe(false);
|
|
324
332
|
});
|
|
325
333
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the connections registry store (`connections.json`,
|
|
3
|
+
* `connections-store.ts`). The store's behavior is otherwise exercised
|
|
4
|
+
* indirectly through `admin-connections.test.ts`; this file pins the on-disk
|
|
5
|
+
* shape directly — specifically the `version` field added 2026-07-01 (the
|
|
6
|
+
* future-migration seam) and the legacy-file tolerance that makes it safe.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
CONNECTIONS_FILE_VERSION,
|
|
14
|
+
type ConnectionRecord,
|
|
15
|
+
getConnection,
|
|
16
|
+
putConnection,
|
|
17
|
+
readConnections,
|
|
18
|
+
removeConnection,
|
|
19
|
+
} from "../connections-store.ts";
|
|
20
|
+
|
|
21
|
+
let dir: string;
|
|
22
|
+
let storePath: string;
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
dir = mkdtempSync(join(tmpdir(), "phub-connections-store-"));
|
|
25
|
+
storePath = join(dir, "connections.json");
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
rmSync(dir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function rec(over: Partial<ConnectionRecord> = {}): ConnectionRecord {
|
|
32
|
+
return {
|
|
33
|
+
id: over.id ?? "conn-1",
|
|
34
|
+
source: over.source ?? { module: "vault", vault: "default", event: "note.created" },
|
|
35
|
+
sink: over.sink ?? { module: "agent", action: "message.deliver" },
|
|
36
|
+
provisioned: over.provisioned ?? { type: "vault-trigger", triggerName: "t1", vault: "default" },
|
|
37
|
+
createdAt: over.createdAt ?? "2026-07-01T00:00:00.000Z",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("file versioning (2026-07-01 — future-migration seam)", () => {
|
|
42
|
+
test("every write stamps version: 1 and put → read round-trips", () => {
|
|
43
|
+
putConnection(storePath, rec());
|
|
44
|
+
const onDisk = JSON.parse(readFileSync(storePath, "utf8")) as { version?: unknown };
|
|
45
|
+
expect(onDisk.version).toBe(CONNECTIONS_FILE_VERSION);
|
|
46
|
+
expect(onDisk.version).toBe(1);
|
|
47
|
+
const back = readConnections(storePath);
|
|
48
|
+
expect(back).toHaveLength(1);
|
|
49
|
+
expect(back[0]).toEqual(rec());
|
|
50
|
+
// Rewrites (upsert + remove) keep stamping it.
|
|
51
|
+
putConnection(storePath, rec({ id: "conn-2" }));
|
|
52
|
+
removeConnection(storePath, "conn-1");
|
|
53
|
+
const after = JSON.parse(readFileSync(storePath, "utf8")) as {
|
|
54
|
+
version?: unknown;
|
|
55
|
+
connections: unknown[];
|
|
56
|
+
};
|
|
57
|
+
expect(after.version).toBe(1);
|
|
58
|
+
expect(after.connections).toHaveLength(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a LEGACY file without version loads fine (treated as v1)", () => {
|
|
62
|
+
const legacy = rec();
|
|
63
|
+
writeFileSync(storePath, JSON.stringify({ connections: [legacy] }));
|
|
64
|
+
const back = readConnections(storePath);
|
|
65
|
+
expect(back).toHaveLength(1);
|
|
66
|
+
expect(back[0]).toEqual(legacy);
|
|
67
|
+
expect(getConnection(storePath, legacy.id)?.id).toBe(legacy.id);
|
|
68
|
+
// First write-through upgrades the file to the versioned shape without
|
|
69
|
+
// losing the legacy record.
|
|
70
|
+
putConnection(storePath, rec({ id: "conn-2" }));
|
|
71
|
+
const upgraded = JSON.parse(readFileSync(storePath, "utf8")) as {
|
|
72
|
+
version?: unknown;
|
|
73
|
+
connections: unknown[];
|
|
74
|
+
};
|
|
75
|
+
expect(upgraded.version).toBe(1);
|
|
76
|
+
expect(upgraded.connections).toHaveLength(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a missing or garbage file still reads as empty (fresh hub)", () => {
|
|
80
|
+
expect(readConnections(storePath)).toEqual([]);
|
|
81
|
+
writeFileSync(storePath, "not-json{");
|
|
82
|
+
expect(readConnections(storePath)).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -753,3 +753,134 @@ describe("doctor --fix — canonical-port repair (confirm-gated, idempotent, non
|
|
|
753
753
|
}
|
|
754
754
|
});
|
|
755
755
|
});
|
|
756
|
+
|
|
757
|
+
describe("doctor — loopback-hijack check (hub#737)", () => {
|
|
758
|
+
test("no hub-instance.json → PASS (benign; the Hub check owns 'down') — #717", async () => {
|
|
759
|
+
const h = makeHarness();
|
|
760
|
+
try {
|
|
761
|
+
seedCurrentManifest(h.manifestPath);
|
|
762
|
+
seedOperatorToken(h.configDir);
|
|
763
|
+
// No instance file seeded, no seams overridden — defaults read the empty
|
|
764
|
+
// sandbox and short-circuit before any real network/lsof.
|
|
765
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
766
|
+
expect(byName(checks, "loopback-hijack")?.status).toBe("pass");
|
|
767
|
+
expect(code).toBe(0);
|
|
768
|
+
} finally {
|
|
769
|
+
h.cleanup();
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test("loopback nonce matches ours + single listener → PASS", async () => {
|
|
774
|
+
const h = makeHarness();
|
|
775
|
+
try {
|
|
776
|
+
seedCurrentManifest(h.manifestPath);
|
|
777
|
+
seedOperatorToken(h.configDir);
|
|
778
|
+
const { code, checks } = await runDoctor(
|
|
779
|
+
h,
|
|
780
|
+
healthyDeps({
|
|
781
|
+
readInstanceRecord: () => ({ instance: "n1", pid: 1, port: 1939, startedAt: "" }),
|
|
782
|
+
probeLoopbackInstance: async () => ({ reachable: true, status: 200, instance: "n1" }),
|
|
783
|
+
countHubListeners: () => 1,
|
|
784
|
+
}),
|
|
785
|
+
);
|
|
786
|
+
const c = byName(checks, "loopback-hijack");
|
|
787
|
+
expect(c?.status).toBe("pass");
|
|
788
|
+
expect(c?.detail).toContain("instance nonce");
|
|
789
|
+
expect(code).toBe(0);
|
|
790
|
+
} finally {
|
|
791
|
+
h.cleanup();
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("loopback nonce MISMATCH → FAIL with lsof/orb remediation + incident ref", async () => {
|
|
796
|
+
const h = makeHarness();
|
|
797
|
+
try {
|
|
798
|
+
seedCurrentManifest(h.manifestPath);
|
|
799
|
+
seedOperatorToken(h.configDir);
|
|
800
|
+
const { code, checks } = await runDoctor(
|
|
801
|
+
h,
|
|
802
|
+
healthyDeps({
|
|
803
|
+
readInstanceRecord: () => ({ instance: "ours", pid: 1, port: 1939, startedAt: "" }),
|
|
804
|
+
probeLoopbackInstance: async () => ({
|
|
805
|
+
reachable: true,
|
|
806
|
+
status: 200,
|
|
807
|
+
instance: "rogue-hub",
|
|
808
|
+
}),
|
|
809
|
+
countHubListeners: () => 2,
|
|
810
|
+
}),
|
|
811
|
+
);
|
|
812
|
+
const c = byName(checks, "loopback-hijack");
|
|
813
|
+
expect(c?.status).toBe("fail");
|
|
814
|
+
expect(c?.detail).toContain("rogue-hub");
|
|
815
|
+
expect(c?.detail).toContain("2 listeners");
|
|
816
|
+
expect(c?.detail).toContain("hub#737");
|
|
817
|
+
expect(c?.fix).toContain("lsof -nP -iTCP:1939 -sTCP:LISTEN");
|
|
818
|
+
expect(c?.fix).toContain("orb list");
|
|
819
|
+
expect(code).toBe(1);
|
|
820
|
+
} finally {
|
|
821
|
+
h.cleanup();
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("foreign process answering with NO nonce → FAIL (the OrbStack container-hub shape)", async () => {
|
|
826
|
+
const h = makeHarness();
|
|
827
|
+
try {
|
|
828
|
+
seedCurrentManifest(h.manifestPath);
|
|
829
|
+
seedOperatorToken(h.configDir);
|
|
830
|
+
const { checks } = await runDoctor(
|
|
831
|
+
h,
|
|
832
|
+
healthyDeps({
|
|
833
|
+
readInstanceRecord: () => ({ instance: "ours", pid: 1, port: 1939, startedAt: "" }),
|
|
834
|
+
probeLoopbackInstance: async () => ({ reachable: true, status: 200 }),
|
|
835
|
+
countHubListeners: () => undefined, // lsof indeterminate — still FAILs on the nonce alone
|
|
836
|
+
}),
|
|
837
|
+
);
|
|
838
|
+
const c = byName(checks, "loopback-hijack");
|
|
839
|
+
expect(c?.status).toBe("fail");
|
|
840
|
+
expect(c?.detail).toContain("foreign process");
|
|
841
|
+
} finally {
|
|
842
|
+
h.cleanup();
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
test("nonce matches but a SECOND listener exists → WARN (latent shadow, not FAIL)", async () => {
|
|
847
|
+
const h = makeHarness();
|
|
848
|
+
try {
|
|
849
|
+
seedCurrentManifest(h.manifestPath);
|
|
850
|
+
seedOperatorToken(h.configDir);
|
|
851
|
+
const { code, checks } = await runDoctor(
|
|
852
|
+
h,
|
|
853
|
+
healthyDeps({
|
|
854
|
+
readInstanceRecord: () => ({ instance: "n1", pid: 1, port: 1939, startedAt: "" }),
|
|
855
|
+
probeLoopbackInstance: async () => ({ reachable: true, status: 200, instance: "n1" }),
|
|
856
|
+
countHubListeners: () => 2,
|
|
857
|
+
}),
|
|
858
|
+
);
|
|
859
|
+
const c = byName(checks, "loopback-hijack");
|
|
860
|
+
expect(c?.status).toBe("warn");
|
|
861
|
+
expect(c?.detail).toContain("2 listeners");
|
|
862
|
+
// WARN never fails the exit code.
|
|
863
|
+
expect(code).toBe(0);
|
|
864
|
+
} finally {
|
|
865
|
+
h.cleanup();
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
test("record present but loopback unreachable → PASS (defers to the Hub check)", async () => {
|
|
870
|
+
const h = makeHarness();
|
|
871
|
+
try {
|
|
872
|
+
seedCurrentManifest(h.manifestPath);
|
|
873
|
+
seedOperatorToken(h.configDir);
|
|
874
|
+
const { checks } = await runDoctor(
|
|
875
|
+
h,
|
|
876
|
+
healthyDeps({
|
|
877
|
+
readInstanceRecord: () => ({ instance: "n1", pid: 1, port: 1939, startedAt: "" }),
|
|
878
|
+
probeLoopbackInstance: async () => ({ reachable: false }),
|
|
879
|
+
}),
|
|
880
|
+
);
|
|
881
|
+
expect(byName(checks, "loopback-hijack")?.status).toBe("pass");
|
|
882
|
+
} finally {
|
|
883
|
+
h.cleanup();
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
});
|
|
@@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { REGISTERED_MINT_TTL_THRESHOLD_SECONDS } from "../admin-connections.ts";
|
|
6
|
+
import {
|
|
7
|
+
NOTIFY_TTL_SECONDS,
|
|
8
|
+
PULL_TTL_SECONDS,
|
|
9
|
+
assertUnregisteredMintTtl,
|
|
10
|
+
notifySurfacePushed,
|
|
11
|
+
} from "../git-notify.ts";
|
|
6
12
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
7
13
|
import { validateAccessToken } from "../jwt-sign.ts";
|
|
8
14
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
@@ -37,6 +43,28 @@ function fetchSpy(status = 200, body = '{"ok":true}') {
|
|
|
37
43
|
return { impl, calls };
|
|
38
44
|
}
|
|
39
45
|
|
|
46
|
+
describe("unregistered-mint TTL policy guard", () => {
|
|
47
|
+
test("the shipped TTLs sit strictly under the registered-mint threshold", () => {
|
|
48
|
+
// The import of git-notify.ts at the top of this file already ran the
|
|
49
|
+
// module-load asserts — reaching here means they passed. Pin the values
|
|
50
|
+
// explicitly too, so a bumped TTL fails with a readable diff.
|
|
51
|
+
expect(NOTIFY_TTL_SECONDS).toBeLessThan(REGISTERED_MINT_TTL_THRESHOLD_SECONDS);
|
|
52
|
+
expect(PULL_TTL_SECONDS).toBeLessThan(REGISTERED_MINT_TTL_THRESHOLD_SECONDS);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("the guard throws at/above the threshold and passes just under it", () => {
|
|
56
|
+
expect(() =>
|
|
57
|
+
assertUnregisteredMintTtl("PULL_TTL_SECONDS", REGISTERED_MINT_TTL_THRESHOLD_SECONDS),
|
|
58
|
+
).toThrow(/registered-mint/);
|
|
59
|
+
expect(() =>
|
|
60
|
+
assertUnregisteredMintTtl("PULL_TTL_SECONDS", REGISTERED_MINT_TTL_THRESHOLD_SECONDS + 1),
|
|
61
|
+
).toThrow(/PULL_TTL_SECONDS/);
|
|
62
|
+
expect(() =>
|
|
63
|
+
assertUnregisteredMintTtl("NOTIFY_TTL_SECONDS", REGISTERED_MINT_TTL_THRESHOLD_SECONDS - 1),
|
|
64
|
+
).not.toThrow();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
40
68
|
describe("notifySurfacePushed", () => {
|
|
41
69
|
test("no surface module installed → no-op, no fetch", async () => {
|
|
42
70
|
const h = makeHarness();
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* the lenient-read posture.
|
|
8
8
|
*/
|
|
9
9
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
-
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import {
|
|
14
14
|
type ConnectionSpec,
|
|
15
|
+
GRANTS_FILE_VERSION,
|
|
15
16
|
type GrantRecord,
|
|
16
17
|
connectionKey,
|
|
17
18
|
getGrant,
|
|
@@ -93,6 +94,37 @@ describe("round-trip", () => {
|
|
|
93
94
|
});
|
|
94
95
|
});
|
|
95
96
|
|
|
97
|
+
describe("file versioning (2026-07-01 — future-migration seam)", () => {
|
|
98
|
+
test("every write stamps version: 1 and a rewrite preserves it", () => {
|
|
99
|
+
putGrant(storePath, rec());
|
|
100
|
+
const first = JSON.parse(readFileSync(storePath, "utf8")) as { version?: unknown };
|
|
101
|
+
expect(first.version).toBe(GRANTS_FILE_VERSION);
|
|
102
|
+
expect(first.version).toBe(1);
|
|
103
|
+
// Round-trip through the store API keeps stamping it.
|
|
104
|
+
putGrant(storePath, rec({ id: "second", agent: "agent2" }));
|
|
105
|
+
const second = JSON.parse(readFileSync(storePath, "utf8")) as { version?: unknown };
|
|
106
|
+
expect(second.version).toBe(1);
|
|
107
|
+
expect(readGrants(storePath)).toHaveLength(2);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a LEGACY file without version loads fine (treated as v1)", () => {
|
|
111
|
+
const legacy = rec();
|
|
112
|
+
writeFileSync(storePath, JSON.stringify({ grants: [legacy] }));
|
|
113
|
+
const back = readGrants(storePath);
|
|
114
|
+
expect(back).toHaveLength(1);
|
|
115
|
+
expect(back[0]).toEqual(legacy);
|
|
116
|
+
// First write-through upgrades the file to the versioned shape without
|
|
117
|
+
// losing the legacy record.
|
|
118
|
+
putGrant(storePath, rec({ id: "new-row", agent: "agent2" }));
|
|
119
|
+
const upgraded = JSON.parse(readFileSync(storePath, "utf8")) as {
|
|
120
|
+
version?: unknown;
|
|
121
|
+
grants: unknown[];
|
|
122
|
+
};
|
|
123
|
+
expect(upgraded.version).toBe(1);
|
|
124
|
+
expect(upgraded.grants).toHaveLength(2);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
96
128
|
describe("0600 perms (the file holds secrets)", () => {
|
|
97
129
|
test("the store file is created mode 0600", () => {
|
|
98
130
|
putGrant(
|