@openparachute/hub 0.7.7-rc.9 → 0.7.7

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 (78) hide show
  1. package/README.md +7 -7
  2. package/package.json +4 -12
  3. package/src/__tests__/account-api.test.ts +29 -2
  4. package/src/__tests__/account-session.test.ts +9 -1
  5. package/src/__tests__/account-token.test.ts +26 -2
  6. package/src/__tests__/admin-connections-credentials.test.ts +41 -0
  7. package/src/__tests__/admin-lock.test.ts +3 -14
  8. package/src/__tests__/admin-module-token.test.ts +10 -30
  9. package/src/__tests__/admin-surfaces.test.ts +21 -0
  10. package/src/__tests__/api-hub-upgrade.test.ts +11 -0
  11. package/src/__tests__/api-mint-token.test.ts +25 -0
  12. package/src/__tests__/api-modules-ops.test.ts +34 -29
  13. package/src/__tests__/api-modules.test.ts +50 -58
  14. package/src/__tests__/api-revoke-token.test.ts +23 -0
  15. package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
  16. package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
  17. package/src/__tests__/api-tokens.test.ts +44 -0
  18. package/src/__tests__/audience-gate.test.ts +24 -0
  19. package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
  20. package/src/__tests__/chrome-strip.test.ts +18 -1
  21. package/src/__tests__/doctor.test.ts +10 -17
  22. package/src/__tests__/hub-command.test.ts +70 -2
  23. package/src/__tests__/hub-server.test.ts +322 -1
  24. package/src/__tests__/hub-settings.test.ts +110 -6
  25. package/src/__tests__/hub.test.ts +29 -0
  26. package/src/__tests__/install.test.ts +210 -38
  27. package/src/__tests__/migrate.test.ts +3 -1
  28. package/src/__tests__/oauth-handlers.test.ts +19 -8
  29. package/src/__tests__/operator-token.test.ts +1 -2
  30. package/src/__tests__/port-assign.test.ts +17 -26
  31. package/src/__tests__/root-serve.test.ts +139 -0
  32. package/src/__tests__/scope-explanations.test.ts +0 -2
  33. package/src/__tests__/serve-boot.test.ts +25 -36
  34. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  35. package/src/__tests__/services-manifest.test.ts +22 -92
  36. package/src/__tests__/setup-wizard.test.ts +301 -22
  37. package/src/__tests__/setup.test.ts +8 -13
  38. package/src/__tests__/status.test.ts +0 -5
  39. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  40. package/src/__tests__/wizard-transcription.test.ts +35 -0
  41. package/src/__tests__/wizard.test.ts +79 -0
  42. package/src/account-api.ts +6 -0
  43. package/src/admin-connections.ts +4 -2
  44. package/src/admin-lock.ts +1 -2
  45. package/src/admin-module-token.ts +13 -8
  46. package/src/admin-surfaces.ts +2 -1
  47. package/src/api-hub-upgrade.ts +2 -1
  48. package/src/api-mint-token.ts +2 -1
  49. package/src/api-modules-ops.ts +2 -1
  50. package/src/api-modules.ts +9 -7
  51. package/src/api-revoke-token.ts +2 -1
  52. package/src/api-settings-hub-origin.ts +2 -1
  53. package/src/api-settings-root-redirect.ts +97 -20
  54. package/src/api-tokens.ts +2 -1
  55. package/src/audience-gate.ts +2 -1
  56. package/src/chrome-strip.ts +16 -4
  57. package/src/commands/hub.ts +104 -0
  58. package/src/commands/install.ts +72 -27
  59. package/src/commands/migrate.ts +5 -1
  60. package/src/commands/setup.ts +0 -2
  61. package/src/commands/wizard-transcription.ts +24 -0
  62. package/src/commands/wizard.ts +34 -1
  63. package/src/help.ts +0 -1
  64. package/src/hub-server.ts +111 -54
  65. package/src/hub-settings.ts +147 -0
  66. package/src/hub.ts +64 -31
  67. package/src/module-ops-client.ts +2 -1
  68. package/src/oauth-handlers.ts +1 -11
  69. package/src/operator-token.ts +0 -1
  70. package/src/origin-check.ts +2 -2
  71. package/src/root-serve.ts +156 -0
  72. package/src/scope-explanations.ts +2 -5
  73. package/src/service-spec.ts +18 -47
  74. package/src/services-manifest.ts +9 -42
  75. package/src/setup-wizard.ts +144 -31
  76. package/src/surface-notes-alias.ts +126 -0
  77. package/src/__tests__/admin-agent-token.test.ts +0 -173
  78. package/src/admin-agent-token.ts +0 -147
@@ -66,7 +66,7 @@ describe("mintOperatorToken", () => {
66
66
  }
67
67
  });
68
68
 
69
- test("admin scope-set includes hub:admin + parachute:host:* + vault/scribe/agent admins (#213)", () => {
69
+ test("admin scope-set includes hub:admin + parachute:host:* + vault/scribe admin scopes (#213)", () => {
70
70
  // OPERATOR_TOKEN_SCOPES === OPERATOR_TOKEN_SCOPE_SETS.admin (back-compat
71
71
  // alias). The pre-#213 set was 5 scopes; #213 added the fine-grained
72
72
  // parachute:host:install/start/expose/auth/vault scopes to the admin
@@ -81,7 +81,6 @@ describe("mintOperatorToken", () => {
81
81
  "parachute:host:vault",
82
82
  "vault:admin",
83
83
  "scribe:admin",
84
- "agent:send",
85
84
  ]);
86
85
  });
87
86
  });
@@ -44,21 +44,18 @@ describe("assignPort (pure)", () => {
44
44
  });
45
45
 
46
46
  test("walks the unassigned reservation range when canonical is occupied", () => {
47
- // 1940 is taken. 1944 is now `parachute-app`'s canonical slot (hub-parity
48
- // P5, status "assigned") so the walker skips it; the first RESERVED
49
- // (walkable) slot is 1945.
47
+ // 1940 is taken. Agent retirement released 1941 as the first walkable slot.
50
48
  const result = assignPort(1940, [1940]);
51
- expect(result.port).toBe(1945);
49
+ expect(result.port).toBe(1941);
52
50
  expect(result.source).toBe("fallback-in-range");
53
51
  expect(result.warning).toMatch(/canonical port 1940 is in use/);
54
- expect(result.warning).toMatch(/1945/);
52
+ expect(result.warning).toMatch(/1941/);
55
53
  });
56
54
 
57
55
  test("skips reservations that are also occupied", () => {
58
- // Canonical 1940 is in use. 1944 is assigned (parachute-app, skipped
59
- // regardless of `occupied`); 1945 is reserved-but-occupied; 1946 is
60
- // assigned (parachute-surface, skipped); 1947 is reserved-and-free.
61
- const result = assignPort(1940, [1940, 1944, 1945, 1946]);
56
+ // Canonical 1940 and released slot 1941 are occupied. Assigned slots are
57
+ // skipped; 1945 is reserved-but-occupied; 1947 is reserved-and-free.
58
+ const result = assignPort(1940, [1940, 1941, 1945]);
62
59
  expect(result.port).toBe(1947);
63
60
  expect(result.source).toBe("fallback-in-range");
64
61
  });
@@ -83,23 +80,19 @@ describe("assignPort (pure)", () => {
83
80
  });
84
81
 
85
82
  test("third-party (no canonical slot) jumps straight to the reservation range", () => {
86
- // 1944 is assigned (parachute-app, hub-parity P5) so the walker skips it;
87
- // the first RESERVED (walkable) slot is 1945.
83
+ // Agent retirement released 1941 as the first walkable slot.
88
84
  const result = assignPort(undefined, []);
89
- expect(result.port).toBe(1945);
85
+ expect(result.port).toBe(1941);
90
86
  expect(result.source).toBe("fallback-in-range");
91
87
  expect(result.warning).toMatch(/no canonical slot/);
92
- expect(result.warning).toMatch(/1945/);
88
+ expect(result.warning).toMatch(/1941/);
93
89
  });
94
90
 
95
91
  test("third-party with reservations occupied walks further in the range", () => {
96
- // PORT_RESERVATIONS: 1944 assigned (parachute-app), 1945 reserved, 1946
97
- // assigned (parachute-surface both carry canonical slots so the
98
- // fallback walker doesn't hand them to a third party), 1947 reserved.
99
- // With 1944 + 1945 occupied, the walker skips the assigned 1946 slot and
100
- // lands on the next reserved-and-free port — 1947.
101
- const result = assignPort(undefined, [1944, 1945]);
102
- expect(result.port).toBe(1947);
92
+ // With released 1941 occupied, the walker skips assigned 1942–1944 and
93
+ // lands on the next reserved-and-free port, 1945.
94
+ const result = assignPort(undefined, [1941]);
95
+ expect(result.port).toBe(1945);
103
96
  expect(result.source).toBe("fallback-in-range");
104
97
  });
105
98
  });
@@ -170,9 +163,8 @@ describe("assignServicePort (hub#206 — services.json is authoritative)", () =>
170
163
  canonical: 1940,
171
164
  occupied: [1940],
172
165
  });
173
- // 1944 is parachute-app's assigned (non-walkable) slot as of
174
- // hub-parity P5 — the first walkable reserved slot is 1945.
175
- expect(result.port).toBe(1945);
166
+ // Agent retirement released 1941 as the first walkable slot.
167
+ expect(result.port).toBe(1941);
176
168
  expect(result.source).toBe("fallback-in-range");
177
169
  expect(result.warning).toMatch(/canonical port 1940 is in use/);
178
170
  // .env stays bit-for-bit identical.
@@ -189,9 +181,8 @@ describe("assignServicePort (hub#206 — services.json is authoritative)", () =>
189
181
  const result = assignServicePort({
190
182
  occupied: [],
191
183
  });
192
- // 1944 is parachute-app's assigned (non-walkable) slot as of
193
- // hub-parity P5 — the first walkable reserved slot is 1945.
194
- expect(result.port).toBe(1945);
184
+ // Agent retirement released 1941 as the first walkable slot.
185
+ expect(result.port).toBe(1941);
195
186
  expect(result.source).toBe("fallback-in-range");
196
187
  expect(existsSync(envPath)).toBe(false);
197
188
  } finally {
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Tests for `src/root-serve.ts` — serving the Parachute app AT the origin root
3
+ * (`root_mode = serve-app`).
4
+ *
5
+ * Covers `serveAppAtRoot` (the static file-or-SPA-shell decision, Accept-gated,
6
+ * reserved-prefix-guarded, traversal-guarded) and `makeAppDistResolver` (success
7
+ * memoization + failure NON-caching for dynamic recovery).
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { makeAppDistResolver, serveAppAtRoot } from "../root-serve.ts";
14
+
15
+ let dist: string;
16
+ let root: string;
17
+
18
+ beforeEach(() => {
19
+ root = mkdtempSync(join(tmpdir(), "phub-root-serve-"));
20
+ dist = join(root, "dist");
21
+ mkdirSync(join(dist, "assets"), { recursive: true });
22
+ writeFileSync(join(dist, "index.html"), "<!doctype html><title>App</title>");
23
+ writeFileSync(join(dist, "assets", "index-abc.js"), "console.log('app')");
24
+ writeFileSync(join(dist, "manifest.webmanifest"), '{"name":"Parachute"}');
25
+ writeFileSync(join(dist, "sw.js"), "self.addEventListener('install',()=>{})");
26
+ });
27
+
28
+ afterEach(() => {
29
+ rmSync(root, { recursive: true, force: true });
30
+ });
31
+
32
+ function get(pathname: string, accept?: string): Request {
33
+ return new Request(`http://localhost${pathname}`, {
34
+ method: "GET",
35
+ headers: accept ? { accept } : {},
36
+ });
37
+ }
38
+
39
+ describe("serveAppAtRoot", () => {
40
+ test("serves index.html at `/`", async () => {
41
+ const res = serveAppAtRoot(dist, get("/", "text/html"), "/");
42
+ expect(res).not.toBeNull();
43
+ expect(res?.headers.get("content-type")).toContain("text/html");
44
+ expect(await res?.text()).toContain("<title>App</title>");
45
+ });
46
+
47
+ test("serves an existing asset with an inferred content-type", async () => {
48
+ const res = serveAppAtRoot(dist, get("/assets/index-abc.js", "*/*"), "/assets/index-abc.js");
49
+ expect(res).not.toBeNull();
50
+ expect(res?.headers.get("content-type") ?? "").toMatch(/javascript/);
51
+ expect(await res?.text()).toContain("console.log");
52
+ });
53
+
54
+ test("serves the PWA manifest with the application/manifest+json override", () => {
55
+ const res = serveAppAtRoot(dist, get("/manifest.webmanifest", "*/*"), "/manifest.webmanifest");
56
+ expect(res).not.toBeNull();
57
+ expect(res?.headers.get("content-type")).toBe("application/manifest+json");
58
+ });
59
+
60
+ test("serves the service worker as a real file (not the SPA shell)", async () => {
61
+ const res = serveAppAtRoot(dist, get("/sw.js", "*/*"), "/sw.js");
62
+ expect(res).not.toBeNull();
63
+ expect(await res?.text()).toContain("addEventListener");
64
+ });
65
+
66
+ test("SPA fallback: an unclaimed HTML deep link gets index.html", async () => {
67
+ const res = serveAppAtRoot(dist, get("/some/app/route", "text/html"), "/some/app/route");
68
+ expect(res).not.toBeNull();
69
+ expect(await res?.text()).toContain("<title>App</title>");
70
+ });
71
+
72
+ test("non-HTML unclaimed request → null (branded 404 tail)", () => {
73
+ expect(serveAppAtRoot(dist, get("/nope.json", "application/json"), "/nope.json")).toBeNull();
74
+ // A missing asset fetched with Accept: */* is not an HTML navigation → null.
75
+ expect(serveAppAtRoot(dist, get("/assets/missing.js", "*/*"), "/assets/missing.js")).toBeNull();
76
+ });
77
+
78
+ test("non-GET → null (a non-GET unclaimed path keeps its default)", () => {
79
+ const post = new Request("http://localhost/", {
80
+ method: "POST",
81
+ headers: { accept: "text/html" },
82
+ });
83
+ expect(serveAppAtRoot(dist, post, "/")).toBeNull();
84
+ });
85
+
86
+ test("reserved hub/protocol prefixes keep the branded 404 even for HTML nav", () => {
87
+ for (const p of ["/api/bogus", "/oauth/typo", "/.well-known/nope"]) {
88
+ expect(serveAppAtRoot(dist, get(p, "text/html"), p)).toBeNull();
89
+ }
90
+ });
91
+
92
+ test("path traversal cannot escape dist", () => {
93
+ // A crafted encoded traversal joins outside dist → falls through (null).
94
+ const p = "/assets/..%2f..%2f..%2fetc%2fpasswd";
95
+ expect(serveAppAtRoot(dist, get(p, "*/*"), p)).toBeNull();
96
+ });
97
+
98
+ test("a resolved dist whose index.html vanished → null (no broken shell)", () => {
99
+ rmSync(join(dist, "index.html"));
100
+ expect(serveAppAtRoot(dist, get("/", "text/html"), "/")).toBeNull();
101
+ });
102
+
103
+ test("malformed percent-encoding → null (no URIError thrown → branded 404, not 500)", () => {
104
+ // decodeURIComponent throws URIError on a bad escape; the guard must swallow
105
+ // it and fall through so the dispatch never 500s on a garbage asset URL.
106
+ for (const p of ["/assets/%ZZ", "/foo%", "/%E0%A4%A", "/bar%2"]) {
107
+ expect(() => serveAppAtRoot(dist, get(p, "*/*"), p)).not.toThrow();
108
+ expect(serveAppAtRoot(dist, get(p, "*/*"), p)).toBeNull();
109
+ }
110
+ // Even an HTML navigation with bad encoding falls through (redirect-mode
111
+ // parity) rather than shelling.
112
+ expect(serveAppAtRoot(dist, get("/foo%", "text/html"), "/foo%")).toBeNull();
113
+ });
114
+ });
115
+
116
+ describe("makeAppDistResolver", () => {
117
+ test("returns the resolved dist and memoizes a success", () => {
118
+ let calls = 0;
119
+ const resolve = makeAppDistResolver(() => {
120
+ calls++;
121
+ return dist;
122
+ });
123
+ expect(resolve()).toBe(dist);
124
+ expect(resolve()).toBe(dist);
125
+ expect(calls).toBe(1); // success cached — only resolved once
126
+ });
127
+
128
+ test("does NOT cache a failure — recovers once the app is installed", () => {
129
+ let installed = false;
130
+ const resolve = makeAppDistResolver(() => {
131
+ if (!installed) throw new Error("not installed");
132
+ return dist;
133
+ });
134
+ expect(resolve()).toBeNull(); // not installed yet
135
+ expect(resolve()).toBeNull(); // still probing, still null
136
+ installed = true;
137
+ expect(resolve()).toBe(dist); // picked up without a restart
138
+ });
139
+ });
@@ -19,7 +19,6 @@ describe("SCOPE_EXPLANATIONS", () => {
19
19
  "vault:admin",
20
20
  "scribe:transcribe",
21
21
  "scribe:admin",
22
- "agent:send",
23
22
  "hub:admin",
24
23
  "parachute:host:admin",
25
24
  // Account scopes (Parachute App campaign, Phase 2).
@@ -187,7 +186,6 @@ describe("isRequestableScope", () => {
187
186
  test("true for non-admin first-party scopes", () => {
188
187
  expect(isRequestableScope("vault:read")).toBe(true);
189
188
  expect(isRequestableScope("vault:admin")).toBe(true);
190
- expect(isRequestableScope("agent:send")).toBe(true);
191
189
  expect(isRequestableScope("scribe:transcribe")).toBe(true);
192
190
  });
193
191
 
@@ -395,46 +395,39 @@ describe("bootSupervisedModules", () => {
395
395
  });
396
396
  });
397
397
 
398
- // agent (then channel)#41 — a transiently-wrong (drifted) services.json port for a
399
- // fixed-port first-party module self-perpetuates: the supervisor injects PORT /
400
- // probes / proxies from that row, so the wrong port strands the module forever.
401
- // The boot path snaps it back to canonical before spawn AND persists the fix so
402
- // the reverse-proxy (which reads services.json) routes correctly.
403
- describe("reconcilePortToCanonical (channel#41 — module now agent)", () => {
398
+ // A transiently wrong services.json port for a fixed-port first-party module can
399
+ // self-perpetuate through supervisor env, probes, and proxy routing. The boot path
400
+ // snaps supported modules back to canonical and persists the fix.
401
+ describe("reconcilePortToCanonical", () => {
404
402
  let h: Harness;
405
403
  beforeEach(() => {
406
404
  h = makeHarness();
407
405
  });
408
406
  afterEach(() => h.cleanup());
409
407
 
410
- // The live-observed signature: the agent (then channel) row carried 19415 instead of its
411
- // canonical 1941.
412
- const DRIFTED_CHANNEL: ServiceEntry = {
413
- name: "parachute-agent",
408
+ const DRIFTED_SCRIBE: ServiceEntry = {
409
+ name: "parachute-scribe",
414
410
  port: 19415,
415
- paths: ["/agent"],
411
+ paths: ["/scribe"],
416
412
  health: "/health",
417
413
  version: "0.1.0",
418
414
  };
419
415
 
420
416
  test("snaps a drifted fixed-port row back to canonical + persists it", () => {
421
- writeManifest({ services: [DRIFTED_CHANNEL] }, h.manifestPath);
417
+ writeManifest({ services: [DRIFTED_SCRIBE] }, h.manifestPath);
422
418
  const logs: string[] = [];
423
419
 
424
- const reconciled = reconcilePortToCanonical(DRIFTED_CHANNEL, h.manifestPath, (l) =>
420
+ const reconciled = reconcilePortToCanonical(DRIFTED_SCRIBE, h.manifestPath, (l) =>
425
421
  logs.push(l),
426
422
  );
427
423
 
428
- // Returned entry carries canonical (agent → 1941).
429
- expect(reconciled.port).toBe(1941);
430
- // And it's PERSISTED — the proxy reads services.json, so the row itself must
431
- // now point at 1941 or `/agent/*` keeps routing to the dead 19415.
424
+ expect(reconciled.port).toBe(1943);
432
425
  const onDisk = readManifestLenient(h.manifestPath).services.find(
433
- (s) => s.name === "parachute-agent",
426
+ (s) => s.name === "parachute-scribe",
434
427
  );
435
- expect(onDisk?.port).toBe(1941);
428
+ expect(onDisk?.port).toBe(1943);
436
429
  expect(
437
- logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("1941")),
430
+ logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("1943")),
438
431
  ).toBe(true);
439
432
  });
440
433
 
@@ -460,31 +453,30 @@ describe("reconcilePortToCanonical (channel#41 — module now agent)", () => {
460
453
  });
461
454
 
462
455
  test("does NOT steal the canonical port when another row already holds it", () => {
463
- // Another row legitimately occupies 1941 reconciling would trip the
464
- // write-side duplicate-port guard and isn't the agent module's to take. Leave the
465
- // drift; the supervisor's squatter detection surfaces it.
456
+ // Another row occupies Scribe's canonical 1943, so reconciliation must not
457
+ // steal it; leave the drift for squatter diagnostics.
466
458
  const squatter: ServiceEntry = {
467
459
  name: "parachute-vault",
468
- port: 1941, // unusual, but it owns this slot right now
460
+ port: 1943,
469
461
  paths: ["/vault/default"],
470
462
  health: "/vault/default/health",
471
463
  version: "0.4.5",
472
464
  };
473
- writeManifest({ services: [squatter, DRIFTED_CHANNEL] }, h.manifestPath);
465
+ writeManifest({ services: [squatter, DRIFTED_SCRIBE] }, h.manifestPath);
474
466
  const logs: string[] = [];
475
467
 
476
- const out = reconcilePortToCanonical(DRIFTED_CHANNEL, h.manifestPath, (l) => logs.push(l));
468
+ const out = reconcilePortToCanonical(DRIFTED_SCRIBE, h.manifestPath, (l) => logs.push(l));
477
469
 
478
470
  expect(out.port).toBe(19415); // unchanged
479
471
  const onDisk = readManifestLenient(h.manifestPath).services.find(
480
- (s) => s.name === "parachute-agent",
472
+ (s) => s.name === "parachute-scribe",
481
473
  );
482
474
  expect(onDisk?.port).toBe(19415); // not rewritten
483
475
  expect(logs.some((l) => l.includes("held by another row"))).toBe(true);
484
476
  });
485
477
 
486
- test("boot path injects PORT=canonical + persists the fix for a drifted agent row", async () => {
487
- writeManifest({ services: [DRIFTED_CHANNEL] }, h.manifestPath);
478
+ test("boot path injects and persists the supported module's canonical port", async () => {
479
+ writeManifest({ services: [DRIFTED_SCRIBE] }, h.manifestPath);
488
480
  const recorder = makeRecorder();
489
481
  const sup = new Supervisor({ spawnFn: recorder.spawn });
490
482
 
@@ -493,15 +485,12 @@ describe("reconcilePortToCanonical (channel#41 — module now agent)", () => {
493
485
  configDir: h.dir,
494
486
  });
495
487
 
496
- // The supervisor child gets PORT=1941 (so it binds + the readiness probe
497
- // checks the right port), not the drifted 19415.
498
- expect(recorder.calls[0]?.short).toBe("agent");
499
- expect(recorder.calls[0]?.env?.PORT).toBe("1941");
500
- // services.json row is reconciled → proxy routes /agent/* to 1941.
488
+ expect(recorder.calls[0]?.short).toBe("scribe");
489
+ expect(recorder.calls[0]?.env?.PORT).toBe("1943");
501
490
  const onDisk = readManifestLenient(h.manifestPath).services.find(
502
- (s) => s.name === "parachute-agent",
491
+ (s) => s.name === "parachute-scribe",
503
492
  );
504
- expect(onDisk?.port).toBe(1941);
493
+ expect(onDisk?.port).toBe(1943);
505
494
  });
506
495
 
507
496
  test("boot path leaves a non-drifted vault row's port untouched", async () => {
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  FIRST_PARTY_FALLBACKS,
4
4
  KNOWN_MODULES,
5
+ PORT_RESERVATIONS,
6
+ RETIRED_MODULES,
5
7
  discoverableShorts,
6
8
  findServiceByShort,
7
9
  focusForShort,
@@ -25,20 +27,18 @@ describe("discoverableShorts", () => {
25
27
  expect(shorts.length).toBe(new Set(shorts).size);
26
28
  });
27
29
 
28
- test("includes agent (the module the whitelist used to hide) + the core set", () => {
30
+ test("includes the supported module set and excludes retired Agent", () => {
29
31
  const shorts = discoverableShorts();
30
- for (const s of ["vault", "scribe", "surface", "agent", "notes"]) {
32
+ for (const s of ["vault", "scribe", "surface", "app", "notes"]) {
31
33
  expect(shorts).toContain(s);
32
34
  }
35
+ expect(shorts).not.toContain("agent");
33
36
  });
34
37
 
35
38
  test("FIRST_PARTY_FALLBACKS shorts lead KNOWN_MODULES shorts (registry order)", () => {
36
39
  const shorts = discoverableShorts();
37
- // notes (the remaining FALLBACK agent moved to KNOWN_MODULES in
38
- // boundary D3) appears before vault (KNOWN_MODULES) in the union.
40
+ // notes (a FALLBACK) appears before vault (KNOWN_MODULES) in the union.
39
41
  expect(shorts.indexOf("notes")).toBeLessThan(shorts.indexOf("vault"));
40
- // agent rides in KNOWN_MODULES now but is still discoverable.
41
- expect(shorts).toContain("agent");
42
42
  });
43
43
  });
44
44
 
@@ -106,9 +106,30 @@ describe("runner registry removal (2026-07-01)", () => {
106
106
  });
107
107
  });
108
108
 
109
+ describe("Agent module retirement (2026-07-15)", () => {
110
+ test("is no longer known, discoverable, or resolvable", () => {
111
+ expect(isKnownModuleShort("agent")).toBe(false);
112
+ expect(discoverableShorts()).not.toContain("agent");
113
+ expect("agent" in KNOWN_MODULES).toBe(false);
114
+ expect(shortNameForManifest("parachute-agent")).toBeUndefined();
115
+ expect(shortNameForManifest("parachute-channel")).toBeUndefined();
116
+ });
117
+
118
+ test("retires every historical row name and releases port 1941", () => {
119
+ expect(RETIRED_MODULES.agent).toBeDefined();
120
+ expect(RETIRED_MODULES["parachute-agent"]).toBeDefined();
121
+ expect(RETIRED_MODULES["parachute-channel"]).toBeDefined();
122
+ expect(PORT_RESERVATIONS.find((entry) => entry.port === 1941)).toEqual({
123
+ port: 1941,
124
+ name: "unassigned",
125
+ status: "reserved",
126
+ });
127
+ });
128
+ });
129
+
109
130
  describe("isKnownModuleShort", () => {
110
131
  test("true for every known module (the install/config gate)", () => {
111
- for (const s of ["vault", "scribe", "surface", "agent", "notes"]) {
132
+ for (const s of ["vault", "scribe", "surface", "app", "notes"]) {
112
133
  expect(isKnownModuleShort(s)).toBe(true);
113
134
  }
114
135
  });
@@ -119,12 +140,6 @@ describe("isKnownModuleShort", () => {
119
140
  });
120
141
  });
121
142
 
122
- // Regression: services.json rows carry the MANIFEST name (`parachute-agent`),
123
- // not the bare short (`agent`). The connection/channels wiring used to do
124
- // `services.find((s) => s.name === "agent")`, which never matched the on-disk
125
- // row → agentOrigin null → a spurious "agent module is not installed" when
126
- // linking a vault-backed channel. findServiceByShort resolves through the
127
- // short↔manifest map so the lookup hits the real row.
128
143
  describe("findServiceByShort", () => {
129
144
  const services = [
130
145
  { name: "parachute-vault-default", port: 1940 },
@@ -132,29 +147,9 @@ describe("findServiceByShort", () => {
132
147
  { name: "parachute-scribe", port: 1943 },
133
148
  ];
134
149
 
135
- test("matches a row by its manifest name via the short↔manifest map", () => {
136
- const found = findServiceByShort(services, "agent");
137
- expect(found?.name).toBe("parachute-agent");
138
- expect(found?.port).toBe(1941);
139
- });
140
-
141
- test("the naive `name === short` comparison would have missed it (the bug)", () => {
142
- // The exact pre-fix predicate: a bare short never matches a manifest-named row.
143
- expect(services.find((s) => s.name === "agent")).toBeUndefined();
144
- // The fix finds it.
145
- expect(findServiceByShort(services, "agent")).toBeDefined();
146
- });
147
-
148
- test("resolves a legacy parachute-channel row to short `agent` (rename back-compat)", () => {
149
- // Un-upgraded operators carry a `parachute-channel` row; the
150
- // LEGACY_MANIFEST_ALIASES fallback keeps it routing to the agent module
151
- // until the daemon re-registers under `parachute-agent`.
152
- const legacy = [{ name: "parachute-channel", port: 1941 }];
153
- expect(findServiceByShort(legacy, "agent")?.port).toBe(1941);
154
- });
155
-
156
- test("resolves scribe too, and returns undefined for an absent module", () => {
150
+ test("resolves supported modules and not retired Agent rows", () => {
157
151
  expect(findServiceByShort(services, "scribe")?.port).toBe(1943);
152
+ expect(findServiceByShort(services, "agent")).toBeUndefined();
158
153
  expect(findServiceByShort(services, "runner")).toBeUndefined();
159
154
  });
160
155
  });
@@ -404,9 +404,9 @@ describe("services-manifest", () => {
404
404
  slug: {
405
405
  displayName: "S",
406
406
  path: "/app/s",
407
- // biome-ignore lint/suspicious/noExplicitAny: deliberately
408
- // writing the pre-F legacy alias to pin the normalization
409
- // boundary; the schema accepts it on read.
407
+ // Deliberately write the pre-F legacy alias to pin the
408
+ // normalization boundary; the schema accepts it on read.
409
+ // biome-ignore lint/suspicious/noExplicitAny: legacy fixture value
410
410
  status: "pending-oauth" as any,
411
411
  },
412
412
  },
@@ -872,20 +872,7 @@ describe("services-manifest", () => {
872
872
  });
873
873
  });
874
874
 
875
- describe("claw agent migration", () => {
876
- // Paraclaw was renamed to parachute-agent across the ecosystem (npm
877
- // package, mount path, short name). The migration was a transitional
878
- // read-time rewrite that aliased legacy `name: "claw"` rows to
879
- // `name: "agent"` so operators on the old shape kept routing.
880
- //
881
- // History: parachute-agent (the Claude-in-containers module) was retired
882
- // 2026-05-20 (hub#334 added `agent` to RETIRED_MODULES), which briefly made
883
- // this a one-step retirement path (claw → agent → GC'd). The 2026-06-17
884
- // channel→agent rename RE-ASSIGNED `agent`/`parachute-agent` to the renamed
885
- // channel module, so those names left RETIRED_MODULES — `agent` is a live
886
- // module again. The claw → agent rewrite still runs; the migrated row now
887
- // PERSISTS (it routes to the live agent module). The tests below assert the
888
- // rewrite + the persistence.
875
+ describe("historical Agent row retirement", () => {
889
876
  const claw: ServiceEntry = {
890
877
  name: "claw",
891
878
  port: 1944,
@@ -893,70 +880,37 @@ describe("claw → agent migration", () => {
893
880
  health: "/claw/health",
894
881
  version: "0.1.0",
895
882
  };
896
- const agent: ServiceEntry = {
897
- name: "agent",
898
- port: 1944,
899
- paths: ["/agent"],
900
- health: "/agent/health",
901
- version: "0.1.0",
902
- };
903
-
904
- test("rewrites name + paths + health when both name=claw and paths[0]=/claw", () => {
905
- const { path, cleanup } = makeTempPath();
906
- try {
907
- writeFileSync(path, `${JSON.stringify({ services: [claw] }, null, 2)}\n`);
908
- const got = readManifest(path);
909
- // Migration ran in this read (claw → agent on raw entries), then
910
- // the row was rewritten to disk. Post the 2026-06-17 channel→agent
911
- // rename, `agent` is once again a LIVE module (the renamed channel
912
- // module), so it is NO LONGER GC'd by RETIRED_MODULES — the migrated
913
- // row persists and routes to the live agent module's mount.
914
- expect(got.services).toEqual([agent]);
915
- const reread = JSON.parse(readFileSync(path, "utf8")) as {
916
- services: ServiceEntry[];
917
- };
918
- expect(reread.services[0]?.name).toBe("agent");
919
- expect(reread.services[0]?.paths).toEqual(["/agent"]);
920
- expect(reread.services[0]?.health).toBe("/agent/health");
921
- } finally {
922
- cleanup();
923
- }
924
- });
925
883
 
926
- test("the migrated agent row PERSISTS on the next read (agent is live again post-rename)", () => {
927
- // Pre-rename this was a one-step retirement path (claw → agent → GC'd).
928
- // After the channel→agent rename (2026-06-17) `agent`/`parachute-agent`
929
- // are re-assigned to the live module, so the row is NOT dropped — a
930
- // stale paraclaw row now ends up pointing at the live agent module
931
- // (harmless / arguably correct, since paraclaw was the original "agent").
884
+ test("strict reads retire the structural claw row immediately and persist the cleanup", () => {
932
885
  const { path, cleanup } = makeTempPath();
933
886
  try {
934
887
  writeFileSync(path, `${JSON.stringify({ services: [claw] }, null, 2)}\n`);
935
- const first = readManifest(path);
936
- expect(first.services).toEqual([agent]);
937
- const second = readManifest(path);
938
- expect(second.services).toEqual([agent]);
888
+ expect(readManifest(path).services).toEqual([]);
889
+ const persisted = JSON.parse(readFileSync(path, "utf8")) as { services: ServiceEntry[] };
890
+ expect(persisted.services).toEqual([]);
939
891
  } finally {
940
892
  cleanup();
941
893
  }
942
894
  });
943
895
 
944
- test("an already-agent entry round-trips unchanged (agent live again post-rename)", () => {
945
- // Pre-hub#334 this verified the migration was idempotent; hub#334 made it
946
- // GC the agent row (agent was retired). After the channel→agent rename
947
- // (2026-06-17) agent is live again, so the row round-trips unchanged —
948
- // back to the original idempotent behavior.
896
+ test("lenient hot-path reads also hide structural claw and bare channel rows", () => {
949
897
  const { path, cleanup } = makeTempPath();
950
898
  try {
951
- writeFileSync(path, `${JSON.stringify({ services: [agent] }, null, 2)}\n`);
952
- const got = readManifest(path);
953
- expect(got.services).toEqual([agent]);
899
+ const channel: ServiceEntry = {
900
+ name: "channel",
901
+ port: 1941,
902
+ paths: ["/channel"],
903
+ health: "/channel/health",
904
+ version: "0.1.0",
905
+ };
906
+ writeFileSync(path, `${JSON.stringify({ services: [claw, channel] }, null, 2)}\n`);
907
+ expect(readManifestLenient(path, { warn: () => {} }).services).toEqual([]);
954
908
  } finally {
955
909
  cleanup();
956
910
  }
957
911
  });
958
912
 
959
- test("mixed manifest: vault and scribe are untouched, only claw migrates", () => {
913
+ test("mixed manifests retain supported rows while dropping historical Agent shapes", () => {
960
914
  const { path, cleanup } = makeTempPath();
961
915
  try {
962
916
  const scribe: ServiceEntry = {
@@ -967,38 +921,13 @@ describe("claw → agent migration", () => {
967
921
  version: "0.1.0",
968
922
  };
969
923
  writeFileSync(path, `${JSON.stringify({ services: [vault, claw, scribe] }, null, 2)}\n`);
970
- const got = readManifest(path);
971
- // First read: claw migrates to agent (retired GC didn't see `claw`
972
- // on the way in). Vault + scribe round-trip unchanged.
973
- expect(got.services).toHaveLength(3);
974
- expect(got.services[0]).toEqual(vault);
975
- expect(got.services[1]).toEqual(agent);
976
- expect(got.services[2]).toEqual(scribe);
977
- } finally {
978
- cleanup();
979
- }
980
- });
981
-
982
- test("preserves nested /claw paths when present (e.g. /claw/api)", () => {
983
- const { path, cleanup } = makeTempPath();
984
- try {
985
- const clawNested: ServiceEntry = {
986
- name: "claw",
987
- port: 1944,
988
- paths: ["/claw", "/claw/api"],
989
- health: "/claw/api/health",
990
- version: "0.1.0",
991
- };
992
- writeFileSync(path, `${JSON.stringify({ services: [clawNested] }, null, 2)}\n`);
993
- const got = readManifest(path);
994
- expect(got.services[0]?.paths).toEqual(["/agent", "/agent/api"]);
995
- expect(got.services[0]?.health).toBe("/agent/api/health");
924
+ expect(readManifest(path).services).toEqual([vault, scribe]);
996
925
  } finally {
997
926
  cleanup();
998
927
  }
999
928
  });
1000
929
 
1001
- test("leaves a row alone if name is claw but mount is something else (deliberate third-party reuse)", () => {
930
+ test("leaves a row alone if name is claw but mount is something else", () => {
1002
931
  const { path, cleanup } = makeTempPath();
1003
932
  try {
1004
933
  const oddClaw: ServiceEntry = {
@@ -1010,6 +939,7 @@ describe("claw → agent migration", () => {
1010
939
  };
1011
940
  writeFileSync(path, `${JSON.stringify({ services: [oddClaw] }, null, 2)}\n`);
1012
941
  expect(readManifest(path).services).toEqual([oddClaw]);
942
+ expect(readManifestLenient(path, { warn: () => {} }).services).toEqual([oddClaw]);
1013
943
  } finally {
1014
944
  cleanup();
1015
945
  }