@openparachute/hub 0.6.3 → 0.6.4-rc.10

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 (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -52,6 +52,7 @@ import {
52
52
  synthesizeManifestForKnownModule,
53
53
  } from "./service-spec.ts";
54
54
  import { findService, readManifestLenient, removeService } from "./services-manifest.ts";
55
+ import { enrichedPath } from "./spawn-path.ts";
55
56
  import type { ModuleState, SpawnRequest, Supervisor } from "./supervisor.ts";
56
57
  import { WELL_KNOWN_PATH, type regenerateWellKnown } from "./well-known.ts";
57
58
 
@@ -383,11 +384,95 @@ async function resolveSpawnSpec(
383
384
  return composeKnownModuleSpec(km, manifest);
384
385
  }
385
386
 
387
+ /**
388
+ * Outcome of `resolveSpawnRequest`: either a freshly-built `SpawnRequest`
389
+ * (carrying CURRENT env — live `deps.issuer` → `PARACHUTE_HUB_ORIGIN`,
390
+ * enriched PATH, re-resolved `cwd`) or a structured error the HTTP handler
391
+ * can return verbatim. A discriminated union so `handleStart` /
392
+ * `handleRestart` / `runUpgrade` share the resolve-and-rebuild path
393
+ * without duplicating the not-installed / no-start-cmd error shapes.
394
+ */
395
+ type ResolveSpawnResult =
396
+ | { ok: true; req: SpawnRequest }
397
+ | { ok: false; status: number; code: string; message: string };
398
+
399
+ /**
400
+ * Build a `SpawnRequest` for an already-installed module from CURRENT state,
401
+ * identically to the serve-boot path (PORT / per-service `.env` / live
402
+ * `PARACHUTE_HUB_ORIGIN` / enriched PATH via the shared
403
+ * `buildModuleSpawnRequest`). This is the single source of truth the start,
404
+ * restart, and post-upgrade-restart paths share so each re-injects the
405
+ * current hub origin + PATH rather than replaying a stale first-start
406
+ * snapshot (hub#532). `cwd` is re-resolved from the row's `installDir` too,
407
+ * so a post-upgrade relocation is picked up.
408
+ *
409
+ * Returns a structured `{ ok: false }` for the two operator-facing
410
+ * preconditions the start endpoint already surfaced: 400 `not_installed`
411
+ * (no services.json row) and 422 `no_start_cmd` (CLI-only / unreadable
412
+ * module.json). Callers map these to the response shape their surface wants.
413
+ */
414
+ async function resolveSpawnRequest(
415
+ short: CuratedModuleShort,
416
+ deps: ApiModulesOpsDeps,
417
+ ): Promise<ResolveSpawnResult> {
418
+ const spec = specFor(short);
419
+
420
+ // The module must already be installed (present in services.json).
421
+ const entry = findService(spec.manifestName, deps.manifestPath);
422
+ if (!entry) {
423
+ return {
424
+ ok: false,
425
+ status: 400,
426
+ code: "not_installed",
427
+ message: `${short} is not installed (no services.json entry) — install it first via POST /api/modules/${short}/install`,
428
+ };
429
+ }
430
+
431
+ // KNOWN_MODULES shorts (vault / scribe / runner): module.json is the
432
+ // canonical source for startCmd. Re-resolve from
433
+ // `<installDir>/.parachute/module.json` when installDir is stamped so the
434
+ // module is authoritative for its own spawn cmd — mirroring runInstall's
435
+ // post-bun-add re-resolve. Falls back to the imperative `extras.startCmd`
436
+ // carried by `spec` when installDir is absent or module.json is unreadable.
437
+ let spawnSpec: ServiceSpec = spec;
438
+ if (entry.installDir && KNOWN_MODULES[short]) {
439
+ const resolved = await resolveSpawnSpec(short, entry.installDir);
440
+ if (resolved) spawnSpec = resolved;
441
+ }
442
+
443
+ const cmd = spawnSpec.startCmd?.(entry);
444
+ if (!cmd || cmd.length === 0) {
445
+ return {
446
+ ok: false,
447
+ status: 422,
448
+ code: "no_start_cmd",
449
+ message: `${short} has no resolvable startCmd (CLI-only module, or <installDir>/.parachute/module.json missing a startCmd)`,
450
+ };
451
+ }
452
+
453
+ // Build the SpawnRequest identically to the serve-boot path so start,
454
+ // restart, and post-upgrade-restart produce the same child env (PORT / .env
455
+ // / live HUB_ORIGIN / enriched PATH). The test-seam / first-boot `spawnEnv`
456
+ // rides the shared helper's `extraEnv` and wins last, matching
457
+ // `spawnSupervised`'s precedence.
458
+ const req = buildModuleSpawnRequest(short, entry, cmd, {
459
+ configDir: deps.configDir,
460
+ ...(deps.issuer ? { hubOrigin: deps.issuer } : {}),
461
+ ...(deps.spawnEnv ? { extraEnv: deps.spawnEnv } : {}),
462
+ });
463
+ return { ok: true, req };
464
+ }
465
+
386
466
  function defaultRun(cmd: readonly string[]): Promise<number> {
387
467
  // Inherit env so child `bun add` sees TMPDIR, BUN_INSTALL, PARACHUTE_*,
388
468
  // etc. set by the Dockerfile / Render env. Bun.spawn defaults to empty
389
469
  // env — without this, bun-add fails with cross-mount rename errors on
390
470
  // Render (where TMPDIR points at the persistent disk). See hub#349.
471
+ // PATH: intentionally the raw `process.env` (no per-call `enrichedPath()`).
472
+ // This is `bun add`, not a module spawn — it doesn't need the operator-tool
473
+ // dirs (parakeet-mlx / ffmpeg). It still benefits from the serve-startup PATH
474
+ // enrichment: `serve.ts` applies `enrichedPath()` to `process.env.PATH` at
475
+ // boot, so the PATH inherited here is already enriched under a managed hub.
391
476
  const proc = Bun.spawn([...cmd], {
392
477
  stdio: ["ignore", "inherit", "inherit"],
393
478
  env: process.env,
@@ -488,7 +573,18 @@ async function spawnSupervised(
488
573
  // operator has had a chance to write `configDir/<short>/.env`, so install
489
574
  // spawns with install-env only. The per-service `.env` is layered in by
490
575
  // `buildModuleSpawnRequest` (serve-boot.ts) on the next `boot` or `start`.
576
+ // PATH enrichment (hub launchd-PATH regression): mirror the serve-boot path's
577
+ // `buildModuleSpawnRequest` so a module STARTED from the admin SPA gets the
578
+ // same operator-tool dirs (`$HOME/.local/bin`, brew bin) as one booted from
579
+ // services.json — otherwise scribe started via /admin/modules can't find
580
+ // `parakeet-mlx` / `ffmpeg`. This is the SECOND, independently-built spawn
581
+ // env; keep it in sync with serve-boot.ts:buildModuleSpawnRequest. `spawnEnv`
582
+ // (test seam) still wins via the spread below. See `spawn-path.ts`.
583
+ // `process.env.PATH` may ALREADY be enriched by serve startup (serve.ts);
584
+ // re-enriching here is a harmless no-op — `enrichedPath` is idempotent
585
+ // (dedupe + append-only), so double-enrichment can't duplicate or reorder.
491
586
  const childEnv: Record<string, string> = {
587
+ PATH: enrichedPath(),
492
588
  PORT: String(entry.port),
493
589
  ...(deps.issuer ? { PARACHUTE_HUB_ORIGIN: deps.issuer } : {}),
494
590
  ...(deps.spawnEnv ?? {}),
@@ -775,56 +871,40 @@ export async function handleStart(
775
871
  const authFail = await authorize(req, deps);
776
872
  if (authFail) return authFail;
777
873
 
778
- const spec = specFor(short);
779
-
780
- // Pure-spawn precondition: the module must already be installed
781
- // (present in services.json). `start` never installs — that's the
782
- // install endpoint's job, which is far heavier (bun add -g / seed /
783
- // stamp). A missing row is an operator error worth a clear message.
784
- const entry = findService(spec.manifestName, deps.manifestPath);
785
- if (!entry) {
786
- return jsonError(
787
- 400,
788
- "not_installed",
789
- `${short} is not installed (no services.json entry) — install it first via POST /api/modules/${short}/install`,
790
- );
791
- }
792
-
793
- // KNOWN_MODULES shorts (vault / scribe / runner): module.json is the
794
- // canonical source for startCmd. Re-resolve from
795
- // `<installDir>/.parachute/module.json` when installDir is stamped so the
796
- // module is authoritative for its own spawn cmd mirroring runInstall's
797
- // post-bun-add re-resolve. Falls back to the imperative `extras.startCmd`
798
- // carried by `spec` when installDir is absent or module.json is unreadable.
799
- let spawnSpec: ServiceSpec = spec;
800
- if (entry.installDir && KNOWN_MODULES[short]) {
801
- const resolved = await resolveSpawnSpec(short, entry.installDir);
802
- if (resolved) spawnSpec = resolved;
803
- }
804
-
805
- const cmd = spawnSpec.startCmd?.(entry);
806
- if (!cmd || cmd.length === 0) {
807
- return jsonError(
808
- 422,
809
- "no_start_cmd",
810
- `${short} has no resolvable startCmd (CLI-only module, or <installDir>/.parachute/module.json missing a startCmd)`,
811
- );
874
+ // Build the SpawnRequest from CURRENT state (live issuer / PATH / .env /
875
+ // re-resolved startCmd), shared with the restart + post-upgrade-restart
876
+ // paths so each spawn carries the same fresh env (hub#532). The two
877
+ // operator-facing preconditions 400 `not_installed` (no services.json
878
+ // row; `start` never installs, that's the heavier install endpoint's job)
879
+ // and 422 `no_start_cmd` (CLI-only / unreadable module.json) surface as
880
+ // structured errors here.
881
+ const resolved = await resolveSpawnRequest(short, deps);
882
+ if (!resolved.ok) return jsonError(resolved.status, resolved.code, resolved.message);
883
+ const spawnReq = resolved.req;
884
+
885
+ let state: Awaited<ReturnType<typeof deps.supervisor.start>>;
886
+ try {
887
+ state = await deps.supervisor.start(spawnReq);
888
+ } catch (err) {
889
+ // A spawn-level throw (e.g. Bun.spawn ENOENT because the module's
890
+ // installDir/cwd no longer exists the hub#536 wedge) used to escape the
891
+ // handler as a naked 500 with no JSON body; the CLI then surfaced an
892
+ // opaque "✗ <short>: request failed" with no actionable next step.
893
+ // Return the real reason instead.
894
+ return moduleOpFailure(short, "start", err);
812
895
  }
813
-
814
- // Build the SpawnRequest identically to the serve-boot path so `start`
815
- // and boot produce the same child env (PORT / .env / HUB_ORIGIN). The
816
- // test-seam / first-boot `spawnEnv` rides the shared helper's `extraEnv`
817
- // and wins last, matching `spawnSupervised`'s precedence.
818
- const spawnReq = buildModuleSpawnRequest(short, entry, cmd, {
819
- configDir: deps.configDir,
820
- ...(deps.issuer ? { hubOrigin: deps.issuer } : {}),
821
- ...(deps.spawnEnv ? { extraEnv: deps.spawnEnv } : {}),
822
- });
823
-
824
- const state = await deps.supervisor.start(spawnReq);
825
896
  return jsonOk({ short, state });
826
897
  }
827
898
 
899
+ /**
900
+ * Map a thrown supervisor-op failure to a structured 500 so the CLI/SPA can
901
+ * surface the real reason instead of an opaque "request failed" (hub#536).
902
+ */
903
+ function moduleOpFailure(short: string, op: string, err: unknown): Response {
904
+ const msg = err instanceof Error ? err.message : String(err);
905
+ return jsonError(500, "module_op_failed", `${short} ${op} failed: ${msg}`);
906
+ }
907
+
828
908
  /**
829
909
  * POST /api/modules/:short/stop — synchronous.
830
910
  *
@@ -847,7 +927,12 @@ export async function handleStop(
847
927
  const authFail = await authorize(req, deps);
848
928
  if (authFail) return authFail;
849
929
 
850
- const state = await deps.supervisor.stop(short);
930
+ let state: Awaited<ReturnType<typeof deps.supervisor.stop>>;
931
+ try {
932
+ state = await deps.supervisor.stop(short);
933
+ } catch (err) {
934
+ return moduleOpFailure(short, "stop", err);
935
+ }
851
936
  if (!state) {
852
937
  return jsonOk({ short, stopped: false });
853
938
  }
@@ -857,10 +942,19 @@ export async function handleStop(
857
942
  /**
858
943
  * POST /api/modules/:short/restart — synchronous.
859
944
  *
860
- * Routes through `supervisor.restart(short)` which does stop await
861
- * exit start with the same SpawnRequest. Returns the new state in
862
- * the body the UI's spinner can clear as soon as the response
863
- * arrives, no operation poll needed.
945
+ * Rebuilds the SpawnRequest from CURRENT state (live `deps.issuer` →
946
+ * `PARACHUTE_HUB_ORIGIN`, enriched PATH, re-resolved cwd) identically to
947
+ * `handleStart` via the shared `resolveSpawnRequest` and hands it to
948
+ * `supervisor.restart(short, req)` so the re-spawn re-injects the current
949
+ * hub origin (hub#532). Before this, restart replayed the env captured at
950
+ * FIRST start, so an admin-UI restart / `parachute restart <svc>` never
951
+ * picked up a corrected hub origin (or, since #546, the enriched PATH) —
952
+ * only `start` and the `expose up` self-heal rebuilt env. The refreshed req
953
+ * also becomes the supervisor entry's `req`, so subsequent crash-restarts
954
+ * carry the current env too.
955
+ *
956
+ * Returns the new state in the body — the UI's spinner can clear as soon as
957
+ * the response arrives, no operation poll needed.
864
958
  */
865
959
  export async function handleRestart(
866
960
  req: Request,
@@ -871,7 +965,19 @@ export async function handleRestart(
871
965
  const authFail = await authorize(req, deps);
872
966
  if (authFail) return authFail;
873
967
 
874
- const state = await deps.supervisor.restart(short);
968
+ // Rebuild the spawn env from current state. A `not_installed` row is a 400
969
+ // (same as `start`); `no_start_cmd` a 422. A module that's installed but
970
+ // not currently supervised falls through to the 404 `not_supervised` below
971
+ // (the supervisor returns `undefined`).
972
+ const resolved = await resolveSpawnRequest(short, deps);
973
+ if (!resolved.ok) return jsonError(resolved.status, resolved.code, resolved.message);
974
+
975
+ let state: Awaited<ReturnType<typeof deps.supervisor.restart>>;
976
+ try {
977
+ state = await deps.supervisor.restart(short, resolved.req);
978
+ } catch (err) {
979
+ return moduleOpFailure(short, "restart", err);
980
+ }
875
981
  if (!state) {
876
982
  return jsonError(
877
983
  404,
@@ -1057,7 +1163,16 @@ async function runUpgrade(
1057
1163
  });
1058
1164
  }
1059
1165
 
1060
- const state = await deps.supervisor.restart(short);
1166
+ // Rebuild the spawn req from post-upgrade state before restarting: a major
1167
+ // bump may have relocated installDir (re-stamped above), so the re-spawn's
1168
+ // cwd must track it, and the restart should carry the live hub origin /
1169
+ // enriched PATH like start does (hub#532). Fall back to a plain replay
1170
+ // restart if the row can't be resolved into a fresh req (shouldn't happen
1171
+ // mid-upgrade, but a missing startCmd shouldn't wedge the upgrade op).
1172
+ const resolved = await resolveSpawnRequest(short, deps);
1173
+ const state = resolved.ok
1174
+ ? await deps.supervisor.restart(short, resolved.req)
1175
+ : await deps.supervisor.restart(short);
1061
1176
  if (!state) {
1062
1177
  registry.update(
1063
1178
  opId,
@@ -243,8 +243,32 @@ interface ModuleWireShape {
243
243
  management_url: string | null;
244
244
  }
245
245
 
246
+ /**
247
+ * Per-module supervisor snapshot for the `supervised` array (hub#539). The
248
+ * supervisor-derived subset of `ModuleWireShape` — enough for `status` to
249
+ * render a run-state row for a module that isn't in the curated catalog.
250
+ */
251
+ interface SupervisedSnapshotWire {
252
+ short: string;
253
+ installed: boolean;
254
+ installed_version: string | null;
255
+ supervisor_status: ModuleState["status"] | null;
256
+ pid: number | null;
257
+ supervisor_start_error: ModuleStartError | null;
258
+ }
259
+
246
260
  interface ModulesResponse {
247
261
  modules: ModuleWireShape[];
262
+ /**
263
+ * Run-state for EVERY module the supervisor is currently tracking — not just
264
+ * the curated `modules` (vault/scribe). Non-curated supervised modules (e.g.
265
+ * the `surface` UI host) appear here so `parachute status` / the SPA can
266
+ * reflect their real run-state instead of mislabelling them `inactive`
267
+ * because they're absent from the curated catalog (hub#539). Curated modules
268
+ * also appear here (harmless — consumers dedupe by `short`, preferring the
269
+ * richer `modules` entry). Same supervisor-field shape as a `modules` entry.
270
+ */
271
+ supervised: SupervisedSnapshotWire[];
248
272
  /**
249
273
  * Whether the supervisor is wired into this hub. `false` under
250
274
  * `parachute expose` / on-box CLI; the UI greys out install/start
@@ -522,8 +546,20 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
522
546
  });
523
547
  }
524
548
 
549
+ // Every supervised module's run-state — curated AND non-curated (hub#539).
550
+ // Built from the same supervisor.list() snapshot already in `stateByShort`.
551
+ const supervised: SupervisedSnapshotWire[] = Array.from(stateByShort.values()).map((s) => ({
552
+ short: s.short,
553
+ installed: installedByShort.has(s.short),
554
+ installed_version: installedByShort.get(s.short)?.version ?? null,
555
+ supervisor_status: s.status,
556
+ pid: s.pid ?? null,
557
+ supervisor_start_error: s.startError ?? null,
558
+ }));
559
+
525
560
  const body: ModulesResponse = {
526
561
  modules,
562
+ supervised,
527
563
  supervisor_available: supervisor !== undefined,
528
564
  module_install_channel: getModuleInstallChannel(deps.db),
529
565
  };
package/src/auto-wire.ts CHANGED
@@ -99,6 +99,26 @@ function writeScribeConfig(path: string, token: string): void {
99
99
  renameSync(tmp, path);
100
100
  }
101
101
 
102
+ /**
103
+ * Read scribe's current `auth.required_token` from `config.json`, or undefined
104
+ * when the file is absent / malformed / has no auth token. Used by the
105
+ * serve-boot self-heal to decide whether scribe's config is already in sync
106
+ * with vault's `.env`.
107
+ */
108
+ function readScribeAuthToken(path: string): string | undefined {
109
+ if (!existsSync(path)) return undefined;
110
+ try {
111
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
112
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
113
+ const auth = (parsed as Record<string, unknown>).auth;
114
+ if (!auth || typeof auth !== "object" || Array.isArray(auth)) return undefined;
115
+ const token = (auth as Record<string, unknown>).required_token;
116
+ return typeof token === "string" && token.length > 0 ? token : undefined;
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ }
121
+
102
122
  /**
103
123
  * Mint (or preserve) a shared secret and persist it to vault and scribe, plus
104
124
  * pin SCRIBE_URL on vault's side. Caller has already confirmed both services
@@ -182,3 +202,70 @@ export async function autoWireScribeAuth(opts: AutoWireOpts): Promise<AutoWireRe
182
202
  restartedVault,
183
203
  };
184
204
  }
205
+
206
+ export interface SelfHealScribeAuthResult {
207
+ /** True when scribe's config.json was written this call (was missing/out-of-sync). */
208
+ healed: boolean;
209
+ /**
210
+ * Why no heal happened (when `healed` is false): "no-token" (vault .env has
211
+ * no SCRIBE_AUTH_TOKEN — nothing to sync) or "already-synced" (scribe already
212
+ * carries the same token). Undefined when `healed` is true.
213
+ */
214
+ reason?: "no-token" | "already-synced";
215
+ }
216
+
217
+ /**
218
+ * Idempotent self-heal of scribe's `auth.required_token`, run on hub `serve`
219
+ * startup (item H — the loopback-open finding).
220
+ *
221
+ * The gap: `autoWireScribeAuth` only fires from `parachute install scribe` (and
222
+ * the vault↔scribe install pairing). An install that PREDATES auto-wire — or
223
+ * any path where scribe booted with no `auth.required_token` — leaves scribe
224
+ * accepting UNAUTHENTICATED transcription requests over loopback forever; the
225
+ * shared secret never lands in scribe's config even though vault's `.env`
226
+ * already carries `SCRIBE_AUTH_TOKEN`. Install-time wiring can't fix an
227
+ * already-installed box.
228
+ *
229
+ * The fix mirrors the issuer self-heal in `vault-hub-origin-env.ts`: run on
230
+ * every `serve` boot, fully idempotent. When vault's `.env` carries a
231
+ * `SCRIBE_AUTH_TOKEN` AND scribe's `config.json` either lacks
232
+ * `auth.required_token` or carries a DIFFERENT value, write/sync the vault
233
+ * value into scribe's config (via `writeScribeConfig`'s merge-don't-clobber
234
+ * logic — only the auth token is touched, every other config key is preserved).
235
+ * Vault's `.env` is treated as the source of truth (it's where the operator's
236
+ * worker reads the secret from). No-op when vault has no token (nothing to
237
+ * sync) or the two already match. Does NOT restart scribe — `serve` boots the
238
+ * supervised modules AFTER this runs, so the synced config is read on that
239
+ * first boot; an already-running scribe (manual start) is the operator's to
240
+ * restart, same posture as the issuer self-heal.
241
+ *
242
+ * Logs only when it actually heals.
243
+ */
244
+ export function selfHealScribeAuth(opts: {
245
+ configDir: string;
246
+ log?: (line: string) => void;
247
+ }): SelfHealScribeAuthResult {
248
+ const log = opts.log ?? (() => {});
249
+ const vaultEnvPath = join(opts.configDir, "vault", ".env");
250
+ const scribeConfigPath = join(opts.configDir, "scribe", "config.json");
251
+
252
+ const vaultToken = parseEnvFile(vaultEnvPath).values[SCRIBE_AUTH_ENV_KEY];
253
+ if (vaultToken === undefined || vaultToken.length === 0) {
254
+ // Nothing to sync — vault hasn't been wired with a scribe secret. (Either
255
+ // scribe isn't in use, or auto-wire never ran on either side.)
256
+ return { healed: false, reason: "no-token" };
257
+ }
258
+
259
+ const scribeToken = readScribeAuthToken(scribeConfigPath);
260
+ if (scribeToken === vaultToken) {
261
+ return { healed: false, reason: "already-synced" };
262
+ }
263
+
264
+ writeScribeConfig(scribeConfigPath, vaultToken);
265
+ log(
266
+ scribeToken === undefined
267
+ ? `Self-healed scribe auth: wrote required_token to ${scribeConfigPath} (scribe was running auth-OPEN; synced from vault ${SCRIBE_AUTH_ENV_KEY}).`
268
+ : `Self-healed scribe auth: re-synced required_token in ${scribeConfigPath} to match vault ${SCRIBE_AUTH_ENV_KEY}.`,
269
+ );
270
+ return { healed: true };
271
+ }