@openparachute/hub 0.7.6-rc.4 → 0.7.7-rc.12

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 +8 -13
  3. package/src/__tests__/account-api.test.ts +798 -0
  4. package/src/__tests__/account-session.test.ts +252 -0
  5. package/src/__tests__/account-token.test.ts +316 -0
  6. package/src/__tests__/admin-connections-credentials.test.ts +41 -0
  7. package/src/__tests__/admin-handlers.test.ts +25 -0
  8. package/src/__tests__/admin-lock.test.ts +3 -14
  9. package/src/__tests__/admin-module-token.test.ts +10 -30
  10. package/src/__tests__/admin-surfaces.test.ts +21 -0
  11. package/src/__tests__/api-hub-upgrade.test.ts +11 -0
  12. package/src/__tests__/api-mint-token.test.ts +25 -0
  13. package/src/__tests__/api-modules-ops.test.ts +34 -29
  14. package/src/__tests__/api-modules.test.ts +50 -58
  15. package/src/__tests__/api-revoke-token.test.ts +23 -0
  16. package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
  17. package/src/__tests__/api-settings-root-redirect.test.ts +12 -0
  18. package/src/__tests__/api-tokens.test.ts +44 -0
  19. package/src/__tests__/audience-gate.test.ts +24 -0
  20. package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
  21. package/src/__tests__/chrome-strip.test.ts +18 -1
  22. package/src/__tests__/doctor.test.ts +10 -17
  23. package/src/__tests__/door-contract-parity.test.ts +46 -0
  24. package/src/__tests__/hub-server.test.ts +37 -0
  25. package/src/__tests__/hub.test.ts +29 -0
  26. package/src/__tests__/install.test.ts +279 -5
  27. package/src/__tests__/migrate.test.ts +3 -1
  28. package/src/__tests__/notes-serve.test.ts +216 -0
  29. package/src/__tests__/oauth-handlers.test.ts +19 -8
  30. package/src/__tests__/operator-token.test.ts +1 -2
  31. package/src/__tests__/port-assign.test.ts +37 -17
  32. package/src/__tests__/scope-explanations.test.ts +22 -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 +372 -132
  36. package/src/__tests__/sessions.test.ts +75 -28
  37. package/src/__tests__/setup-wizard.test.ts +7 -10
  38. package/src/__tests__/setup.test.ts +13 -14
  39. package/src/__tests__/status.test.ts +0 -5
  40. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  41. package/src/account-api.ts +677 -0
  42. package/src/account-session.ts +132 -0
  43. package/src/account-token.ts +200 -0
  44. package/src/admin-connections.ts +4 -2
  45. package/src/admin-lock.ts +1 -2
  46. package/src/admin-module-token.ts +13 -8
  47. package/src/admin-surfaces.ts +2 -1
  48. package/src/api-hub-upgrade.ts +2 -1
  49. package/src/api-invites.ts +19 -0
  50. package/src/api-mint-token.ts +2 -1
  51. package/src/api-modules-ops.ts +2 -1
  52. package/src/api-modules.ts +10 -8
  53. package/src/api-revoke-token.ts +2 -1
  54. package/src/api-settings-hub-origin.ts +2 -1
  55. package/src/api-settings-root-redirect.ts +2 -1
  56. package/src/api-tokens.ts +2 -1
  57. package/src/audience-gate.ts +2 -1
  58. package/src/chrome-strip.ts +16 -4
  59. package/src/commands/install.ts +86 -8
  60. package/src/commands/migrate.ts +5 -1
  61. package/src/commands/setup.ts +9 -6
  62. package/src/help.ts +6 -6
  63. package/src/hub-server.ts +247 -52
  64. package/src/hub-settings.ts +25 -1
  65. package/src/hub.ts +64 -31
  66. package/src/invites.ts +42 -0
  67. package/src/module-ops-client.ts +2 -1
  68. package/src/notes-serve.ts +73 -31
  69. package/src/oauth-handlers.ts +1 -11
  70. package/src/operator-token.ts +0 -1
  71. package/src/origin-check.ts +2 -2
  72. package/src/scope-explanations.ts +35 -5
  73. package/src/service-spec.ts +128 -74
  74. package/src/services-manifest.ts +112 -52
  75. package/src/sessions.ts +66 -30
  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
@@ -1,7 +1,8 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { RETIRED_MODULES } from "../service-spec.ts";
5
6
  import {
6
7
  type ServiceEntry,
7
8
  ServicesManifestError,
@@ -403,9 +404,9 @@ describe("services-manifest", () => {
403
404
  slug: {
404
405
  displayName: "S",
405
406
  path: "/app/s",
406
- // biome-ignore lint/suspicious/noExplicitAny: deliberately
407
- // writing the pre-F legacy alias to pin the normalization
408
- // 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
409
410
  status: "pending-oauth" as any,
410
411
  },
411
412
  },
@@ -871,20 +872,7 @@ describe("services-manifest", () => {
871
872
  });
872
873
  });
873
874
 
874
- describe("claw agent migration", () => {
875
- // Paraclaw was renamed to parachute-agent across the ecosystem (npm
876
- // package, mount path, short name). The migration was a transitional
877
- // read-time rewrite that aliased legacy `name: "claw"` rows to
878
- // `name: "agent"` so operators on the old shape kept routing.
879
- //
880
- // History: parachute-agent (the Claude-in-containers module) was retired
881
- // 2026-05-20 (hub#334 added `agent` to RETIRED_MODULES), which briefly made
882
- // this a one-step retirement path (claw → agent → GC'd). The 2026-06-17
883
- // channel→agent rename RE-ASSIGNED `agent`/`parachute-agent` to the renamed
884
- // channel module, so those names left RETIRED_MODULES — `agent` is a live
885
- // module again. The claw → agent rewrite still runs; the migrated row now
886
- // PERSISTS (it routes to the live agent module). The tests below assert the
887
- // rewrite + the persistence.
875
+ describe("historical Agent row retirement", () => {
888
876
  const claw: ServiceEntry = {
889
877
  name: "claw",
890
878
  port: 1944,
@@ -892,70 +880,37 @@ describe("claw → agent migration", () => {
892
880
  health: "/claw/health",
893
881
  version: "0.1.0",
894
882
  };
895
- const agent: ServiceEntry = {
896
- name: "agent",
897
- port: 1944,
898
- paths: ["/agent"],
899
- health: "/agent/health",
900
- version: "0.1.0",
901
- };
902
883
 
903
- test("rewrites name + paths + health when both name=claw and paths[0]=/claw", () => {
884
+ test("strict reads retire the structural claw row immediately and persist the cleanup", () => {
904
885
  const { path, cleanup } = makeTempPath();
905
886
  try {
906
887
  writeFileSync(path, `${JSON.stringify({ services: [claw] }, null, 2)}\n`);
907
- const got = readManifest(path);
908
- // Migration ran in this read (claw agent on raw entries), then
909
- // the row was rewritten to disk. Post the 2026-06-17 channel→agent
910
- // rename, `agent` is once again a LIVE module (the renamed channel
911
- // module), so it is NO LONGER GC'd by RETIRED_MODULES — the migrated
912
- // row persists and routes to the live agent module's mount.
913
- expect(got.services).toEqual([agent]);
914
- const reread = JSON.parse(readFileSync(path, "utf8")) as {
915
- services: ServiceEntry[];
916
- };
917
- expect(reread.services[0]?.name).toBe("agent");
918
- expect(reread.services[0]?.paths).toEqual(["/agent"]);
919
- expect(reread.services[0]?.health).toBe("/agent/health");
888
+ expect(readManifest(path).services).toEqual([]);
889
+ const persisted = JSON.parse(readFileSync(path, "utf8")) as { services: ServiceEntry[] };
890
+ expect(persisted.services).toEqual([]);
920
891
  } finally {
921
892
  cleanup();
922
893
  }
923
894
  });
924
895
 
925
- test("the migrated agent row PERSISTS on the next read (agent is live again post-rename)", () => {
926
- // Pre-rename this was a one-step retirement path (claw → agent → GC'd).
927
- // After the channel→agent rename (2026-06-17) `agent`/`parachute-agent`
928
- // are re-assigned to the live module, so the row is NOT dropped — a
929
- // stale paraclaw row now ends up pointing at the live agent module
930
- // (harmless / arguably correct, since paraclaw was the original "agent").
896
+ test("lenient hot-path reads also hide structural claw and bare channel rows", () => {
931
897
  const { path, cleanup } = makeTempPath();
932
898
  try {
933
- writeFileSync(path, `${JSON.stringify({ services: [claw] }, null, 2)}\n`);
934
- const first = readManifest(path);
935
- expect(first.services).toEqual([agent]);
936
- const second = readManifest(path);
937
- expect(second.services).toEqual([agent]);
938
- } finally {
939
- cleanup();
940
- }
941
- });
942
-
943
- test("an already-agent entry round-trips unchanged (agent live again post-rename)", () => {
944
- // Pre-hub#334 this verified the migration was idempotent; hub#334 made it
945
- // GC the agent row (agent was retired). After the channel→agent rename
946
- // (2026-06-17) agent is live again, so the row round-trips unchanged —
947
- // back to the original idempotent behavior.
948
- const { path, cleanup } = makeTempPath();
949
- try {
950
- writeFileSync(path, `${JSON.stringify({ services: [agent] }, null, 2)}\n`);
951
- const got = readManifest(path);
952
- 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([]);
953
908
  } finally {
954
909
  cleanup();
955
910
  }
956
911
  });
957
912
 
958
- test("mixed manifest: vault and scribe are untouched, only claw migrates", () => {
913
+ test("mixed manifests retain supported rows while dropping historical Agent shapes", () => {
959
914
  const { path, cleanup } = makeTempPath();
960
915
  try {
961
916
  const scribe: ServiceEntry = {
@@ -966,38 +921,13 @@ describe("claw → agent migration", () => {
966
921
  version: "0.1.0",
967
922
  };
968
923
  writeFileSync(path, `${JSON.stringify({ services: [vault, claw, scribe] }, null, 2)}\n`);
969
- const got = readManifest(path);
970
- // First read: claw migrates to agent (retired GC didn't see `claw`
971
- // on the way in). Vault + scribe round-trip unchanged.
972
- expect(got.services).toHaveLength(3);
973
- expect(got.services[0]).toEqual(vault);
974
- expect(got.services[1]).toEqual(agent);
975
- expect(got.services[2]).toEqual(scribe);
976
- } finally {
977
- cleanup();
978
- }
979
- });
980
-
981
- test("preserves nested /claw paths when present (e.g. /claw/api)", () => {
982
- const { path, cleanup } = makeTempPath();
983
- try {
984
- const clawNested: ServiceEntry = {
985
- name: "claw",
986
- port: 1944,
987
- paths: ["/claw", "/claw/api"],
988
- health: "/claw/api/health",
989
- version: "0.1.0",
990
- };
991
- writeFileSync(path, `${JSON.stringify({ services: [clawNested] }, null, 2)}\n`);
992
- const got = readManifest(path);
993
- expect(got.services[0]?.paths).toEqual(["/agent", "/agent/api"]);
994
- expect(got.services[0]?.health).toBe("/agent/api/health");
924
+ expect(readManifest(path).services).toEqual([vault, scribe]);
995
925
  } finally {
996
926
  cleanup();
997
927
  }
998
928
  });
999
929
 
1000
- 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", () => {
1001
931
  const { path, cleanup } = makeTempPath();
1002
932
  try {
1003
933
  const oddClaw: ServiceEntry = {
@@ -1009,6 +939,7 @@ describe("claw → agent migration", () => {
1009
939
  };
1010
940
  writeFileSync(path, `${JSON.stringify({ services: [oddClaw] }, null, 2)}\n`);
1011
941
  expect(readManifest(path).services).toEqual([oddClaw]);
942
+ expect(readManifestLenient(path, { warn: () => {} }).services).toEqual([oddClaw]);
1012
943
  } finally {
1013
944
  cleanup();
1014
945
  }
@@ -1024,6 +955,14 @@ describe("claw → agent migration", () => {
1024
955
  // file so the next read is clean.
1025
956
  describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
1026
957
  test("drops the short-name row when a same-port manifestName row exists", () => {
958
+ // Fixture uses `surface` (the ACTUAL short-name twin of `parachute-surface`
959
+ // per the structural rule: b.name === `parachute-${a.name}`) rather than
960
+ // `app`. Pre-hub-parity-P5 this test used `app` here, but that only
961
+ // passed because `app` was ALSO in RETIRED_MODULES at the time — the
962
+ // unconditional retired-module GC (not this structural short-name rule)
963
+ // was doing the dropping. Now that `app`/`parachute-app` are un-retired
964
+ // (hub-parity P5, a new unrelated module claims them), this fixture is
965
+ // corrected to actually exercise `dropLegacyShortNameRows`.
1027
966
  const { path, cleanup } = makeTempPath();
1028
967
  try {
1029
968
  writeFileSync(
@@ -1038,7 +977,7 @@ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
1038
977
  version: "0.2.0",
1039
978
  },
1040
979
  {
1041
- name: "app",
980
+ name: "surface",
1042
981
  port: 1946,
1043
982
  paths: ["/surface"],
1044
983
  health: "/surface/healthz",
@@ -1195,6 +1134,9 @@ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
1195
1134
  });
1196
1135
 
1197
1136
  test("idempotent — second read leaves the cleaned file alone", () => {
1137
+ // See the fixture note on the first test in this describe block — `app`
1138
+ // is renamed to `surface` here for the same reason (un-retired 2026-07-11,
1139
+ // hub-parity P5).
1198
1140
  const { path, cleanup } = makeTempPath();
1199
1141
  try {
1200
1142
  writeFileSync(
@@ -1209,7 +1151,7 @@ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
1209
1151
  version: "0.2.0",
1210
1152
  },
1211
1153
  {
1212
- name: "app",
1154
+ name: "surface",
1213
1155
  port: 1946,
1214
1156
  paths: ["/surface"],
1215
1157
  health: "/surface/healthz",
@@ -1232,21 +1174,37 @@ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
1232
1174
 
1233
1175
  // Retired-module row cleanup (hub#334 — Aaron's actual reproducer on
1234
1176
  // 2026-05-22). His services.json originally carried a stale `agent` row at
1235
- // 1946 colliding with `parachute-app`'s canonical slot. NOTE: post the
1236
- // 2026-06-17 channel→agent rename, `agent` is a LIVE module again, so these
1237
- // fixtures use the still-retired `app` (parachute-app) name to exercise the
1238
- // same GC. The legacy-short-name de-dupe doesn't help a retired short
1239
- // isn't the short-name twin of the colliding row. The retired-module GC
1240
- // fires unconditionally on rows whose name appears in `RETIRED_MODULES`,
1241
- // regardless of port collision.
1177
+ // 1946 colliding with `parachute-app`'s canonical slot. The legacy-short-name
1178
+ // de-dupe doesn't help a retired short isn't the short-name twin of the
1179
+ // colliding row. The retired-module GC fires unconditionally on rows whose
1180
+ // name appears in `RETIRED_MODULES`, regardless of port collision.
1181
+ //
1182
+ // NOTE (2026-07-11, hub-parity P5): the original fixtures used `agent` —
1183
+ // which was a RETIRED_MODULES entry until the 2026-06-17 channel→agent
1184
+ // rename re-assigned that name to the live (renamed-from-channel) module —
1185
+ // and were later updated to use `app` (parachute-app, retired 2026-05-27).
1186
+ // `app`/`parachute-app` are now THEMSELVES un-retired (a new, unrelated
1187
+ // module — the real parachute-app super-surface — claims the name for
1188
+ // real; see service-spec.ts's RETIRED_MODULES comment). So these tests now
1189
+ // inject a throwaway synthetic retired entry via a describe-scoped
1190
+ // beforeEach/afterEach rather than depending on production RETIRED_MODULES
1191
+ // having a spare non-live name to spend on test fixtures — this mechanism
1192
+ // has now been renamed out from under these tests twice.
1242
1193
  describe("retired-module row de-dupe (hub#334)", () => {
1243
- // NOTE: the original fixtures used `agent` — which was a RETIRED_MODULES
1244
- // entry until the 2026-06-17 channel→agent rename re-assigned that name to
1245
- // the live (renamed-from-channel) module. These tests now use `app`
1246
- // (parachute-app, retired 2026-05-27 — still in RETIRED_MODULES) so they
1247
- // keep exercising the retired-module GC mechanism on a name that is still
1248
- // genuinely retired.
1249
- test("drops a row whose name is in RETIRED_MODULES (app)", () => {
1194
+ const RETIRED_FIXTURE_NAME = "legacy-test-retired-module";
1195
+
1196
+ beforeEach(() => {
1197
+ RETIRED_MODULES[RETIRED_FIXTURE_NAME] = {
1198
+ retiredAt: "2020-01-01",
1199
+ replacement: "n/a — test-only fixture, not a real retirement",
1200
+ };
1201
+ });
1202
+
1203
+ afterEach(() => {
1204
+ delete RETIRED_MODULES[RETIRED_FIXTURE_NAME];
1205
+ });
1206
+
1207
+ test("drops a row whose name is in RETIRED_MODULES", () => {
1250
1208
  const { path, cleanup } = makeTempPath();
1251
1209
  try {
1252
1210
  writeFileSync(
@@ -1254,10 +1212,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
1254
1212
  JSON.stringify({
1255
1213
  services: [
1256
1214
  {
1257
- name: "app",
1215
+ name: RETIRED_FIXTURE_NAME,
1258
1216
  port: 1946,
1259
- paths: ["/app"],
1260
- health: "/app/health",
1217
+ paths: ["/legacy"],
1218
+ health: "/legacy/health",
1261
1219
  version: "0.1.4",
1262
1220
  },
1263
1221
  ],
@@ -1275,7 +1233,7 @@ describe("retired-module row de-dupe (hub#334)", () => {
1275
1233
 
1276
1234
  test("retirement is unconditional — no other rows required", () => {
1277
1235
  // Verifies dropRetiredModuleRows doesn't depend on a collision
1278
- // partner (unlike dropLegacyShortNameRows). An app row sitting
1236
+ // partner (unlike dropLegacyShortNameRows). A retired row sitting
1279
1237
  // alone is still stale.
1280
1238
  const { path, cleanup } = makeTempPath();
1281
1239
  try {
@@ -1284,10 +1242,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
1284
1242
  JSON.stringify({
1285
1243
  services: [
1286
1244
  {
1287
- name: "app",
1245
+ name: RETIRED_FIXTURE_NAME,
1288
1246
  port: 9999,
1289
- paths: ["/app"],
1290
- health: "/app/health",
1247
+ paths: ["/legacy"],
1248
+ health: "/legacy/health",
1291
1249
  version: "0.1.4",
1292
1250
  },
1293
1251
  ],
@@ -1329,10 +1287,9 @@ describe("retired-module row de-dupe (hub#334)", () => {
1329
1287
  });
1330
1288
 
1331
1289
  test("Aaron's reproducer — retired row + parachute-surface at same port resolves cleanly", () => {
1332
- // The motivating bug for hub#334 (originally an `agent` row; `agent` is
1333
- // a live module again post-rename, so this uses the still-retired `app`).
1334
- // With dropRetiredModuleRows running before validateManifest, the stale
1335
- // retired row is GC'd and the duplicate-port gate doesn't trip downstream.
1290
+ // The motivating bug for hub#334. With dropRetiredModuleRows running
1291
+ // before validateManifest, the stale retired row is GC'd and the
1292
+ // duplicate-port gate doesn't trip downstream.
1336
1293
  const { path, cleanup } = makeTempPath();
1337
1294
  try {
1338
1295
  writeFileSync(
@@ -1340,10 +1297,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
1340
1297
  JSON.stringify({
1341
1298
  services: [
1342
1299
  {
1343
- name: "app",
1300
+ name: RETIRED_FIXTURE_NAME,
1344
1301
  port: 1946,
1345
- paths: ["/app"],
1346
- health: "/app/health",
1302
+ paths: ["/legacy"],
1303
+ health: "/legacy/health",
1347
1304
  version: "0.1.4",
1348
1305
  },
1349
1306
  {
@@ -1366,12 +1323,11 @@ describe("retired-module row de-dupe (hub#334)", () => {
1366
1323
 
1367
1324
  test("interaction — retired row + legacy short-name pair both cleaned, correct order", () => {
1368
1325
  // Drop order matters: retired-module cleanup runs first, then
1369
- // legacy-short-name cleanup. This test ensures both passes
1370
- // compose correctly on a services.json that exercises both
1371
- // shapes simultaneously. The `app` row is unconditional retire
1372
- // (the original `agent` fixture is a live module again post-rename);
1373
- // the parachute-runner + runner pair triggers legacy-short-name
1374
- // dedup at port 1945.
1326
+ // legacy-short-name cleanup. This test ensures both passes compose
1327
+ // correctly on a services.json that exercises both shapes
1328
+ // simultaneously. The retired-fixture row is unconditional retire; the
1329
+ // parachute-runner + runner pair triggers legacy-short-name dedup at
1330
+ // port 1945.
1375
1331
  const { path, cleanup } = makeTempPath();
1376
1332
  try {
1377
1333
  writeFileSync(
@@ -1379,10 +1335,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
1379
1335
  JSON.stringify({
1380
1336
  services: [
1381
1337
  {
1382
- name: "app",
1338
+ name: RETIRED_FIXTURE_NAME,
1383
1339
  port: 1946,
1384
- paths: ["/app"],
1385
- health: "/app/health",
1340
+ paths: ["/legacy"],
1341
+ health: "/legacy/health",
1386
1342
  version: "0.1.4",
1387
1343
  },
1388
1344
  {
@@ -1411,6 +1367,290 @@ describe("retired-module row de-dupe (hub#334)", () => {
1411
1367
  });
1412
1368
  });
1413
1369
 
1370
+ // Stale OLD-UI-host `app`/`parachute-app` row heal (hub-parity P5, B1). The
1371
+ // name `parachute-app` used to belong to the UI-host that renamed to
1372
+ // `parachute-surface` 2026-05-27; hub-parity P5 reuses the name for the NEW
1373
+ // super-surface app (mount /app, port 1944), so the RETIRED_MODULES entry that
1374
+ // used to GC the old row was removed. `healStaleUiHostAppRows` is its
1375
+ // shape-gated replacement for operators upgrading DIRECTLY from a pre-rename
1376
+ // release who still carry the stale `/surface`/1946 row. The load-bearing
1377
+ // assertion: a stale old-app row does NOT survive to spawn a crash-looping
1378
+ // unit, while the NEW app row (paths:["/app"], port 1944) is left intact.
1379
+ describe("stale OLD-UI-host app row heal (hub-parity P5, B1)", () => {
1380
+ test("heals (drops) a stale `parachute-app` row with the old /surface + 1946 shape", () => {
1381
+ const { path, cleanup } = makeTempPath();
1382
+ try {
1383
+ writeFileSync(
1384
+ path,
1385
+ JSON.stringify({
1386
+ services: [
1387
+ {
1388
+ name: "parachute-app",
1389
+ port: 1946,
1390
+ paths: ["/surface"],
1391
+ health: "/surface/healthz",
1392
+ version: "0.1.4",
1393
+ },
1394
+ ],
1395
+ }),
1396
+ );
1397
+ const m = readManifest(path);
1398
+ // The stale row is gone — it never survives to spawn a crash-looping
1399
+ // `--package @openparachute/parachute-app` unit the operator never
1400
+ // installed.
1401
+ expect(m.services).toHaveLength(0);
1402
+ // Rewritten clean on disk too.
1403
+ const onDisk = JSON.parse(readFileSync(path, "utf8"));
1404
+ expect(onDisk.services).toHaveLength(0);
1405
+ } finally {
1406
+ cleanup();
1407
+ }
1408
+ });
1409
+
1410
+ test("heals a bare-short `app` row with the old shape (the pre-rename self-register name)", () => {
1411
+ const { path, cleanup } = makeTempPath();
1412
+ try {
1413
+ writeFileSync(
1414
+ path,
1415
+ JSON.stringify({
1416
+ services: [
1417
+ {
1418
+ name: "app",
1419
+ port: 1946,
1420
+ paths: ["/surface"],
1421
+ health: "/surface/healthz",
1422
+ version: "0.1.4",
1423
+ },
1424
+ ],
1425
+ }),
1426
+ );
1427
+ const m = readManifest(path);
1428
+ expect(m.services).toHaveLength(0);
1429
+ } finally {
1430
+ cleanup();
1431
+ }
1432
+ });
1433
+
1434
+ test("heals on port 1946 even if the stale row's paths got mangled (port discriminant)", () => {
1435
+ // Corroborating OR-clause: a stale row squatting the old canonical 1946
1436
+ // is healed even if paths[0] isn't /surface-rooted anymore.
1437
+ const { path, cleanup } = makeTempPath();
1438
+ try {
1439
+ writeFileSync(
1440
+ path,
1441
+ JSON.stringify({
1442
+ services: [
1443
+ {
1444
+ name: "parachute-app",
1445
+ port: 1946,
1446
+ paths: ["/whatever"],
1447
+ health: "/whatever/health",
1448
+ version: "0.1.4",
1449
+ },
1450
+ ],
1451
+ }),
1452
+ );
1453
+ const m = readManifest(path);
1454
+ expect(m.services).toHaveLength(0);
1455
+ } finally {
1456
+ cleanup();
1457
+ }
1458
+ });
1459
+
1460
+ test("leaves the NEW app row (paths:['/app'], port 1944) intact — structurally disjoint", () => {
1461
+ const { path, cleanup } = makeTempPath();
1462
+ try {
1463
+ writeFileSync(
1464
+ path,
1465
+ JSON.stringify({
1466
+ services: [
1467
+ {
1468
+ name: "parachute-app",
1469
+ port: 1944,
1470
+ paths: ["/app"],
1471
+ health: "/app/health",
1472
+ version: "0.4.0",
1473
+ },
1474
+ ],
1475
+ }),
1476
+ );
1477
+ const m = readManifest(path);
1478
+ expect(m.services).toHaveLength(1);
1479
+ expect(m.services[0]?.name).toBe("parachute-app");
1480
+ expect(m.services[0]?.port).toBe(1944);
1481
+ expect(m.services[0]?.paths).toEqual(["/app"]);
1482
+ } finally {
1483
+ cleanup();
1484
+ }
1485
+ });
1486
+
1487
+ test("never heals a /app-mounted app row, whatever its port (new-app protection)", () => {
1488
+ // Defensive: even if a new-app row somehow drifted onto a weird port, its
1489
+ // `/app` mount alone protects it from the heal.
1490
+ const { path, cleanup } = makeTempPath();
1491
+ try {
1492
+ writeFileSync(
1493
+ path,
1494
+ JSON.stringify({
1495
+ services: [
1496
+ {
1497
+ name: "app",
1498
+ port: 1946,
1499
+ paths: ["/app"],
1500
+ health: "/app/health",
1501
+ version: "0.4.0",
1502
+ },
1503
+ ],
1504
+ }),
1505
+ );
1506
+ const m = readManifest(path);
1507
+ expect(m.services).toHaveLength(1);
1508
+ expect(m.services[0]?.paths).toEqual(["/app"]);
1509
+ } finally {
1510
+ cleanup();
1511
+ }
1512
+ });
1513
+
1514
+ test("coexistence — stale old-app + real parachute-surface both at 1946: stale healed, surface keeps /surface", () => {
1515
+ // The load-bearing coexistence case. Without the heal, the two rows collide
1516
+ // on port 1946 and trip the duplicate-port gate (or the stale row hijacks
1517
+ // /surface). With the heal running first, the stale row is dropped by shape
1518
+ // and surface survives clean.
1519
+ const { path, cleanup } = makeTempPath();
1520
+ try {
1521
+ writeFileSync(
1522
+ path,
1523
+ JSON.stringify({
1524
+ services: [
1525
+ {
1526
+ name: "parachute-app",
1527
+ port: 1946,
1528
+ paths: ["/surface"],
1529
+ health: "/surface/healthz",
1530
+ version: "0.1.4",
1531
+ },
1532
+ {
1533
+ name: "parachute-surface",
1534
+ port: 1946,
1535
+ paths: ["/surface"],
1536
+ health: "/surface/healthz",
1537
+ version: "0.2.0",
1538
+ },
1539
+ ],
1540
+ }),
1541
+ );
1542
+ const m = readManifest(path);
1543
+ expect(m.services).toHaveLength(1);
1544
+ expect(m.services[0]?.name).toBe("parachute-surface");
1545
+ expect(m.services[0]?.paths).toEqual(["/surface"]);
1546
+ } finally {
1547
+ cleanup();
1548
+ }
1549
+ });
1550
+
1551
+ test("double-write reproducer — stale `parachute-app` + `app` both at 1946/'/surface' → both healed", () => {
1552
+ // A pre-rename operator hit by the 2026-05-22 double-write carries BOTH
1553
+ // rows. The heal drops both by shape (running ahead of dropLegacyShortNameRows,
1554
+ // which would otherwise keep the `parachute-app` half — still stale).
1555
+ const { path, cleanup } = makeTempPath();
1556
+ try {
1557
+ writeFileSync(
1558
+ path,
1559
+ JSON.stringify({
1560
+ services: [
1561
+ {
1562
+ name: "parachute-app",
1563
+ port: 1946,
1564
+ paths: ["/surface"],
1565
+ health: "/surface/healthz",
1566
+ version: "0.1.4",
1567
+ },
1568
+ {
1569
+ name: "app",
1570
+ port: 1946,
1571
+ paths: ["/surface"],
1572
+ health: "/surface/healthz",
1573
+ version: "0.1.4",
1574
+ },
1575
+ ],
1576
+ }),
1577
+ );
1578
+ const m = readManifest(path);
1579
+ expect(m.services).toHaveLength(0);
1580
+ } finally {
1581
+ cleanup();
1582
+ }
1583
+ });
1584
+
1585
+ test("coexistence — NEW app (/app, 1944) + real surface (/surface, 1946): both survive", () => {
1586
+ // The steady-state post-P5 world: the new app and surface installed side by
1587
+ // side. Neither is healed; both route on their own mounts.
1588
+ const { path, cleanup } = makeTempPath();
1589
+ try {
1590
+ writeFileSync(
1591
+ path,
1592
+ JSON.stringify({
1593
+ services: [
1594
+ {
1595
+ name: "parachute-app",
1596
+ port: 1944,
1597
+ paths: ["/app"],
1598
+ health: "/app/health",
1599
+ version: "0.4.0",
1600
+ },
1601
+ {
1602
+ name: "parachute-surface",
1603
+ port: 1946,
1604
+ paths: ["/surface"],
1605
+ health: "/surface/healthz",
1606
+ version: "0.2.0",
1607
+ },
1608
+ ],
1609
+ }),
1610
+ );
1611
+ const m = readManifest(path);
1612
+ const names = m.services.map((s) => s.name).sort();
1613
+ expect(names).toEqual(["parachute-app", "parachute-surface"]);
1614
+ } finally {
1615
+ cleanup();
1616
+ }
1617
+ });
1618
+
1619
+ test("readManifestLenient heals the same stale old-app shape", () => {
1620
+ // The lenient reader shares the same raw-JSON heal pipeline — verify the
1621
+ // crash-loop row is dropped there too (the supervisor's boot path reads
1622
+ // lenient).
1623
+ const { path, cleanup } = makeTempPath();
1624
+ try {
1625
+ writeFileSync(
1626
+ path,
1627
+ JSON.stringify({
1628
+ services: [
1629
+ {
1630
+ name: "parachute-app",
1631
+ port: 1946,
1632
+ paths: ["/surface"],
1633
+ health: "/surface/healthz",
1634
+ version: "0.1.4",
1635
+ },
1636
+ {
1637
+ name: "parachute-vault",
1638
+ port: 1940,
1639
+ paths: ["/vault/default"],
1640
+ health: "/vault/default/health",
1641
+ version: "0.4.8",
1642
+ },
1643
+ ],
1644
+ }),
1645
+ );
1646
+ const m = readManifestLenient(path, { warn: () => {} });
1647
+ expect(m.services.map((s) => s.name)).toEqual(["parachute-vault"]);
1648
+ } finally {
1649
+ cleanup();
1650
+ }
1651
+ });
1652
+ });
1653
+
1414
1654
  describe("readManifestLenient — skips bad entries instead of throwing (hub#406)", () => {
1415
1655
  test("returns the healthy entries when one row has port=0 (the rc.4 app bug)", () => {
1416
1656
  // Reproduces what hub saw 2026-05-26: a fresh deploy installed