@openparachute/hub 0.7.5 → 0.7.6-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-module-token.test.ts +40 -3
  3. package/src/__tests__/api-modules-ops.test.ts +8 -3
  4. package/src/__tests__/api-modules.test.ts +26 -18
  5. package/src/__tests__/connections-store.test.ts +84 -0
  6. package/src/__tests__/doctor.test.ts +131 -0
  7. package/src/__tests__/git-notify.test.ts +29 -1
  8. package/src/__tests__/grants-store.test.ts +33 -1
  9. package/src/__tests__/hub-instance.test.ts +297 -0
  10. package/src/__tests__/hub-server.test.ts +169 -0
  11. package/src/__tests__/install.test.ts +28 -0
  12. package/src/__tests__/serve-boot.test.ts +60 -0
  13. package/src/__tests__/service-spec-discovery.test.ts +32 -9
  14. package/src/__tests__/setup.test.ts +64 -16
  15. package/src/__tests__/stale-module-units.test.ts +1 -1
  16. package/src/__tests__/status-supervisor.test.ts +112 -0
  17. package/src/admin-connections.ts +5 -1
  18. package/src/admin-module-token.ts +2 -2
  19. package/src/api-modules-ops.ts +3 -3
  20. package/src/api-modules.ts +13 -13
  21. package/src/commands/doctor.ts +167 -4
  22. package/src/commands/install.ts +29 -3
  23. package/src/commands/migrate.ts +5 -0
  24. package/src/commands/serve.ts +52 -0
  25. package/src/commands/setup.ts +10 -9
  26. package/src/commands/status.ts +42 -1
  27. package/src/connections-store.ts +15 -2
  28. package/src/git-notify.ts +34 -5
  29. package/src/grants-store.ts +15 -2
  30. package/src/help.ts +3 -3
  31. package/src/hub-instance.ts +365 -0
  32. package/src/hub-server.ts +89 -1
  33. package/src/install-source.ts +1 -1
  34. package/src/service-spec.ts +36 -44
  35. package/src/services-manifest.ts +1 -1
  36. package/src/stale-module-units.ts +2 -2
  37. package/src/well-known.ts +3 -3
@@ -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 / runner) on a fresh install", () => {
122
+ test("does NOT offer a deprecated module (notes) on a fresh install", () => {
123
123
  expect(isOfferable({ short: "notes", installed: false })).toBe(false);
124
- expect(isOfferable({ short: "runner", installed: false })).toBe(false);
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/runner (2026-06-25)", async () => {
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 + runner
183
- // (deprecated) while keeping vault/scribe/surface/agent. Only vault +
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 runner (deprecated) on disk. It must surface in the
225
- // "Already installed" banner (so they know it's there + can manage it via
226
- // `parachute <verb> runner`), and must NOT reappear in the fresh-install
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 / runner / surface / notes / channel.
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
+ });
@@ -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`, runner `/runner/admin`, surface `/surface/admin/`, …). The
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`, `runner`, `surface`, `agent`) — the same shape `inferAudience`
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
@@ -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/runner in
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 / runner / channel / surface): no
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 / runner / channel / surface):
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
@@ -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 / runner) further — it NEVER hides an installed
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/runner/agent (post-FALLBACK
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 / runner / channel / surface) second.
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 / runner) render in a further-de-emphasized
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/runner → deprecated,
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 / runner aren't pushed on a fresh box; an already-installed
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
- * runner today). Used by parachute-app to surface each hosted UI as its
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 — runner's shape today or "module
340
- * hasn't shipped an admin UI yet" otherwise).
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 / runner) last — the
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 / runner) stays `available` (re-installable
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, runner, agent) declare their full hub-origin
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
@@ -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
  };