@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.
Files changed (72) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +880 -0
  3. package/src/__tests__/account-usage.test.ts +137 -0
  4. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  5. package/src/__tests__/account-vault-token.test.ts +53 -1
  6. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  7. package/src/__tests__/admin-vaults.test.ts +20 -0
  8. package/src/__tests__/api-account.test.ts +125 -4
  9. package/src/__tests__/api-invites.test.ts +217 -0
  10. package/src/__tests__/api-mint-token.test.ts +259 -10
  11. package/src/__tests__/api-modules-ops.test.ts +187 -1
  12. package/src/__tests__/api-modules.test.ts +40 -4
  13. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  14. package/src/__tests__/auto-wire.test.ts +101 -1
  15. package/src/__tests__/cli.test.ts +188 -2
  16. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  17. package/src/__tests__/expose-cloudflare.test.ts +5 -4
  18. package/src/__tests__/expose.test.ts +10 -5
  19. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  20. package/src/__tests__/hub-server.test.ts +628 -13
  21. package/src/__tests__/hub-unit.test.ts +4 -0
  22. package/src/__tests__/invites.test.ts +220 -0
  23. package/src/__tests__/launchctl-guard.test.ts +185 -0
  24. package/src/__tests__/migrate-cutover.test.ts +32 -0
  25. package/src/__tests__/module-ops-client.test.ts +68 -0
  26. package/src/__tests__/scope-explanations.test.ts +16 -0
  27. package/src/__tests__/serve-boot.test.ts +74 -1
  28. package/src/__tests__/serve.test.ts +121 -7
  29. package/src/__tests__/spawn-path.test.ts +191 -0
  30. package/src/__tests__/status.test.ts +64 -0
  31. package/src/__tests__/supervisor.test.ts +177 -0
  32. package/src/__tests__/users.test.ts +27 -0
  33. package/src/account-home-ui.ts +82 -9
  34. package/src/account-setup.ts +381 -0
  35. package/src/account-usage.ts +118 -0
  36. package/src/account-vault-admin-token.ts +242 -0
  37. package/src/account-vault-token.ts +27 -2
  38. package/src/admin-login-ui.ts +121 -0
  39. package/src/admin-vault-admin-token.ts +8 -2
  40. package/src/admin-vaults.ts +137 -29
  41. package/src/api-account.ts +54 -1
  42. package/src/api-invites.ts +345 -0
  43. package/src/api-mint-token.ts +81 -0
  44. package/src/api-modules-ops.ts +168 -53
  45. package/src/api-modules.ts +36 -0
  46. package/src/auto-wire.ts +87 -0
  47. package/src/cli.ts +122 -32
  48. package/src/commands/expose-2fa-warning.ts +17 -13
  49. package/src/commands/migrate-cutover.ts +12 -5
  50. package/src/commands/serve-boot.ts +33 -3
  51. package/src/commands/serve.ts +158 -37
  52. package/src/commands/status.ts +9 -1
  53. package/src/hub-db.ts +70 -2
  54. package/src/hub-server.ts +399 -41
  55. package/src/hub-unit.ts +4 -9
  56. package/src/invites.ts +291 -0
  57. package/src/launchctl-guard.ts +131 -0
  58. package/src/managed-unit.ts +13 -3
  59. package/src/migrate-offer.ts +15 -6
  60. package/src/module-ops-client.ts +47 -22
  61. package/src/scope-attenuation.ts +19 -0
  62. package/src/scope-explanations.ts +9 -1
  63. package/src/service-spec.ts +8 -3
  64. package/src/spawn-path.ts +148 -0
  65. package/src/supervisor.ts +84 -7
  66. package/src/users.ts +42 -4
  67. package/src/vault-hub-origin-env.ts +28 -0
  68. package/src/vault-name.ts +13 -1
  69. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  70. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
package/src/hub-unit.ts CHANGED
@@ -53,6 +53,7 @@ import {
53
53
  systemdUnitPathForName,
54
54
  } from "./managed-unit.ts";
55
55
  import { type PortListeningFn, defaultPortListening } from "./port-probe.ts";
56
+ import { enrichedUnitPath } from "./spawn-path.ts";
56
57
 
57
58
  /** Default canonical hub port (the 1939 pin). */
58
59
  export const HUB_UNIT_DEFAULT_PORT = 1939;
@@ -623,14 +624,8 @@ export function hubUnitMessages(): ManagedUnitMessages {
623
624
  };
624
625
  }
625
626
 
626
- /**
627
- * Sane default PATH for the hub unit when the caller doesn't supply one: bun's
628
- * global bin first (so supervised children resolve a bun-linked binary on cold
629
- * boot, R20), then the usual system dirs.
630
- */
631
- function defaultUnitPath(bunInstall: string): string {
632
- return `${bunInstall}/bin:/usr/local/bin:/usr/bin:/bin`;
633
- }
627
+ // The hub-unit PATH is built by `enrichedUnitPath` (src/spawn-path.ts) so this
628
+ // init-bringup path and the `migrate --to-supervised` cutover path can't drift.
634
629
 
635
630
  /**
636
631
  * Build + install + start the hub unit, then wait for hub readiness (design
@@ -652,7 +647,7 @@ export async function installAndStartHubUnit(
652
647
  const deps = opts.deps ?? defaultHubUnitDeps;
653
648
  const port = opts.port ?? HUB_UNIT_DEFAULT_PORT;
654
649
  const bunInstall = opts.bunInstall ?? `${deps.homeDir()}/.bun`;
655
- const path = opts.path ?? defaultUnitPath(bunInstall);
650
+ const path = opts.path ?? enrichedUnitPath(bunInstall, deps.homeDir(), deps.platform);
656
651
  const logPath = opts.logPath ?? `${opts.parachuteHome}/hub/logs/hub.log`;
657
652
  const log = opts.log ?? (() => {});
658
653
 
package/src/invites.ts ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * One-time, expiring invite links (design
3
+ * 2026-06-04-individual-users-and-vault-operations.md §7). An admin issues
4
+ * a link; the recipient opens `/account/setup/<token>`, picks a username +
5
+ * password, and gets their OWN freshly-provisioned vault as owner.
6
+ *
7
+ * Token model — mirrors `auth-codes.ts` (single-use + expiring + sha256-at-
8
+ * rest), with the key difference that invites are LONGER-LIVED (default 7
9
+ * days vs the 60s auth-code TTL), so the row stores **sha256(token)**, never
10
+ * the raw value. The raw token is returned exactly ONCE from `issueInvite`
11
+ * and never persisted — a DB read alone can't replay the link (the same
12
+ * posture as the bootstrap token). Lookup hashes the URL token and selects
13
+ * by the hash.
14
+ *
15
+ * What an invite pre-authorizes: creating exactly ONE account + the one
16
+ * named/created vault at the baked-in role — NEVER host:admin, NEVER another
17
+ * vault. The redeemed user inherits only the `user_vaults` row's authority.
18
+ * The redemption flow (`/account/setup/<token>` in hub-server.ts) enforces
19
+ * the createUser-then-stamp ordering so a createUser failure leaves the
20
+ * invite re-usable.
21
+ *
22
+ * Single-use is enforced by stamping `used_at` on redemption — a replay
23
+ * attempt sees the row with `used_at` set and `redeemInvite` throws
24
+ * `InviteUsedError`. Revocation is a separate `revoked_at` stamp the admin
25
+ * sets before redemption. Expiry is enforced at redeem-time.
26
+ */
27
+ import type { Database } from "bun:sqlite";
28
+ import { createHash, randomBytes } from "node:crypto";
29
+
30
+ /** Default invite lifetime — long enough to deliver out-of-band (no email), short enough to bound a leaked link. */
31
+ export const DEFAULT_INVITE_TTL_SECONDS = 7 * 24 * 60 * 60;
32
+
33
+ /** Token entropy in bytes — 256 bits, matching the bootstrap / auth-code token. */
34
+ const INVITE_TOKEN_BYTES = 32;
35
+
36
+ export type InviteStatus = "pending" | "redeemed" | "expired" | "revoked";
37
+
38
+ export interface Invite {
39
+ /** sha256(raw token), hex. The raw token is never stored. */
40
+ tokenHash: string;
41
+ createdBy: string | null;
42
+ /** Pinned vault name, or null when the redeemer names their own vault. */
43
+ vaultName: string | null;
44
+ /** `user_vaults.role` granted on redemption (`'write'` = owner). */
45
+ role: string;
46
+ /** Whether redemption provisions a NEW vault for the redeemer. */
47
+ provisionVault: boolean;
48
+ /** `'internal' | 'off'` mirror knob for the provisioned vault, or null. */
49
+ defaultMirror: string | null;
50
+ expiresAt: string;
51
+ usedAt: string | null;
52
+ redeemedUserId: string | null;
53
+ revokedAt: string | null;
54
+ createdAt: string;
55
+ }
56
+
57
+ export class InviteNotFoundError extends Error {
58
+ constructor() {
59
+ super("invite not found");
60
+ this.name = "InviteNotFoundError";
61
+ }
62
+ }
63
+
64
+ export class InviteExpiredError extends Error {
65
+ constructor() {
66
+ super("invite has expired");
67
+ this.name = "InviteExpiredError";
68
+ }
69
+ }
70
+
71
+ export class InviteUsedError extends Error {
72
+ constructor() {
73
+ super("invite has already been redeemed");
74
+ this.name = "InviteUsedError";
75
+ }
76
+ }
77
+
78
+ export class InviteRevokedError extends Error {
79
+ constructor() {
80
+ super("invite has been revoked");
81
+ this.name = "InviteRevokedError";
82
+ }
83
+ }
84
+
85
+ interface Row {
86
+ token: string;
87
+ created_by: string | null;
88
+ vault_name: string | null;
89
+ role: string;
90
+ provision_vault: number;
91
+ default_mirror: string | null;
92
+ expires_at: string;
93
+ used_at: string | null;
94
+ redeemed_user_id: string | null;
95
+ revoked_at: string | null;
96
+ created_at: string;
97
+ }
98
+
99
+ function rowToInvite(r: Row): Invite {
100
+ return {
101
+ tokenHash: r.token,
102
+ createdBy: r.created_by,
103
+ vaultName: r.vault_name,
104
+ role: r.role,
105
+ provisionVault: r.provision_vault === 1,
106
+ defaultMirror: r.default_mirror,
107
+ expiresAt: r.expires_at,
108
+ usedAt: r.used_at,
109
+ redeemedUserId: r.redeemed_user_id,
110
+ revokedAt: r.revoked_at,
111
+ createdAt: r.created_at,
112
+ };
113
+ }
114
+
115
+ /** sha256 of the raw token, hex — the at-rest representation + PK. */
116
+ export function hashInviteToken(rawToken: string): string {
117
+ return createHash("sha256").update(rawToken).digest("hex");
118
+ }
119
+
120
+ /** Derive an invite's status from its stamps + the current time. */
121
+ export function inviteStatus(invite: Invite, now: Date = new Date()): InviteStatus {
122
+ if (invite.revokedAt) return "revoked";
123
+ if (invite.usedAt) return "redeemed";
124
+ if (now.getTime() > new Date(invite.expiresAt).getTime()) return "expired";
125
+ return "pending";
126
+ }
127
+
128
+ export interface IssueInviteOpts {
129
+ /** Admin user id issuing the invite (audit). */
130
+ createdBy: string;
131
+ /** Pinned vault name; omit/null to let the redeemer name their own. */
132
+ vaultName?: string | null;
133
+ /** `user_vaults` role granted on redemption. Default `'write'` (owner). */
134
+ role?: string;
135
+ /** Provision a new vault on redemption. Default `true` (the primary flow). */
136
+ provisionVault?: boolean;
137
+ /** `'internal' | 'off'` mirror knob for the provisioned vault. */
138
+ defaultMirror?: string | null;
139
+ /** Lifetime in seconds. Default {@link DEFAULT_INVITE_TTL_SECONDS} (7 days). */
140
+ expiresInSeconds?: number;
141
+ now?: () => Date;
142
+ }
143
+
144
+ export interface IssuedInvite {
145
+ /**
146
+ * The raw token — returned EXACTLY ONCE here and never persisted. The
147
+ * caller builds the redemption URL from it (`/account/setup/<rawToken>`)
148
+ * and shows it once; the hub keeps only `sha256(rawToken)`.
149
+ */
150
+ rawToken: string;
151
+ invite: Invite;
152
+ }
153
+
154
+ /**
155
+ * Mint an invite: generate a 256-bit raw token, store its sha256, return the
156
+ * raw token once. The row's PK is the hash, so a DB compromise can't replay
157
+ * the link.
158
+ */
159
+ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
160
+ const rawToken = randomBytes(INVITE_TOKEN_BYTES).toString("base64url");
161
+ const tokenHash = hashInviteToken(rawToken);
162
+ const now = opts.now?.() ?? new Date();
163
+ const createdAt = now.toISOString();
164
+ const ttl = opts.expiresInSeconds ?? DEFAULT_INVITE_TTL_SECONDS;
165
+ const expiresAt = new Date(now.getTime() + ttl * 1000).toISOString();
166
+ const role = opts.role ?? "write";
167
+ const vaultName = opts.vaultName ?? null;
168
+ const provisionVault = opts.provisionVault ?? true;
169
+ const defaultMirror = opts.defaultMirror ?? null;
170
+
171
+ db.prepare(
172
+ `INSERT INTO invites
173
+ (token, created_by, vault_name, role, provision_vault, default_mirror,
174
+ expires_at, used_at, redeemed_user_id, revoked_at, created_at)
175
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?)`,
176
+ ).run(
177
+ tokenHash,
178
+ opts.createdBy,
179
+ vaultName,
180
+ role,
181
+ provisionVault ? 1 : 0,
182
+ defaultMirror,
183
+ expiresAt,
184
+ createdAt,
185
+ );
186
+
187
+ return {
188
+ rawToken,
189
+ invite: {
190
+ tokenHash,
191
+ createdBy: opts.createdBy,
192
+ vaultName,
193
+ role,
194
+ provisionVault,
195
+ defaultMirror,
196
+ expiresAt,
197
+ usedAt: null,
198
+ redeemedUserId: null,
199
+ revokedAt: null,
200
+ createdAt,
201
+ },
202
+ };
203
+ }
204
+
205
+ /** Look up an invite by its raw (URL) token. Hashes then selects. */
206
+ export function findInviteByRawToken(db: Database, rawToken: string): Invite | null {
207
+ const hash = hashInviteToken(rawToken);
208
+ const row = db.query<Row, [string]>("SELECT * FROM invites WHERE token = ?").get(hash);
209
+ return row ? rowToInvite(row) : null;
210
+ }
211
+
212
+ /** Look up an invite by its sha256 hash (admin DELETE/revoke by id). */
213
+ export function findInviteByHash(db: Database, tokenHash: string): Invite | null {
214
+ const row = db.query<Row, [string]>("SELECT * FROM invites WHERE token = ?").get(tokenHash);
215
+ return row ? rowToInvite(row) : null;
216
+ }
217
+
218
+ /** List every invite, newest first, with derived status. */
219
+ export function listInvites(
220
+ db: Database,
221
+ now: Date = new Date(),
222
+ ): (Invite & { status: InviteStatus })[] {
223
+ const rows = db.query<Row, []>("SELECT * FROM invites ORDER BY created_at DESC").all();
224
+ return rows.map((r) => {
225
+ const invite = rowToInvite(r);
226
+ return { ...invite, status: inviteStatus(invite, now) };
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Validate an invite for redemption WITHOUT consuming it. Throws on every
232
+ * not-redeemable branch (not-found / expired / used / revoked). Returns the
233
+ * invite when it's redeemable. The redemption handler calls this FIRST (so a
234
+ * bad token is rejected before any account/vault work), then does
235
+ * createUser, then `consumeInvite` AFTER the user row commits.
236
+ */
237
+ export function assertInviteRedeemable(
238
+ db: Database,
239
+ rawToken: string,
240
+ now: Date = new Date(),
241
+ ): Invite {
242
+ const invite = findInviteByRawToken(db, rawToken);
243
+ if (!invite) throw new InviteNotFoundError();
244
+ // Revoked + used are terminal regardless of clock; check them before expiry
245
+ // so a revoked-then-expired invite reports the more specific reason.
246
+ if (invite.revokedAt) throw new InviteRevokedError();
247
+ if (invite.usedAt) throw new InviteUsedError();
248
+ if (now.getTime() > new Date(invite.expiresAt).getTime()) {
249
+ throw new InviteExpiredError();
250
+ }
251
+ return invite;
252
+ }
253
+
254
+ /**
255
+ * Mark an invite consumed — stamp `used_at` + `redeemed_user_id`. Called
256
+ * Called within the account-creation transaction (or after a committed user
257
+ * row). Single-use + not-revoked is enforced by the
258
+ * `used_at IS NULL AND revoked_at IS NULL` guard in the UPDATE: a racing
259
+ * second redeem — or a concurrent revoke — updates zero rows and the caller
260
+ * treats that as already-consumed/revoked. Race-safe because sqlite
261
+ * serializes writes.
262
+ *
263
+ * Returns `true` if THIS call consumed the invite, `false` otherwise.
264
+ */
265
+ export function consumeInvite(
266
+ db: Database,
267
+ tokenHash: string,
268
+ redeemedUserId: string,
269
+ now: Date = new Date(),
270
+ ): boolean {
271
+ const res = db
272
+ .prepare(
273
+ "UPDATE invites SET used_at = ?, redeemed_user_id = ? WHERE token = ? AND used_at IS NULL AND revoked_at IS NULL",
274
+ )
275
+ .run(now.toISOString(), redeemedUserId, tokenHash);
276
+ return res.changes > 0;
277
+ }
278
+
279
+ /**
280
+ * Revoke a pending invite (admin DELETE). Stamps `revoked_at` only when the
281
+ * invite isn't already used or revoked. Returns `true` if this call revoked
282
+ * it, `false` if it was already consumed/revoked or not found.
283
+ */
284
+ export function revokeInvite(db: Database, tokenHash: string, now: Date = new Date()): boolean {
285
+ const res = db
286
+ .prepare(
287
+ "UPDATE invites SET revoked_at = ? WHERE token = ? AND used_at IS NULL AND revoked_at IS NULL",
288
+ )
289
+ .run(now.toISOString(), tokenHash);
290
+ return res.changes > 0;
291
+ }
@@ -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 {