@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
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Test-isolation boundary guard for destructive service-manager verbs (hub#535).
3
+ *
4
+ * THE OUTAGE (2026-06-03): a hub test running on a LIVE operator machine reached
5
+ * the production default Runner — `Bun.spawnSync(["launchctl", "bootout", …])` —
6
+ * with the real hub label, and `launchctl bootout computer.parachute.hub`'d the
7
+ * running `computer.parachute.hub` launchd daemon, taking hub + vault + scribe
8
+ * down under the operator's feet. Every daemon-op helper (`removeManagedUnit`,
9
+ * `installManagedUnit`, `stopHubUnit`/`restartHubUnit`, `disableStaleModuleUnits`,
10
+ * `teardownHubUnit`) shells launchctl through an injectable `deps.run([...])`
11
+ * whose PRODUCTION DEFAULT is a real spawn. A test that forgets to inject a fake
12
+ * `run` (or whose fake gets removed in a refactor) silently falls back to that
13
+ * real spawn → it drives the operator's actual service manager.
14
+ *
15
+ * THE GUARD: when running under a test runner (`NODE_ENV === "test"`, which Bun
16
+ * sets automatically for `bun test`), the production default Runner REFUSES the
17
+ * destructive launchd verbs — `bootout`, `bootstrap`, `load`, `kickstart` (and
18
+ * their systemd analogues `enable`/`disable`/`start`/`stop`/`restart` against a
19
+ * real systemctl) — and THROWS loudly instead of spawning. A test is thereby
20
+ * FORCED to inject a fake `run`; it can never reach the operator's live daemon by
21
+ * omission. Read-only verbs (`launchctl print`, `systemctl is-active`/
22
+ * `is-enabled`, `journalctl`, `loginctl`, `which`-style probes) are left alone —
23
+ * they're harmless and some tests intentionally exercise the default deps for
24
+ * them.
25
+ *
26
+ * PRODUCTION BEHAVIOR IS IDENTICAL: outside a test runner (`NODE_ENV !== "test"`)
27
+ * the guard is a no-op and the spawn proceeds exactly as before. There is also an
28
+ * explicit escape hatch — `PARACHUTE_ALLOW_REAL_LAUNCHCTL=1` — for the rare
29
+ * deliberate integration test that genuinely wants to drive a real (sandboxed)
30
+ * manager; it must opt IN, loudly, rather than reaching the daemon by accident.
31
+ *
32
+ * This is layer (a) of the hub#535 fix (the durable boundary guard). Layer (b) is
33
+ * the targeted fake-injection in the offending tests; layer (c) is the regression
34
+ * test that asserts the default deps throw here rather than spawn.
35
+ */
36
+
37
+ /** Destructive launchd subcommands that mutate / tear down a loaded unit. */
38
+ const DESTRUCTIVE_LAUNCHCTL_VERBS = new Set([
39
+ "bootout",
40
+ "bootstrap",
41
+ "load",
42
+ "unload",
43
+ "kickstart",
44
+ ]);
45
+
46
+ /**
47
+ * Destructive systemd subcommands that mutate a unit's run/enable state. `enable`
48
+ * / `disable` may carry `--now` (which also starts/stops); `start`/`stop`/
49
+ * `restart` mutate run-state directly. Read-only `is-active` / `is-enabled` /
50
+ * `show` / `status` / `cat` are NOT here — tests use the default deps for those.
51
+ */
52
+ const DESTRUCTIVE_SYSTEMCTL_VERBS = new Set([
53
+ "start",
54
+ "stop",
55
+ "restart",
56
+ "reload",
57
+ "enable",
58
+ "disable",
59
+ "daemon-reload",
60
+ "mask",
61
+ "unmask",
62
+ ]);
63
+
64
+ /** True when we're executing under a test runner. Bun sets NODE_ENV=test for `bun test`. */
65
+ function underTestRunner(): boolean {
66
+ return process.env.NODE_ENV === "test";
67
+ }
68
+
69
+ /** True when an operator has explicitly opted in to real service-manager calls under test. */
70
+ function realCallsExplicitlyAllowed(): boolean {
71
+ const v = process.env.PARACHUTE_ALLOW_REAL_LAUNCHCTL;
72
+ return v === "1" || v === "true";
73
+ }
74
+
75
+ /**
76
+ * Find the first meaningful subcommand token after the tool name, skipping
77
+ * scope/option flags (`--user`, `-k`, `--now`, etc.) so we classify the VERB,
78
+ * not a flag. Returns undefined when there's nothing past the flags.
79
+ */
80
+ function firstSubcommand(rest: readonly string[]): string | undefined {
81
+ for (const tok of rest) {
82
+ if (tok.startsWith("-")) continue;
83
+ return tok;
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ /**
89
+ * Decide whether a command (as the argv the default Runner is about to spawn) is
90
+ * a DESTRUCTIVE service-manager mutation that must be blocked under a test runner.
91
+ *
92
+ * - `launchctl <verb> …` where verb ∈ {bootout, bootstrap, load, unload, kickstart}
93
+ * - `systemctl [--user] <verb> …` where verb ∈ {start, stop, restart, reload,
94
+ * enable, disable, daemon-reload, mask, unmask}
95
+ *
96
+ * The tool name is matched on the basename so an absolute path (`/bin/launchctl`)
97
+ * is classified too — though in this codebase every invocation is bare (the PATH
98
+ * shim's safety relies on that), this keeps the guard correct regardless.
99
+ */
100
+ export function isDestructiveServiceManagerCommand(cmd: readonly string[]): boolean {
101
+ if (cmd.length === 0) return false;
102
+ const tool = (cmd[0] ?? "").split("/").pop() ?? "";
103
+ const rest = cmd.slice(1);
104
+ const verb = firstSubcommand(rest);
105
+ if (verb === undefined) return false;
106
+ if (tool === "launchctl") return DESTRUCTIVE_LAUNCHCTL_VERBS.has(verb);
107
+ if (tool === "systemctl") return DESTRUCTIVE_SYSTEMCTL_VERBS.has(verb);
108
+ return false;
109
+ }
110
+
111
+ /**
112
+ * The boundary guard the production default Runner calls before spawning. When
113
+ * running under a test runner and NOT explicitly opted in, a destructive
114
+ * service-manager command THROWS — forcing the test to inject a fake `run`
115
+ * instead of driving the operator's live daemon. A no-op everywhere else
116
+ * (production, or a non-destructive/read-only command, or explicit opt-in).
117
+ *
118
+ * The thrown error names the exact command and tells the author how to fix it,
119
+ * so a regressed test fails with an actionable message rather than a silent
120
+ * daemon teardown.
121
+ */
122
+ export function guardServiceManagerCommand(cmd: readonly string[]): void {
123
+ if (!underTestRunner()) return; // production — spawn proceeds unchanged.
124
+ if (realCallsExplicitlyAllowed()) return; // deliberate integration test opted in.
125
+ if (!isDestructiveServiceManagerCommand(cmd)) return; // read-only / unrelated — fine.
126
+ throw new Error(
127
+ `[launchctl-guard] Refusing to run a destructive service-manager command under a test runner: \`${cmd.join(
128
+ " ",
129
+ )}\`. The default (production) Runner shells out to the REAL launchctl/systemctl — on a live machine this would tear down the operator's running daemon (this is the hub#535 outage class). Inject a fake \`run\` into this code path's deps so the test never touches the real service manager. (If you GENUINELY need a real, sandboxed manager call in this test, set PARACHUTE_ALLOW_REAL_LAUNCHCTL=1 to opt in.)`,
130
+ );
131
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
+ import { guardServiceManagerCommand } from "./launchctl-guard.ts";
4
5
 
5
6
  /**
6
7
  * Platform-agnostic "managed unit" machinery — the reusable launchd/systemd
@@ -86,6 +87,12 @@ export const defaultManagedUnitDeps: ManagedUnitDeps = {
86
87
  userName: () => process.env.USER ?? process.env.LOGNAME ?? process.env.USERNAME ?? "",
87
88
  which: (binary) => Bun.which(binary),
88
89
  run: (cmd) => {
90
+ // hub#535 boundary guard: under a test runner, REFUSE destructive
91
+ // launchctl/systemctl verbs (bootout/bootstrap/load/kickstart, etc.) instead
92
+ // of spawning the REAL service manager — a test that forgot to inject a fake
93
+ // `run` must not be able to tear down the operator's live daemon by omission.
94
+ // No-op in production (NODE_ENV !== "test"); see src/launchctl-guard.ts.
95
+ guardServiceManagerCommand(cmd);
89
96
  const proc = Bun.spawnSync([...cmd], { env: process.env });
90
97
  return {
91
98
  code: proc.exitCode ?? 1,
@@ -659,9 +666,12 @@ export interface BuildHubManagedUnitOpts {
659
666
  * the bind host to `0.0.0.0` (serve.ts), which is correct for the container
660
667
  * shape (the platform's HTTP forwarder must reach the hub) but WRONG for a
661
668
  * self-hosted box — bare `serve` would expose the admin/OAuth surfaces on every
662
- * interface, contradicting the pre-supervisor detached behavior and the trust
663
- * model `layerOf` (hub-server.ts) assumes (header-absent ⇒ "loopback"). The
664
- * container path never calls this builder (the Dockerfile pins
669
+ * interface, contradicting the pre-supervisor detached behavior. Forcing a
670
+ * loopback bind also keeps `layerOf` (hub-server.ts) precise: post-#526 it
671
+ * derives trust from the peer address (`server.requestIP`), failing closed to
672
+ * "public" for any non-loopback peer — so a 127.0.0.1-only listener is what
673
+ * lets a header-absent on-box CLI caller classify as "loopback". The container
674
+ * path never calls this builder (the Dockerfile pins
665
675
  * `ENV PARACHUTE_BIND_HOST=0.0.0.0` + runs `serve` directly), so it stays
666
676
  * 0.0.0.0. The canonical expose path is unaffected: cloudflared/tailscale dial
667
677
  * `127.0.0.1:<port>` from the same host, and the hub's own proxy targets
@@ -22,11 +22,15 @@
22
22
  */
23
23
 
24
24
  import { existsSync } from "node:fs";
25
- import {
26
- type CutoverOpts,
27
- type CutoverResult,
28
- cutoverToSupervised,
29
- } from "./commands/migrate-cutover.ts";
25
+ // `migrate-cutover.ts` is imported as a TYPE only (erased at compile time, no
26
+ // module evaluation) and loaded LAZILY at the call site below. This breaks the
27
+ // transitive eager-load chain `cli.ts` → `lifecycle.ts` → `migrate-offer.ts` →
28
+ // `migrate-cutover.ts`: a broken `migrate-cutover` (e.g. the 0.6.2 eval-time
29
+ // ReferenceError) must not crash the start/stop/restart/logs lifecycle commands
30
+ // that pull in this module purely for the §7.5 detect-and-offer machinery. The
31
+ // cutover is only ever evaluated when an operator interactively accepts the
32
+ // offer, so deferring its import to that moment keeps the whole chain robust.
33
+ import type { CutoverOpts, CutoverResult } from "./commands/migrate-cutover.ts";
30
34
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "./config.ts";
31
35
  import { HUB_SVC } from "./hub-control.ts";
32
36
  import { type HubUnitDeps, defaultHubUnitDeps, isHubUnitInstalled } from "./hub-unit.ts";
@@ -149,7 +153,6 @@ export async function offerMigrateToSupervised(
149
153
  const unitInstalledFn = opts.isHubUnitInstalled ?? isHubUnitInstalled;
150
154
  const hubUnitDeps = opts.hubUnitDeps ?? defaultHubUnitDeps;
151
155
  const hasPriorDetached = opts.hasPriorDetached ?? hasPriorDetachedInstall;
152
- const cutover = opts.cutover ?? cutoverToSupervised;
153
156
  const prompt = opts.prompt ?? defaultOfferPrompt;
154
157
  const isTty = opts.isTty ?? Boolean(process.stdin.isTTY);
155
158
 
@@ -179,6 +182,12 @@ export async function offerMigrateToSupervised(
179
182
  return { outcome: "declined" };
180
183
  }
181
184
 
185
+ // Resolve the cutover lazily: only import `migrate-cutover.ts` now that the
186
+ // operator has accepted, so the offer's mere availability never drags the
187
+ // cutover module into the lifecycle-command load graph (see the `import type`
188
+ // note at the top). Tests inject `opts.cutover` and never hit the import.
189
+ const cutover =
190
+ opts.cutover ?? (await import("./commands/migrate-cutover.ts")).cutoverToSupervised;
182
191
  const result = await cutover({ configDir, manifestPath, log });
183
192
  for (const line of result.messages) log(line);
184
193
  const ok = result.outcome === "migrated" || result.outcome === "already-migrated";
@@ -183,7 +183,7 @@ export async function driveModuleOp(
183
183
  const body = await parseJsonSafe(res);
184
184
 
185
185
  if (res.status < 200 || res.status >= 300) {
186
- const { error, error_description } = asErrorBody(body);
186
+ const { error, error_description } = asErrorBody(body, res.status);
187
187
  throw new ModuleOpHttpError(res.status, error, error_description);
188
188
  }
189
189
 
@@ -231,7 +231,7 @@ async function pollOperation(
231
231
  });
232
232
  const body = await parseJsonSafe(res);
233
233
  if (res.status < 200 || res.status >= 300) {
234
- const { error, error_description } = asErrorBody(body);
234
+ const { error, error_description } = asErrorBody(body, res.status);
235
235
  throw new ModuleOpHttpError(res.status, error, error_description);
236
236
  }
237
237
  const status = extractOpStatus(body);
@@ -288,7 +288,7 @@ export async function fetchModuleLogs(
288
288
  });
289
289
  const body = await parseJsonSafe(res);
290
290
  if (res.status < 200 || res.status >= 300) {
291
- const { error, error_description } = asErrorBody(body);
291
+ const { error, error_description } = asErrorBody(body, res.status);
292
292
  throw new ModuleOpHttpError(res.status, error, error_description);
293
293
  }
294
294
  const b = (body ?? {}) as { lines?: unknown; text?: unknown };
@@ -330,6 +330,14 @@ export interface ModuleStatesResult {
330
330
  readonly supervisorAvailable: boolean;
331
331
  /** Per-module supervisor snapshots, keyed by short name in array order. */
332
332
  readonly modules: ModuleStateSnapshot[];
333
+ /**
334
+ * Run-state for ALL supervised modules — including non-curated ones the
335
+ * `modules` catalog omits (e.g. the `surface` UI host). `status` falls back
336
+ * to this so a running-but-non-curated module reads `active`, not `inactive`
337
+ * (hub#539). The live `fetchModuleStates` always populates it (`[]` against an
338
+ * older hub that predates the field); optional so test stubs may omit it.
339
+ */
340
+ readonly supervised?: ModuleStateSnapshot[];
333
341
  }
334
342
 
335
343
  /**
@@ -388,25 +396,37 @@ export async function fetchModuleStates(deps: DriveModuleOpDeps): Promise<Module
388
396
  }
389
397
  const body = await parseJsonSafe(res);
390
398
  if (res.status < 200 || res.status >= 300) {
391
- const { error, error_description } = asErrorBody(body);
399
+ const { error, error_description } = asErrorBody(body, res.status);
392
400
  throw new ModuleOpHttpError(res.status, error, error_description);
393
401
  }
394
- const b = (body ?? {}) as { modules?: unknown; supervisor_available?: unknown };
402
+ const b = (body ?? {}) as {
403
+ modules?: unknown;
404
+ supervised?: unknown;
405
+ supervisor_available?: unknown;
406
+ };
395
407
  const supervisorAvailable = b.supervisor_available === true;
396
- const modules: ModuleStateSnapshot[] = Array.isArray(b.modules)
397
- ? b.modules
398
- .filter((m): m is Record<string, unknown> => !!m && typeof m === "object")
399
- .map((m) => ({
400
- short: typeof m.short === "string" ? m.short : "",
401
- installed: m.installed === true,
402
- installed_version: typeof m.installed_version === "string" ? m.installed_version : null,
403
- supervisor_status: typeof m.supervisor_status === "string" ? m.supervisor_status : null,
404
- pid: typeof m.pid === "number" ? m.pid : null,
405
- supervisor_start_error:
406
- m.supervisor_start_error !== undefined ? (m.supervisor_start_error ?? null) : null,
407
- }))
408
- : [];
409
- return { supervisorAvailable, modules };
408
+ const modules = parseSnapshots(b.modules);
409
+ // `supervised` (hub#539) carries run-state for ALL supervised modules,
410
+ // including non-curated ones absent from `modules` (e.g. the surface host).
411
+ // Older hubs without the field yield []; consumers tolerate that.
412
+ const supervised = parseSnapshots(b.supervised);
413
+ return { supervisorAvailable, modules, supervised };
414
+ }
415
+
416
+ /** Parse a `modules`/`supervised` array into validated snapshots (hub#539). */
417
+ function parseSnapshots(raw: unknown): ModuleStateSnapshot[] {
418
+ if (!Array.isArray(raw)) return [];
419
+ return raw
420
+ .filter((m): m is Record<string, unknown> => !!m && typeof m === "object")
421
+ .map((m) => ({
422
+ short: typeof m.short === "string" ? m.short : "",
423
+ installed: m.installed === true,
424
+ installed_version: typeof m.installed_version === "string" ? m.installed_version : null,
425
+ supervisor_status: typeof m.supervisor_status === "string" ? m.supervisor_status : null,
426
+ pid: typeof m.pid === "number" ? m.pid : null,
427
+ supervisor_start_error:
428
+ m.supervisor_start_error !== undefined ? (m.supervisor_start_error ?? null) : null,
429
+ }));
410
430
  }
411
431
 
412
432
  async function parseJsonSafe(res: Response): Promise<unknown> {
@@ -417,15 +437,20 @@ async function parseJsonSafe(res: Response): Promise<unknown> {
417
437
  }
418
438
  }
419
439
 
420
- function asErrorBody(body: unknown): { error: string; error_description: string } {
440
+ function asErrorBody(body: unknown, status: number): { error: string; error_description: string } {
441
+ // A bare/unparseable error response used to collapse to "request failed",
442
+ // which gave the operator nothing to act on (hub#536 — a spawn-throw
443
+ // escaping a handler produced exactly this). Carry the HTTP status so even
444
+ // the worst case names the failure class.
445
+ const fallback = `hub returned HTTP ${status} with no error detail`;
421
446
  if (body && typeof body === "object") {
422
447
  const b = body as Record<string, unknown>;
423
448
  const error = typeof b.error === "string" ? b.error : "error";
424
449
  const error_description =
425
- typeof b.error_description === "string" ? b.error_description : "request failed";
450
+ typeof b.error_description === "string" ? b.error_description : fallback;
426
451
  return { error, error_description };
427
452
  }
428
- return { error: "error", error_description: "request failed" };
453
+ return { error: "error", error_description: fallback };
429
454
  }
430
455
 
431
456
  function extractOperationId(body: unknown): string | undefined {
@@ -83,3 +83,22 @@ export function hasMintingAuthority(bearerScopes: string[]): boolean {
83
83
  bearerScopes.some((s) => isVaultAdminScope(s))
84
84
  );
85
85
  }
86
+
87
+ /**
88
+ * Is this an *operator* bearer — i.e. does it hold host-level minting authority
89
+ * (`parachute:host:auth` or `parachute:host:admin`)?
90
+ *
91
+ * The distinction matters for the `subject` override on the mint endpoint: an
92
+ * operator bearer is the on-box host administrator (or a service account it
93
+ * delegated to), so it may legitimately mint a token whose `sub` names a
94
+ * service account other than its own — the documented service-account override.
95
+ * A merely vault-scoped bearer (`vault:<N>:admin` only) has NO host authority,
96
+ * so letting it set an arbitrary `sub` is audit-attribution forgery: it could
97
+ * mint a token that the registry + revocation list attribute to a foreign
98
+ * subject. Non-operator bearers are therefore pinned to their own `sub`.
99
+ */
100
+ export function isOperatorBearer(bearerScopes: string[]): boolean {
101
+ return (
102
+ bearerScopes.includes(MINT_HOST_AUTH_SCOPE) || bearerScopes.includes(MINT_HOST_ADMIN_SCOPE)
103
+ );
104
+ }
@@ -270,7 +270,15 @@ export function isNonRequestableScope(scope: string): boolean {
270
270
  // consent path and through `canGrant` rule 1, capped to the consenting
271
271
  // user's held authority at the `issueAuthCodeRedirect` choke-point. Only
272
272
  // the host-level operator scopes stay non-requestable here.
273
- return NON_REQUESTABLE_SCOPES.has(scope);
273
+ //
274
+ // Item C — case-insensitive guard. The membership check is exact-string,
275
+ // but Parachute scope tokens are canonically lowercase. A casing variant
276
+ // like `PARACHUTE:HOST:AUTH` would slip past a raw `Set.has` and be treated
277
+ // as requestable — minting a junk-but-harmless token today (consumers are
278
+ // case-sensitive + anchored, so it grants nothing), but a backstop against
279
+ // a future consumer that case-folds. Normalize to lowercase before the
280
+ // membership check so every casing of a host-level scope is refused.
281
+ return NON_REQUESTABLE_SCOPES.has(scope.toLowerCase());
274
282
  }
275
283
 
276
284
  /** True when the scope can appear in a public `/oauth/authorize` request. */
@@ -39,9 +39,14 @@ import type { ServiceEntry } from "./services-manifest.ts";
39
39
  *
40
40
  * Operator override is now "edit services.json" (or `parachute config`
41
41
  * once that lands), not "edit `.env`". Pre-#206 stale `.env` PORT lines on
42
- * existing operator machines stay where they are — harmless, since the
43
- * boot-time ladder reads services.json before falling through to the bare
44
- * PORT env tier — and future installs no longer touch them.
42
+ * existing operator machines stay where they are — harmless to the module's
43
+ * own resolvePort ladder, which reads services.json before falling through to
44
+ * the bare PORT env tier — and future installs no longer touch them. (Caveat,
45
+ * hub#537: the supervisor's spawn-env builder used to echo a stale `.env` PORT
46
+ * into the injected `PORT`, and its readiness probe trusted that — so a `.env`
47
+ * PORT disagreeing with services.json yielded a false `started_but_unbound`.
48
+ * Fixed by making `entry.port` authoritative: `buildModuleSpawnRequest` drops a
49
+ * `.env` PORT.)
45
50
  *
46
51
  * **No speculative reservations.** Future first-party modules claim a slot
47
52
  * the moment they ship, not before — pre-reservation for unbuilt things has
@@ -177,7 +182,15 @@ const NOTES_SERVE_PATH = fileURLToPath(new URL("./notes-serve.ts", import.meta.u
177
182
  * telegraphs the state: the row is a stopgap, and the service's first boot
178
183
  * will overwrite with its own authoritative write.
179
184
  */
180
- const SEED_VERSION = "0.0.0-linked";
185
+ /**
186
+ * Version string stamped on a CLI-seeded services.json entry — the
187
+ * "module installed, but its own daemon hasn't booted and registered a real
188
+ * row yet" placeholder. A real service boot overwrites this with the package's
189
+ * actual version (see "Services own their write side of services.json" in
190
+ * CLAUDE.md). Exported so consumers (e.g. well-known generation, hub#577) can
191
+ * tell a placeholder entry apart from a live one.
192
+ */
193
+ export const SEED_VERSION = "0.0.0-linked";
181
194
 
182
195
  function pathBasedUrl(entry: ServiceEntry): string {
183
196
  const first = entry.paths[0] ?? "";
@@ -399,6 +399,18 @@ export interface SetupWizardDeps {
399
399
  userId: string,
400
400
  opts: MintOperatorTokenOpts & { dir?: string },
401
401
  ) => Promise<IssueOperatorTokenResult>;
402
+ /**
403
+ * Whether the in-flight request arrived over loopback (peer `127.0.0.1` /
404
+ * `::1`). Set by `hub-server.ts` from `layerOf(req, peerAddr)`. hub#576: a
405
+ * loopback caller already proves on-box access (it's the operator's own
406
+ * shell — `parachute init` driving the CLI wizard), so the GET `/admin/setup`
407
+ * JSON probe reveals the actual bootstrap token VALUE to it, not just the
408
+ * `requireBootstrapToken` boolean. Public / tailnet callers (any browser
409
+ * that found the FQDN) get only the boolean and must paste the token the
410
+ * operator copied from their terminal. Absent (undefined) is treated as
411
+ * NON-loopback — fail closed, never leak the token to a header-less caller.
412
+ */
413
+ requestIsLoopback?: boolean;
402
414
  }
403
415
 
404
416
  /**
@@ -1601,8 +1613,17 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1601
1613
  // the HTML rendering branches means the CLI gets the answer it needs
1602
1614
  // without the wizard having to render a 30KB HTML page per poll.
1603
1615
  if (wantsJson) {
1604
- const requireToken = getBootstrapToken() !== undefined;
1605
- const envelope = {
1616
+ const activeToken = getBootstrapToken();
1617
+ const requireToken = activeToken !== undefined;
1618
+ const envelope: {
1619
+ step: typeof state.step;
1620
+ hasAdmin: boolean;
1621
+ hasVault: boolean;
1622
+ hasExposeMode: boolean;
1623
+ requireBootstrapToken: boolean;
1624
+ csrfToken: string;
1625
+ bootstrapToken?: string;
1626
+ } = {
1606
1627
  step: state.step,
1607
1628
  hasAdmin: state.hasAdmin,
1608
1629
  hasVault: state.hasVault,
@@ -1610,6 +1631,17 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1610
1631
  requireBootstrapToken: requireToken,
1611
1632
  csrfToken: csrf.token,
1612
1633
  };
1634
+ // hub#576: hand the actual token to a LOOPBACK caller only. The on-box
1635
+ // operator (`parachute init` → CLI wizard, or a curl from their own shell)
1636
+ // already proves box access by reaching loopback — same trust level as
1637
+ // reading the token off the startup banner in the hub log. This lets init
1638
+ // surface the token in the operator's terminal and feed it to the CLI
1639
+ // wizard transparently, instead of making them dig through `parachute logs
1640
+ // hub`. A public / tailnet browser never gets the value — it stays gated on
1641
+ // the operator pasting what they copied from their terminal.
1642
+ if (requireToken && deps.requestIsLoopback === true && activeToken !== undefined) {
1643
+ envelope.bootstrapToken = activeToken;
1644
+ }
1613
1645
  const jsonHeaders: Record<string, string> = {
1614
1646
  "content-type": "application/json; charset=utf-8",
1615
1647
  "cache-control": "no-store",
@@ -0,0 +1,148 @@
1
+ /**
2
+ * PATH enrichment for spawned modules + the hub's own boot env.
3
+ *
4
+ * The hub-as-supervisor unit bakes a hardcoded base PATH (see
5
+ * `enrichedUnitPath` below), and `Bun.spawn` defaults to an empty env, so
6
+ * supervised children only ever see the PATH the unit handed the hub. On a
7
+ * launchd-managed Mac that PATH omits the two dirs operator tools actually live
8
+ * in — `$HOME/.local/bin` (scribe's `parakeet-mlx`) and the Homebrew bin
9
+ * (`ffmpeg`, `/opt/homebrew/bin` on Apple Silicon). The result: scribe's
10
+ * `Bun.which("ffmpeg")` / `Bun.which("parakeet-mlx")` probes come up empty and
11
+ * transcription is dead on canonical installs.
12
+ *
13
+ * `enrichedPath` is the shared fix. It takes a base env (defaults to
14
+ * `process.env`), keeps whatever PATH was inherited, and APPENDS the operator-
15
+ * tool dirs that exist on disk and aren't already present. Inherited PATH wins
16
+ * (append, not prepend) so an operator's explicit PATH ordering is never
17
+ * reordered out from under them. `PARACHUTE_EXTRA_PATH` (colon-joined) is
18
+ * PREPENDED so an operator can intentionally shadow a system binary.
19
+ *
20
+ * Two consumers:
21
+ * 1. The spawn side (`buildModuleSpawnRequest` in `commands/serve-boot.ts` +
22
+ * `spawnSupervised` in `api-modules-ops.ts`) injects `enrichedPath()` into
23
+ * every supervised child's env. This is the PRIMARY fix — it self-heals
24
+ * every existing install at the next hub restart, no re-init needed.
25
+ * 2. The hub's own `serve` startup enriches `process.env.PATH` so the hub's
26
+ * own `Bun.which` probes (cloudflared / tailscale detection, etc.) also
27
+ * see brew + `.local`.
28
+ *
29
+ * The unit generator (`enrichedUnitPath`, used by `hub-unit.ts` +
30
+ * `commands/migrate-cutover.ts`) bakes the same dirs into the launchd/systemd
31
+ * unit PATH as belt-and-suspenders for fresh installs.
32
+ */
33
+
34
+ import { existsSync } from "node:fs";
35
+ import { homedir } from "node:os";
36
+
37
+ /** Injectable seams so tests don't touch the real fs / os / platform. */
38
+ export interface EnrichedPathDeps {
39
+ /** Operator home dir. */
40
+ homeDir: () => string;
41
+ /** True when `path` exists on disk. */
42
+ exists: (path: string) => boolean;
43
+ /** `process.platform` value (e.g. "darwin", "linux"). */
44
+ platform: NodeJS.Platform;
45
+ /** `process.arch` value (e.g. "arm64", "x64"). */
46
+ arch: string;
47
+ }
48
+
49
+ export const defaultEnrichedPathDeps: EnrichedPathDeps = {
50
+ homeDir: () => homedir(),
51
+ exists: (path) => existsSync(path),
52
+ platform: process.platform,
53
+ arch: process.arch,
54
+ };
55
+
56
+ /**
57
+ * The Homebrew bin dir for the current platform/arch, or null when there
58
+ * isn't a canonical one (non-darwin — Linux brew is opt-in + non-canonical, so
59
+ * we only contribute `$HOME/.local/bin` there).
60
+ *
61
+ * Apple Silicon brew installs under `/opt/homebrew`; Intel macOS brew under
62
+ * `/usr/local`.
63
+ */
64
+ function brewBinDir(platform: NodeJS.Platform, arch: string): string | null {
65
+ if (platform !== "darwin") return null;
66
+ return arch === "arm64" ? "/opt/homebrew/bin" : "/usr/local/bin";
67
+ }
68
+
69
+ /**
70
+ * Operator-tool dirs to APPEND (in this order): `$HOME/.local/bin` (pipx /
71
+ * `pip install --user` — scribe's `parakeet-mlx`), the platform brew bin
72
+ * (`ffmpeg`), and `$HOME/.bun/bin` (bun-linked binaries on a cold boot). Order
73
+ * is deterministic and stable. Pure of fs — the runtime enrichment filters by
74
+ * existence, the unit generator includes them unconditionally.
75
+ */
76
+ export function operatorToolDirs(home: string, platform: NodeJS.Platform, arch: string): string[] {
77
+ const dirs = [`${home}/.local/bin`];
78
+ const brew = brewBinDir(platform, arch);
79
+ if (brew) dirs.push(brew);
80
+ dirs.push(`${home}/.bun/bin`);
81
+ return dirs;
82
+ }
83
+
84
+ function candidateDirs(deps: EnrichedPathDeps): string[] {
85
+ return operatorToolDirs(deps.homeDir(), deps.platform, deps.arch);
86
+ }
87
+
88
+ function dedupe(entries: string[]): string[] {
89
+ const seen = new Set<string>();
90
+ const out: string[] = [];
91
+ for (const entry of entries) {
92
+ if (seen.has(entry)) continue;
93
+ seen.add(entry);
94
+ out.push(entry);
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /**
100
+ * The PATH baked into the launchd/systemd hub unit at generation time.
101
+ *
102
+ * `${bunInstall}/bin` first (so supervised children resolve a bun-linked binary
103
+ * on cold boot, R20), then the usual system dirs, then the operator-tool dirs
104
+ * (`$HOME/.local/bin`, the platform brew bin) so a managed hub — and the modules
105
+ * it spawns — can find scribe's `parakeet-mlx` + `ffmpeg`. `PARACHUTE_EXTRA_PATH`
106
+ * (if set at generation time) is PREPENDED for intentional operator shadowing.
107
+ * Deduped end-to-end.
108
+ *
109
+ * Unlike `enrichedPath`, the unit-side dirs are included UNCONDITIONALLY (no
110
+ * existence check): the unit is generated once at install/migrate time but
111
+ * brew/tools may be installed afterward, and a non-existent PATH entry is simply
112
+ * skipped by the OS — so baking them in is the robust choice for fresh installs.
113
+ *
114
+ * Shared by `hub-unit.ts` (init bringup) + `commands/migrate-cutover.ts`
115
+ * (`--to-supervised` cutover) so the two unit-generation paths can't drift.
116
+ */
117
+ export function enrichedUnitPath(
118
+ bunInstall: string,
119
+ home: string,
120
+ platform: NodeJS.Platform = process.platform,
121
+ arch: string = process.arch,
122
+ extraPath: string | undefined = process.env.PARACHUTE_EXTRA_PATH,
123
+ ): string {
124
+ const extra = (extraPath ?? "").split(":").filter((e) => e.length > 0);
125
+ const base = [`${bunInstall}/bin`, "/usr/local/bin", "/usr/bin", "/bin"];
126
+ return dedupe([...extra, ...base, ...operatorToolDirs(home, platform, arch)]).join(":");
127
+ }
128
+
129
+ /**
130
+ * Build a PATH that enriches `env.PATH` with operator-tool dirs.
131
+ *
132
+ * Ordering: `PARACHUTE_EXTRA_PATH` (prepended) : inherited PATH : appended
133
+ * operator-tool dirs that exist and aren't already present. Deduped end-to-end
134
+ * (first occurrence wins, so an inherited entry is never reordered).
135
+ */
136
+ export function enrichedPath(
137
+ env: NodeJS.ProcessEnv = process.env,
138
+ deps: EnrichedPathDeps = defaultEnrichedPathDeps,
139
+ ): string {
140
+ const inherited = (env.PATH ?? "").split(":").filter((e) => e.length > 0);
141
+ const extra = (env.PARACHUTE_EXTRA_PATH ?? "").split(":").filter((e) => e.length > 0);
142
+ const appended = candidateDirs(deps).filter((d) => deps.exists(d));
143
+
144
+ // PARACHUTE_EXTRA_PATH first (intentional operator shadow), then the
145
+ // inherited PATH (inherited wins over our appended defaults), then our
146
+ // appended operator-tool dirs.
147
+ return dedupe([...extra, ...inherited, ...appended]).join(":");
148
+ }