@openparachute/hub 0.6.3 → 0.6.4-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +125 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +187 -1
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +5 -4
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +628 -13
- package/src/__tests__/hub-unit.test.ts +4 -0
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +32 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +177 -0
- package/src/__tests__/users.test.ts +27 -0
- package/src/account-home-ui.ts +82 -9
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +27 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +54 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +122 -32
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +399 -41
- package/src/hub-unit.ts +4 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +8 -3
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +84 -7
- package/src/users.ts +42 -4
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
package/src/api-modules-ops.ts
CHANGED
|
@@ -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
|
-
|
|
779
|
-
|
|
780
|
-
//
|
|
781
|
-
//
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/api-modules.ts
CHANGED
|
@@ -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
|
+
}
|