@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
|
@@ -119,9 +119,12 @@ describe("isOfferable (fresh-install OFFER, 2026-06-25)", () => {
|
|
|
119
119
|
expect(isOfferable({ short: "agent", installed: false })).toBe(true);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
-
test("does NOT offer a deprecated module (notes
|
|
122
|
+
test("does NOT offer a deprecated module (notes) on a fresh install", () => {
|
|
123
123
|
expect(isOfferable({ short: "notes", installed: false })).toBe(false);
|
|
124
|
-
|
|
124
|
+
// runner is stronger than deprecated now: it left the registries entirely
|
|
125
|
+
// (2026-07-01), so it never reaches the survey → isOfferable never sees
|
|
126
|
+
// it. (The bare predicate would say true for an unknown short — the OFFER
|
|
127
|
+
// gate for runner is `knownServices()` no longer containing it.)
|
|
125
128
|
});
|
|
126
129
|
|
|
127
130
|
test("never offers an already-installed module regardless of tier", () => {
|
|
@@ -137,6 +140,9 @@ describe("setup", () => {
|
|
|
137
140
|
// Pre-seed every first-party shortname so survey returns all-installed.
|
|
138
141
|
// Distinct canonical ports per service — services-manifest.ts now
|
|
139
142
|
// rejects duplicate ports between distinct services (hub#195).
|
|
143
|
+
// parachute-runner rides along as a LEGACY row (runner left the
|
|
144
|
+
// registries 2026-07-01): it must neither block the all-installed exit
|
|
145
|
+
// nor crash the survey.
|
|
140
146
|
const seeds: Array<{ name: string; port: number }> = [
|
|
141
147
|
{ name: "parachute-vault", port: 1940 },
|
|
142
148
|
{ name: "parachute-notes", port: 1942 },
|
|
@@ -175,12 +181,13 @@ describe("setup", () => {
|
|
|
175
181
|
}
|
|
176
182
|
});
|
|
177
183
|
|
|
178
|
-
test("fresh box: the offered 'Available to install' list excludes deprecated notes
|
|
184
|
+
test("fresh box: the offered 'Available to install' list excludes deprecated notes + removed runner", async () => {
|
|
179
185
|
const h = makeHarness();
|
|
180
186
|
try {
|
|
181
187
|
// 'all' picks every OFFERED service. With a clean services.json the survey
|
|
182
|
-
// sees every known short; the offered filter must drop notes
|
|
183
|
-
//
|
|
188
|
+
// sees every known short; the offered filter must drop notes (deprecated,
|
|
189
|
+
// 2026-06-25) while runner never even reaches the survey (registry
|
|
190
|
+
// removal, 2026-07-01) — keeping vault/scribe/surface/agent. Only vault +
|
|
184
191
|
// scribe have pre-install follow-up prompts (vault name, scribe provider);
|
|
185
192
|
// surface + agent have none — so the scripted answers below are complete.
|
|
186
193
|
const availability = scriptedAvailability([
|
|
@@ -221,10 +228,57 @@ describe("setup", () => {
|
|
|
221
228
|
test("an already-installed deprecated module still shows in 'Already installed' + isn't re-offered (back-compat)", async () => {
|
|
222
229
|
const h = makeHarness();
|
|
223
230
|
try {
|
|
224
|
-
// Legacy operator with
|
|
225
|
-
// "Already installed" banner (so they know it's there + can manage
|
|
226
|
-
// `parachute <verb>
|
|
227
|
-
// OFFER list.
|
|
231
|
+
// Legacy operator with notes-daemon (deprecated) on disk. It must surface
|
|
232
|
+
// in the "Already installed" banner (so they know it's there + can manage
|
|
233
|
+
// it via `parachute <verb> notes`), and must NOT reappear in the
|
|
234
|
+
// fresh-install OFFER list.
|
|
235
|
+
upsertService(
|
|
236
|
+
{
|
|
237
|
+
name: "parachute-notes",
|
|
238
|
+
version: "0.3.15",
|
|
239
|
+
port: 1942,
|
|
240
|
+
paths: ["/notes"],
|
|
241
|
+
health: "/notes/health",
|
|
242
|
+
},
|
|
243
|
+
h.manifestPath,
|
|
244
|
+
);
|
|
245
|
+
const availability = scriptedAvailability([
|
|
246
|
+
"surface", // pick a still-offered module
|
|
247
|
+
]);
|
|
248
|
+
const code = await setup({
|
|
249
|
+
manifestPath: h.manifestPath,
|
|
250
|
+
configDir: h.configDir,
|
|
251
|
+
log: (l) => h.logs.push(l),
|
|
252
|
+
availability,
|
|
253
|
+
installFn: async (short, opts) => {
|
|
254
|
+
h.calls.push({ short, opts });
|
|
255
|
+
return 0;
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
expect(code).toBe(0);
|
|
259
|
+
const joined = h.logs.join("\n");
|
|
260
|
+
// Banner lists notes as already installed…
|
|
261
|
+
const installedBlock = joined.slice(
|
|
262
|
+
joined.indexOf("Already installed:"),
|
|
263
|
+
joined.indexOf("Available to install:"),
|
|
264
|
+
);
|
|
265
|
+
expect(installedBlock).toMatch(/\bnotes\b/);
|
|
266
|
+
// …but notes is NOT in the fresh-install offer.
|
|
267
|
+
const availableBlock = joined.slice(joined.indexOf("Available to install:"));
|
|
268
|
+
expect(availableBlock).not.toMatch(/\bnotes\b/);
|
|
269
|
+
expect(h.calls.map((c) => c.short)).not.toContain("notes");
|
|
270
|
+
} finally {
|
|
271
|
+
h.cleanup();
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("a LEGACY parachute-runner row doesn't break setup and is never offered (registry removal 2026-07-01)", async () => {
|
|
276
|
+
const h = makeHarness();
|
|
277
|
+
try {
|
|
278
|
+
// runner left the registries entirely — the survey no longer knows the
|
|
279
|
+
// short, so the row is simply invisible to setup (not in "Already
|
|
280
|
+
// installed", not in the offer). The load-bearing assertions: setup
|
|
281
|
+
// still runs to completion and never tries to install runner.
|
|
228
282
|
upsertService(
|
|
229
283
|
{
|
|
230
284
|
name: "parachute-runner",
|
|
@@ -250,16 +304,10 @@ describe("setup", () => {
|
|
|
250
304
|
});
|
|
251
305
|
expect(code).toBe(0);
|
|
252
306
|
const joined = h.logs.join("\n");
|
|
253
|
-
// Banner lists runner as already installed…
|
|
254
|
-
const installedBlock = joined.slice(
|
|
255
|
-
joined.indexOf("Already installed:"),
|
|
256
|
-
joined.indexOf("Available to install:"),
|
|
257
|
-
);
|
|
258
|
-
expect(installedBlock).toMatch(/\brunner\b/);
|
|
259
|
-
// …but runner is NOT in the fresh-install offer.
|
|
260
307
|
const availableBlock = joined.slice(joined.indexOf("Available to install:"));
|
|
261
308
|
expect(availableBlock).not.toMatch(/\brunner\b/);
|
|
262
309
|
expect(h.calls.map((c) => c.short)).not.toContain("runner");
|
|
310
|
+
expect(h.calls.map((c) => c.short)).toContain("surface");
|
|
263
311
|
} finally {
|
|
264
312
|
h.cleanup();
|
|
265
313
|
}
|
|
@@ -55,7 +55,7 @@ function joined(calls: string[][]): string[] {
|
|
|
55
55
|
describe("targetModuleShorts() — known module shorts, never hub/cloudflared", () => {
|
|
56
56
|
test("includes the canonical module shorts and excludes hub", () => {
|
|
57
57
|
const shorts = targetModuleShorts();
|
|
58
|
-
// The canonical knownServices() set: vault / scribe /
|
|
58
|
+
// The canonical knownServices() set: vault / scribe / surface / notes / agent.
|
|
59
59
|
expect(shorts).toContain("vault");
|
|
60
60
|
expect(shorts).toContain("scribe");
|
|
61
61
|
expect(shorts).toContain("surface");
|
|
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { status } from "../commands/status.ts";
|
|
6
|
+
import type { SelfProbeState } from "../hub-instance.ts";
|
|
6
7
|
import type { HubUnitDeps, HubUnitStateResult } from "../hub-unit.ts";
|
|
7
8
|
import {
|
|
8
9
|
type ModuleStatesResult,
|
|
@@ -86,6 +87,12 @@ interface SupervisorArmOpts {
|
|
|
86
87
|
* network; specific tests override to mark a module live.
|
|
87
88
|
*/
|
|
88
89
|
probeModuleHealth?: (port: number, health: string) => Promise<boolean>;
|
|
90
|
+
/**
|
|
91
|
+
* Loopback-hijack self-probe verdict read off `hub-instance.json` (hub#737).
|
|
92
|
+
* Defaults to "no verdict on disk" (undefined) so existing tests are
|
|
93
|
+
* unaffected; the hijack tests inject a `hijacked` / `ok` verdict.
|
|
94
|
+
*/
|
|
95
|
+
readInstanceState?: (configDir: string) => SelfProbeState | undefined;
|
|
89
96
|
}
|
|
90
97
|
|
|
91
98
|
/** Drive `status` through the supervisor arm with fully stubbed seams. */
|
|
@@ -104,6 +111,7 @@ function supervisorOpts(configDir: string, path: string, o: SupervisorArmOpts) {
|
|
|
104
111
|
(async () => o.moduleStates ?? { supervisorAvailable: true, modules: [] }),
|
|
105
112
|
probeModuleHealth: o.probeModuleHealth ?? (async () => false),
|
|
106
113
|
openDb: fakeOpenDb as unknown as (configDir: string) => import("bun:sqlite").Database,
|
|
114
|
+
readInstanceState: o.readInstanceState ?? (() => undefined),
|
|
107
115
|
},
|
|
108
116
|
};
|
|
109
117
|
}
|
|
@@ -651,3 +659,107 @@ describe("status — Phase 3c supervisor arm: module rows", () => {
|
|
|
651
659
|
// manager + supervisor. The supervisor-path readout is exercised throughout the
|
|
652
660
|
// suites above; a box with no hub unit degrades gracefully (manager `no-unit` /
|
|
653
661
|
// `/health` down → inactive rows), which the hub-row + module-row suites cover.
|
|
662
|
+
|
|
663
|
+
describe("status — loopback-hijack override (hub#737)", () => {
|
|
664
|
+
test("selfProbe hijacked flips the hub row to failing despite a healthy /health", async () => {
|
|
665
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
666
|
+
try {
|
|
667
|
+
const lines: string[] = [];
|
|
668
|
+
const opts = supervisorOpts(configDir, path, {
|
|
669
|
+
// The rogue answers /health 200, so the raw liveness probe says healthy —
|
|
670
|
+
// the on-disk self-probe verdict is what corrects the row.
|
|
671
|
+
managerState: { state: "active" },
|
|
672
|
+
hubHealthy: true,
|
|
673
|
+
moduleStates: { supervisorAvailable: true, modules: [] },
|
|
674
|
+
readInstanceState: () => ({
|
|
675
|
+
status: "hijacked",
|
|
676
|
+
checkedAt: "2026-07-02T00:00:00.000Z",
|
|
677
|
+
observedInstance: "rogue-hub",
|
|
678
|
+
}),
|
|
679
|
+
});
|
|
680
|
+
const code = await status({ ...opts, print: (l) => lines.push(l) });
|
|
681
|
+
expect(code).toBe(1);
|
|
682
|
+
const out = lines.join("\n");
|
|
683
|
+
const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
|
|
684
|
+
expect(hubLine).toMatch(/\bfailing\b/);
|
|
685
|
+
expect(out).toContain("LOOPBACK HIJACK on :1939");
|
|
686
|
+
expect(out).toMatch(/lsof -nP -iTCP:1939 -sTCP:LISTEN/);
|
|
687
|
+
} finally {
|
|
688
|
+
cleanup();
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("hub down + STALE hijacked verdict on disk → NO phantom hijack, normal down-hub row", async () => {
|
|
693
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
694
|
+
try {
|
|
695
|
+
const lines: string[] = [];
|
|
696
|
+
// A hard-killed hub can leave a stale `hijacked` verdict in hub-instance.json
|
|
697
|
+
// (it's only cleared on a graceful stop). With nothing answering loopback
|
|
698
|
+
// (hubHealthy=false), status must render the ordinary down-hub row, not a
|
|
699
|
+
// phantom LOOPBACK HIJACK warning.
|
|
700
|
+
const opts = supervisorOpts(configDir, path, {
|
|
701
|
+
managerState: { state: "inactive" },
|
|
702
|
+
hubHealthy: false,
|
|
703
|
+
readInstanceState: () => ({
|
|
704
|
+
status: "hijacked",
|
|
705
|
+
checkedAt: "2026-07-02T00:00:00.000Z",
|
|
706
|
+
observedInstance: "rogue-from-a-past-run",
|
|
707
|
+
}),
|
|
708
|
+
});
|
|
709
|
+
const code = await status({ ...opts, print: (l) => lines.push(l) });
|
|
710
|
+
const out = lines.join("\n");
|
|
711
|
+
expect(out).not.toContain("LOOPBACK HIJACK");
|
|
712
|
+
const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
|
|
713
|
+
expect(hubLine).toMatch(/\binactive\b/);
|
|
714
|
+
// An inactive hub is `skipped` (expected-stopped), so exit 0 — the point is
|
|
715
|
+
// simply that no phantom hijack was injected on top of the normal row.
|
|
716
|
+
expect(code).toBe(0);
|
|
717
|
+
} finally {
|
|
718
|
+
cleanup();
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("selfProbe ok leaves a healthy hub row untouched (active)", async () => {
|
|
723
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
724
|
+
try {
|
|
725
|
+
const lines: string[] = [];
|
|
726
|
+
const opts = supervisorOpts(configDir, path, {
|
|
727
|
+
managerState: { state: "active" },
|
|
728
|
+
hubHealthy: true,
|
|
729
|
+
moduleStates: { supervisorAvailable: true, modules: [] },
|
|
730
|
+
readInstanceState: () => ({
|
|
731
|
+
status: "ok",
|
|
732
|
+
checkedAt: "2026-07-02T00:00:00.000Z",
|
|
733
|
+
}),
|
|
734
|
+
});
|
|
735
|
+
const code = await status({ ...opts, print: (l) => lines.push(l) });
|
|
736
|
+
expect(code).toBe(0);
|
|
737
|
+
const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
|
|
738
|
+
expect(hubLine).toMatch(/\bactive\b/);
|
|
739
|
+
expect(lines.join("\n")).not.toContain("LOOPBACK HIJACK");
|
|
740
|
+
} finally {
|
|
741
|
+
cleanup();
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test("no self-probe verdict on disk → no override (default read returns undefined)", async () => {
|
|
746
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
747
|
+
try {
|
|
748
|
+
const lines: string[] = [];
|
|
749
|
+
// No readInstanceState override + no file on disk → the default reader
|
|
750
|
+
// returns undefined and the row is unchanged.
|
|
751
|
+
const code = await status({
|
|
752
|
+
...supervisorOpts(configDir, path, {
|
|
753
|
+
managerState: { state: "active" },
|
|
754
|
+
hubHealthy: true,
|
|
755
|
+
moduleStates: { supervisorAvailable: true, modules: [] },
|
|
756
|
+
}),
|
|
757
|
+
print: (l) => lines.push(l),
|
|
758
|
+
});
|
|
759
|
+
expect(code).toBe(0);
|
|
760
|
+
expect(lines.join("\n")).not.toContain("LOOPBACK HIJACK");
|
|
761
|
+
} finally {
|
|
762
|
+
cleanup();
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
});
|
package/src/admin-connections.ts
CHANGED
|
@@ -1797,8 +1797,12 @@ function sessionUser(req: Request, deps: ConnectionsDeps): { userId: string } {
|
|
|
1797
1797
|
* the rule found this engine minting ~90-day tokens with no registry row — an
|
|
1798
1798
|
* unrevocable-by-construction credential (`api-revoke-token` 404s unknown
|
|
1799
1799
|
* jtis; the revocation list only carries registered jtis).
|
|
1800
|
+
*
|
|
1801
|
+
* Exported so other unregistered-by-policy mint sites (git-notify.ts's
|
|
1802
|
+
* fire-and-forget notify/pull tokens) can statically assert their TTLs stay
|
|
1803
|
+
* under this policy line instead of restating `600` in a comment.
|
|
1800
1804
|
*/
|
|
1801
|
-
const REGISTERED_MINT_TTL_THRESHOLD_SECONDS = 10 * 60;
|
|
1805
|
+
export const REGISTERED_MINT_TTL_THRESHOLD_SECONDS = 10 * 60;
|
|
1802
1806
|
|
|
1803
1807
|
interface MintSpec {
|
|
1804
1808
|
scopes: string[];
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Why this exists (2026-06-09 modular-UI architecture, P3): modules now own
|
|
6
6
|
* their config/admin UIs and declare `configUiUrl` in `module.json` (scribe
|
|
7
|
-
* `/scribe/admin`,
|
|
7
|
+
* `/scribe/admin`, surface `/surface/admin/`, …). The
|
|
8
8
|
* hub frames/links those surfaces consistently (the Modules page "Configure"
|
|
9
9
|
* action). Each module-owned config UI, served behind the hub proxy to a
|
|
10
10
|
* logged-in portal operator, needs an admin-scoped hub Bearer to call its own
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Scope + audience: `<short>:admin`, audience = `<short>` (the bare service
|
|
18
18
|
* prefix). Modules validate the JWT's `aud` against their literal short name
|
|
19
|
-
* (`scribe`, `
|
|
19
|
+
* (`scribe`, `surface`, `agent`) — the same shape `inferAudience`
|
|
20
20
|
* stamps for the public OAuth flow, so a hub-minted and an OAuth-minted admin
|
|
21
21
|
* token are indistinguishable to the module. This mirrors the per-request
|
|
22
22
|
* `<short>:admin` proxy token `api-modules-config.ts` used to mint; the
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -354,13 +354,13 @@ async function authorize(req: Request, deps: ApiModulesOpsDeps): Promise<Respons
|
|
|
354
354
|
* reach the same spec the API handlers use without duplicating the
|
|
355
355
|
* curated-table lookup.
|
|
356
356
|
*
|
|
357
|
-
* Two source paths (post-FALLBACK-retirement: vault/scribe
|
|
357
|
+
* Two source paths (post-FALLBACK-retirement: vault/scribe in
|
|
358
358
|
* hub#310, channel in boundary D3):
|
|
359
359
|
*
|
|
360
360
|
* - **FIRST_PARTY_FALLBACKS** (notes): vendored manifest is
|
|
361
361
|
* authoritative pre-install — the embedded `manifest.startCmd` /
|
|
362
362
|
* `manifest.paths` / etc. drive the install + spawn flow.
|
|
363
|
-
* - **KNOWN_MODULES** (vault / scribe /
|
|
363
|
+
* - **KNOWN_MODULES** (vault / scribe / agent / surface): no
|
|
364
364
|
* vendored manifest.
|
|
365
365
|
* Pre-install we know only the npm package + manifestName + canonical
|
|
366
366
|
* port + imperative `extras` (init, postInstallFooter, urlForEntry,
|
|
@@ -831,7 +831,7 @@ export async function runInstall(
|
|
|
831
831
|
});
|
|
832
832
|
}
|
|
833
833
|
|
|
834
|
-
// KNOWN_MODULES shorts (vault / scribe /
|
|
834
|
+
// KNOWN_MODULES shorts (vault / scribe / agent / surface):
|
|
835
835
|
// module.json is the canonical source for startCmd. Re-resolve the spec
|
|
836
836
|
// from `<installDir>/.parachute/module.json` when installDir is stamped so
|
|
837
837
|
// the module is authoritative for its own spawn cmd. Falls back to the
|
package/src/api-modules.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* `focus` ("core" | "experimental" | "deprecated") comes from each module's
|
|
19
19
|
* `module.json` when declared, else `focusForShort`'s default map. The SPA
|
|
20
20
|
* groups core first, de-emphasizes experimental, and de-emphasizes
|
|
21
|
-
* `deprecated` (notes-daemon
|
|
21
|
+
* `deprecated` (notes-daemon) further — it NEVER hides an installed
|
|
22
22
|
* module (so an existing operator can still manage / uninstall a deprecated
|
|
23
23
|
* one). This is what makes a running, self-registered module (channel) visible
|
|
24
24
|
* + installable; the old `CURATED_MODULES = ["vault","scribe"]` whitelist made
|
|
@@ -67,7 +67,7 @@ import {
|
|
|
67
67
|
} from "./service-spec.ts";
|
|
68
68
|
// `FIRST_PARTY_FALLBACKS` and `KNOWN_MODULES` are both consulted by
|
|
69
69
|
// `lookupModule` below — the former for notes (vendored manifest still
|
|
70
|
-
// required) and the latter for vault/scribe/
|
|
70
|
+
// required) and the latter for vault/scribe/agent/surface (post-FALLBACK
|
|
71
71
|
// retirement, hub#310). The local helper hides the split from the rest of
|
|
72
72
|
// this file. `discoverableShorts` enumerates their UNION — the
|
|
73
73
|
// self-registration-driven discovery surface that replaced the old
|
|
@@ -83,7 +83,7 @@ import type { ModuleStartError, ModuleState, Supervisor } from "./supervisor.ts"
|
|
|
83
83
|
/**
|
|
84
84
|
* Resolve a known module to the display + install bootstrap data the admin SPA
|
|
85
85
|
* renders. Reads from FIRST_PARTY_FALLBACKS (notes) first,
|
|
86
|
-
* KNOWN_MODULES (vault / scribe /
|
|
86
|
+
* KNOWN_MODULES (vault / scribe / agent / surface) second.
|
|
87
87
|
*
|
|
88
88
|
* Returns `undefined` if the short is in neither table — a genuinely
|
|
89
89
|
* third-party module discovered only via services.json / the supervisor. The
|
|
@@ -249,10 +249,10 @@ interface ModuleWireShape {
|
|
|
249
249
|
* Discovery tier (2026-06-09 modular-UI architecture; `deprecated` added
|
|
250
250
|
* 2026-06-25). `core` modules render in the headline group; `experimental`
|
|
251
251
|
* modules render in a de-emphasized "Experimental" group; `deprecated`
|
|
252
|
-
* modules (notes-daemon
|
|
252
|
+
* modules (notes-daemon) render in a further-de-emphasized
|
|
253
253
|
* "Deprecated" group — never hidden when installed. Resolved from the
|
|
254
254
|
* module's `module.json` `focus` when declared, else `focusForShort`'s
|
|
255
|
-
* default map (vault/scribe/hub/surface → core, notes
|
|
255
|
+
* default map (vault/scribe/hub/surface → core, notes → deprecated,
|
|
256
256
|
* others → experimental).
|
|
257
257
|
*/
|
|
258
258
|
focus: ModuleFocus;
|
|
@@ -269,7 +269,7 @@ interface ModuleWireShape {
|
|
|
269
269
|
* The fresh-install OFFER (2026-06-25): whether the hub presents this module
|
|
270
270
|
* in the "available to install fresh" set. `available && focus !==
|
|
271
271
|
* "deprecated"`. The SPA's "Install a module" catalog filters on this so
|
|
272
|
-
* notes-daemon
|
|
272
|
+
* notes-daemon isn't pushed on a fresh box; an already-installed
|
|
273
273
|
* deprecated module still surfaces (in the Installed section) for management.
|
|
274
274
|
*/
|
|
275
275
|
available_to_install: boolean;
|
|
@@ -315,8 +315,8 @@ interface ModuleWireShape {
|
|
|
315
315
|
install_dir: string | null;
|
|
316
316
|
/**
|
|
317
317
|
* Hierarchical sub-units beneath this module (hub#313). Empty when the
|
|
318
|
-
* module's services.json row doesn't declare `uis` (vault, scribe, notes
|
|
319
|
-
*
|
|
318
|
+
* module's services.json row doesn't declare `uis` (vault, scribe, notes
|
|
319
|
+
* today). Used by parachute-app to surface each hosted UI as its
|
|
320
320
|
* own discoverable sub-row under the App module. Per parachute-app
|
|
321
321
|
* design doc §12.
|
|
322
322
|
*/
|
|
@@ -336,8 +336,8 @@ interface ModuleWireShape {
|
|
|
336
336
|
* the operator UI (App today).
|
|
337
337
|
* 3. Null when the module hasn't declared either field — the SPA
|
|
338
338
|
* renders a disabled "Open" tooltip (pointing at Configure when
|
|
339
|
-
* `config_ui_url` is set —
|
|
340
|
-
*
|
|
339
|
+
* `config_ui_url` is set — or "module hasn't shipped an admin UI
|
|
340
|
+
* yet" otherwise).
|
|
341
341
|
*
|
|
342
342
|
* Always an absolute path on the hub origin (leading `/`) — the SPA
|
|
343
343
|
* navigates same-origin, no need to worry about cross-origin
|
|
@@ -660,7 +660,7 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
660
660
|
// module's manifest-declared `focus` (when installed + declared) wins; else
|
|
661
661
|
// the `focusForShort` default map. Sort: `core` group first (with the
|
|
662
662
|
// CURATED_MODULES recommended-install order floated to the top of that
|
|
663
|
-
// group), then `experimental`, then `deprecated` (notes
|
|
663
|
+
// group), then `experimental`, then `deprecated` (notes) last — the
|
|
664
664
|
// SPA renders the groups; `focus` never hides an installed module.
|
|
665
665
|
const recommendedOrder = new Map<string, number>(
|
|
666
666
|
(CURATED_MODULES as readonly string[]).map((s, i) => [s, i]),
|
|
@@ -684,7 +684,7 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
684
684
|
// package) is not hub-installable → false.
|
|
685
685
|
available: m !== undefined,
|
|
686
686
|
// Fresh-install OFFER (2026-06-25): installable AND not deprecated. A
|
|
687
|
-
// deprecated short (notes
|
|
687
|
+
// deprecated short (notes) stays `available` (re-installable
|
|
688
688
|
// for back-compat) but is dropped from the offer set so the SPA's
|
|
689
689
|
// "Install a module" catalog doesn't push it on a fresh box.
|
|
690
690
|
available_to_install: m !== undefined && focus !== "deprecated",
|
|
@@ -867,7 +867,7 @@ let warnedLegacyVaultAdminCandidate = false;
|
|
|
867
867
|
* - `undefined` candidate → `undefined` (the module didn't declare it).
|
|
868
868
|
* - Absolute http(s) URL → returned verbatim (off-origin escape hatch).
|
|
869
869
|
* - Leading-`/` path → ORIGIN-ABSOLUTE, returned verbatim. Single-instance
|
|
870
|
-
* modules (surface, scribe,
|
|
870
|
+
* modules (surface, scribe, agent) declare their full hub-origin
|
|
871
871
|
* path this way (`/surface/admin/`, `/scribe/admin`, `/agent/admin`);
|
|
872
872
|
* vault's daemon-level surface is `/vault/admin/`.
|
|
873
873
|
* - Relative path (no leading slash) → MOUNT-JOINED: the per-instance form
|
package/src/commands/doctor.ts
CHANGED
|
@@ -53,6 +53,13 @@ import { decodeJwt } from "jose";
|
|
|
53
53
|
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
54
54
|
import { type ExposeState, readExposeState } from "../expose-state.ts";
|
|
55
55
|
import { HUB_SVC, readHubPort } from "../hub-control.ts";
|
|
56
|
+
import {
|
|
57
|
+
HIJACK_INCIDENT_REF,
|
|
58
|
+
type HubInstanceRecord,
|
|
59
|
+
type LoopbackProbe,
|
|
60
|
+
probeLoopbackInstance,
|
|
61
|
+
readHubInstanceFile,
|
|
62
|
+
} from "../hub-instance.ts";
|
|
56
63
|
import {
|
|
57
64
|
HUB_UNIT_DEFAULT_PORT,
|
|
58
65
|
type HubUnitDeps,
|
|
@@ -157,6 +164,25 @@ export interface DoctorDeps {
|
|
|
157
164
|
* readline; tests inject a canned answer.
|
|
158
165
|
*/
|
|
159
166
|
readLine?: (prompt: string) => Promise<string>;
|
|
167
|
+
/**
|
|
168
|
+
* Loopback-hijack check (hub#737): read THIS hub's on-disk identity
|
|
169
|
+
* (`hub-instance.json`, written by the running `serve`). Default
|
|
170
|
+
* {@link readHubInstanceFile}; tests inject a fixture record (or null).
|
|
171
|
+
*/
|
|
172
|
+
readInstanceRecord?: (configDir: string) => HubInstanceRecord | null;
|
|
173
|
+
/**
|
|
174
|
+
* Loopback-hijack check: probe `127.0.0.1:<port>/health` and read its
|
|
175
|
+
* `instance`. Default {@link probeLoopbackInstance}; tests inject the
|
|
176
|
+
* matched / mismatched / unreachable outcomes.
|
|
177
|
+
*/
|
|
178
|
+
probeLoopbackInstance?: (port: number) => Promise<LoopbackProbe>;
|
|
179
|
+
/**
|
|
180
|
+
* Loopback-hijack check: count LISTEN sockets on the hub port (a second
|
|
181
|
+
* listener is the OrbStack-shadow fingerprint). Default shells `lsof`;
|
|
182
|
+
* returns `undefined` when it can't determine a count (lsof absent / errored)
|
|
183
|
+
* so the check degrades to the instance comparison alone. Tests inject a count.
|
|
184
|
+
*/
|
|
185
|
+
countHubListeners?: (port: number) => number | undefined;
|
|
160
186
|
}
|
|
161
187
|
|
|
162
188
|
export interface DoctorOpts {
|
|
@@ -217,6 +243,33 @@ async function defaultProbePublicHealth(origin: string): Promise<boolean> {
|
|
|
217
243
|
}
|
|
218
244
|
}
|
|
219
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Count LISTEN sockets on `port` via `lsof`. A hijack shows TWO (this hub's
|
|
248
|
+
* wildcard bind + the shadowing process's specific loopback bind). Bounded +
|
|
249
|
+
* best-effort: returns `undefined` on any failure (lsof absent, non-zero exit,
|
|
250
|
+
* unparseable) so the check degrades to the instance-comparison signal alone
|
|
251
|
+
* rather than false-flagging. Counts DISTINCT pids across the LISTEN rows.
|
|
252
|
+
*/
|
|
253
|
+
function defaultCountHubListeners(port: number): number | undefined {
|
|
254
|
+
try {
|
|
255
|
+
const proc = Bun.spawnSync(["lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-FpP"], {
|
|
256
|
+
stdout: "pipe",
|
|
257
|
+
stderr: "ignore",
|
|
258
|
+
});
|
|
259
|
+
// lsof exits non-zero when there are zero matches — that's a real "0", not
|
|
260
|
+
// an error. Only treat a missing binary (spawn failure) as indeterminate.
|
|
261
|
+
if (proc.exitCode !== 0 && (proc.stdout?.length ?? 0) === 0) return 0;
|
|
262
|
+
const text = new TextDecoder().decode(proc.stdout ?? new Uint8Array());
|
|
263
|
+
const pids = new Set<string>();
|
|
264
|
+
for (const line of text.split("\n")) {
|
|
265
|
+
if (line.startsWith("p")) pids.add(line.slice(1));
|
|
266
|
+
}
|
|
267
|
+
return pids.size;
|
|
268
|
+
} catch {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
220
273
|
/** Both ends of the pipe must be a TTY for an interactive confirm to make sense. */
|
|
221
274
|
function defaultIsInteractive(): boolean {
|
|
222
275
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
@@ -243,6 +296,9 @@ interface ResolvedDeps {
|
|
|
243
296
|
now: () => Date;
|
|
244
297
|
isInteractive: () => boolean;
|
|
245
298
|
readLine: (prompt: string) => Promise<string>;
|
|
299
|
+
readInstanceRecord: (configDir: string) => HubInstanceRecord | null;
|
|
300
|
+
probeLoopbackInstance: (port: number) => Promise<LoopbackProbe>;
|
|
301
|
+
countHubListeners: (port: number) => number | undefined;
|
|
246
302
|
}
|
|
247
303
|
|
|
248
304
|
function resolveDeps(d: DoctorDeps | undefined): ResolvedDeps {
|
|
@@ -257,6 +313,9 @@ function resolveDeps(d: DoctorDeps | undefined): ResolvedDeps {
|
|
|
257
313
|
now: d?.now ?? (() => new Date()),
|
|
258
314
|
isInteractive: d?.isInteractive ?? defaultIsInteractive,
|
|
259
315
|
readLine: d?.readLine ?? defaultReadLine,
|
|
316
|
+
readInstanceRecord: d?.readInstanceRecord ?? readHubInstanceFile,
|
|
317
|
+
probeLoopbackInstance: d?.probeLoopbackInstance ?? probeLoopbackInstance,
|
|
318
|
+
countHubListeners: d?.countHubListeners ?? defaultCountHubListeners,
|
|
260
319
|
};
|
|
261
320
|
}
|
|
262
321
|
|
|
@@ -337,6 +396,110 @@ async function checkHubReachable(configDir: string, deps: ResolvedDeps): Promise
|
|
|
337
396
|
};
|
|
338
397
|
}
|
|
339
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Loopback-hijack detection (hub#737) — the 2026-07-02 P0's root trigger. This
|
|
401
|
+
* hub binds `*:<port>` (wildcard); a foreign process that grabs a SPECIFIC
|
|
402
|
+
* `127.0.0.1:<port>` bind (classically an OrbStack VM auto-forwarding the port)
|
|
403
|
+
* WINS all loopback traffic, so every module's JWKS/API call silently reaches
|
|
404
|
+
* the wrong hub. Detection compares THIS hub's on-disk identity nonce
|
|
405
|
+
* (`hub-instance.json`, written by `serve`) to what a loopback `/health`
|
|
406
|
+
* actually returns:
|
|
407
|
+
* - no instance file → the running hub predates nonce detection, or isn't
|
|
408
|
+
* running under `serve` (the Hub check owns "down") → PASS (benign info,
|
|
409
|
+
* never a false FAIL per #717).
|
|
410
|
+
* - loopback not answering → defer to the Hub check → PASS (info).
|
|
411
|
+
* - loopback nonce === ours → loopback reaches THIS hub. A second LISTEN on
|
|
412
|
+
* the port (lsof) is a latent shadow → WARN; a single listener → PASS.
|
|
413
|
+
* - loopback nonce ≠ ours (or missing) → ACTIVE HIJACK → FAIL with the exact
|
|
414
|
+
* lsof/orb remediation + the incident reference. Detect-only (no `--fix`).
|
|
415
|
+
* Never throws — every read is bounded + degrades to a benign verdict.
|
|
416
|
+
*/
|
|
417
|
+
async function checkLoopbackHijack(configDir: string, deps: ResolvedDeps): Promise<CheckResult> {
|
|
418
|
+
const port = readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT;
|
|
419
|
+
const title = `No loopback hijack on :${port}`;
|
|
420
|
+
|
|
421
|
+
let record: HubInstanceRecord | null = null;
|
|
422
|
+
try {
|
|
423
|
+
record = deps.readInstanceRecord(configDir);
|
|
424
|
+
} catch {
|
|
425
|
+
record = null;
|
|
426
|
+
}
|
|
427
|
+
if (!record) {
|
|
428
|
+
return {
|
|
429
|
+
name: "loopback-hijack",
|
|
430
|
+
title,
|
|
431
|
+
status: "pass",
|
|
432
|
+
detail:
|
|
433
|
+
"no hub-instance.json — the running hub predates loopback-nonce detection or isn't running under `parachute serve` (see the Hub check)",
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let probe: LoopbackProbe;
|
|
438
|
+
try {
|
|
439
|
+
probe = await deps.probeLoopbackInstance(port);
|
|
440
|
+
} catch {
|
|
441
|
+
probe = { reachable: false };
|
|
442
|
+
}
|
|
443
|
+
if (!probe.reachable) {
|
|
444
|
+
return {
|
|
445
|
+
name: "loopback-hijack",
|
|
446
|
+
title,
|
|
447
|
+
status: "pass",
|
|
448
|
+
detail: `loopback /health on 127.0.0.1:${port} didn't answer — nothing to compare (the Hub check covers a down hub)`,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Reachable but a DIFFERENT identity answers → active hijack.
|
|
453
|
+
if (probe.instance !== record.instance) {
|
|
454
|
+
let listeners: number | undefined;
|
|
455
|
+
try {
|
|
456
|
+
listeners = deps.countHubListeners(port);
|
|
457
|
+
} catch {
|
|
458
|
+
listeners = undefined;
|
|
459
|
+
}
|
|
460
|
+
const who = probe.instance
|
|
461
|
+
? `a different hub (instance ${probe.instance})`
|
|
462
|
+
: "a foreign process (its /health carries no hub instance nonce)";
|
|
463
|
+
const listenerNote =
|
|
464
|
+
listeners !== undefined && listeners > 1
|
|
465
|
+
? ` lsof shows ${listeners} listeners on the port.`
|
|
466
|
+
: "";
|
|
467
|
+
return {
|
|
468
|
+
name: "loopback-hijack",
|
|
469
|
+
title,
|
|
470
|
+
status: "fail",
|
|
471
|
+
detail: `loopback 127.0.0.1:${port} is answered by ${who}, NOT this hub (instance ${record.instance}) — module JWKS/API calls are reaching the wrong hub.${listenerNote} Incident: ${HIJACK_INCIDENT_REF}`,
|
|
472
|
+
fix: `lsof -nP -iTCP:${port} -sTCP:LISTEN # find the shadow; then \`orb list\` and stop any VM auto-forwarding ${port}`,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Loopback reaches us. A second listener is a latent shadow that could win the
|
|
477
|
+
// next reboot — WARN so the operator clears it before it flips to a FAIL.
|
|
478
|
+
let listeners: number | undefined;
|
|
479
|
+
try {
|
|
480
|
+
listeners = deps.countHubListeners(port);
|
|
481
|
+
} catch {
|
|
482
|
+
listeners = undefined;
|
|
483
|
+
}
|
|
484
|
+
if (listeners !== undefined && listeners > 1) {
|
|
485
|
+
return {
|
|
486
|
+
name: "loopback-hijack",
|
|
487
|
+
title,
|
|
488
|
+
status: "warn",
|
|
489
|
+
detail: `loopback reaches this hub, but lsof shows ${listeners} listeners on :${port} — a second bind is a latent shadow that could win loopback after a restart`,
|
|
490
|
+
fix: `lsof -nP -iTCP:${port} -sTCP:LISTEN # identify + stop the extra listener (e.g. \`orb list\`)`,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
name: "loopback-hijack",
|
|
495
|
+
title,
|
|
496
|
+
status: "pass",
|
|
497
|
+
detail: `loopback 127.0.0.1:${port}/health returns this hub's instance nonce${
|
|
498
|
+
listeners === 1 ? " (single listener)" : ""
|
|
499
|
+
}`,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
340
503
|
/**
|
|
341
504
|
* Each CONFIGURED module alive via its own loopback `/health` (2xx OR 401).
|
|
342
505
|
* Only modules present in services.json are checked — an absent module is
|
|
@@ -1017,7 +1180,8 @@ async function runChecks(
|
|
|
1017
1180
|
const hub = await checkHubReachable(configDir, deps);
|
|
1018
1181
|
const hubHealthy = hub.status === "pass";
|
|
1019
1182
|
|
|
1020
|
-
const [modules, bins, exposure] = await Promise.all([
|
|
1183
|
+
const [hijack, modules, bins, exposure] = await Promise.all([
|
|
1184
|
+
checkLoopbackHijack(configDir, deps),
|
|
1021
1185
|
checkModulesAlive(manifest, hubHealthy, deps),
|
|
1022
1186
|
checkModuleBins(manifest, deps),
|
|
1023
1187
|
checkExposure(configDir, deps),
|
|
@@ -1032,7 +1196,7 @@ async function runChecks(
|
|
|
1032
1196
|
const add = (group: Group, checks: CheckResult[]) => {
|
|
1033
1197
|
for (const c of checks) grouped.push({ ...c, group });
|
|
1034
1198
|
};
|
|
1035
|
-
add("Hub", [hub]);
|
|
1199
|
+
add("Hub", [hub, hijack]);
|
|
1036
1200
|
add("Modules", [...modules, ...bins]);
|
|
1037
1201
|
add("Configuration", [manifestCheck, portDrift, operator]);
|
|
1038
1202
|
add("Migration", migration);
|
|
@@ -1208,8 +1372,7 @@ async function fixPortDrift(
|
|
|
1208
1372
|
const canonicalByName = new Map(drifted.map((d) => [d.name, d.canonical]));
|
|
1209
1373
|
const next = {
|
|
1210
1374
|
services: parsed.services.map((row) => {
|
|
1211
|
-
const canonical =
|
|
1212
|
-
typeof row.name === "string" ? canonicalByName.get(row.name) : undefined;
|
|
1375
|
+
const canonical = typeof row.name === "string" ? canonicalByName.get(row.name) : undefined;
|
|
1213
1376
|
return canonical === undefined ? row : { ...row, port: canonical };
|
|
1214
1377
|
}),
|
|
1215
1378
|
};
|