@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,220 @@
1
+ /**
2
+ * Core invite-primitive tests (`src/invites.ts`). Mirrors the auth-codes
3
+ * test shape: issue, lookup-by-raw, status derivation, redeemable assertion
4
+ * (not-found / expired / used / revoked), single-use consume, revoke.
5
+ *
6
+ * Security invariants asserted here: 256-bit raw token, sha256 at rest (the
7
+ * raw token never appears in the row), single-use via used_at, expiry
8
+ * enforced at redeem, revocable.
9
+ */
10
+ import { describe, expect, test } from "bun:test";
11
+ import { createHash } from "node:crypto";
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
16
+ import {
17
+ DEFAULT_INVITE_TTL_SECONDS,
18
+ InviteExpiredError,
19
+ InviteNotFoundError,
20
+ InviteRevokedError,
21
+ InviteUsedError,
22
+ assertInviteRedeemable,
23
+ consumeInvite,
24
+ findInviteByRawToken,
25
+ hashInviteToken,
26
+ inviteStatus,
27
+ issueInvite,
28
+ listInvites,
29
+ revokeInvite,
30
+ } from "../invites.ts";
31
+ import { createUser } from "../users.ts";
32
+
33
+ async function makeDb() {
34
+ const dir = mkdtempSync(join(tmpdir(), "phub-invites-"));
35
+ const db = openHubDb(hubDbPath(dir));
36
+ const admin = await createUser(db, "operator", "operator-password-1");
37
+ return {
38
+ db,
39
+ adminId: admin.id,
40
+ cleanup: () => {
41
+ db.close();
42
+ rmSync(dir, { recursive: true, force: true });
43
+ },
44
+ };
45
+ }
46
+
47
+ describe("issueInvite", () => {
48
+ test("returns a 256-bit raw token and stores ONLY its sha256", async () => {
49
+ const { db, adminId, cleanup } = await makeDb();
50
+ try {
51
+ const { rawToken, invite } = issueInvite(db, { createdBy: adminId, vaultName: "maya" });
52
+ // base64url of 32 bytes ≈ 43 chars — comfortably high entropy.
53
+ expect(rawToken.length).toBeGreaterThan(40);
54
+ expect(invite.tokenHash).toBe(hashInviteToken(rawToken));
55
+ expect(invite.tokenHash).not.toBe(rawToken);
56
+ // The row stores the hash, never the raw token.
57
+ const row = db
58
+ .query<{ token: string }, [string]>("SELECT token FROM invites WHERE token = ?")
59
+ .get(createHash("sha256").update(rawToken).digest("hex"));
60
+ expect(row?.token).toBe(invite.tokenHash);
61
+ const rawRow = db
62
+ .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM invites WHERE token = ?")
63
+ .get(rawToken);
64
+ expect(rawRow?.n).toBe(0);
65
+ } finally {
66
+ cleanup();
67
+ }
68
+ });
69
+
70
+ test("defaults: role=write, provision_vault=1, 7-day expiry", async () => {
71
+ const { db, adminId, cleanup } = await makeDb();
72
+ try {
73
+ const now = new Date("2026-06-04T00:00:00Z");
74
+ const { invite } = issueInvite(db, { createdBy: adminId, now: () => now });
75
+ expect(invite.role).toBe("write");
76
+ expect(invite.provisionVault).toBe(true);
77
+ expect(invite.vaultName).toBeNull();
78
+ const expiry = new Date(invite.expiresAt).getTime() - now.getTime();
79
+ expect(Math.round(expiry / 1000)).toBe(DEFAULT_INVITE_TTL_SECONDS);
80
+ } finally {
81
+ cleanup();
82
+ }
83
+ });
84
+ });
85
+
86
+ describe("findInviteByRawToken", () => {
87
+ test("hashes then finds; unknown/tampered token → null", async () => {
88
+ const { db, adminId, cleanup } = await makeDb();
89
+ try {
90
+ const { rawToken } = issueInvite(db, { createdBy: adminId });
91
+ expect(findInviteByRawToken(db, rawToken)).not.toBeNull();
92
+ expect(findInviteByRawToken(db, `${rawToken}x`)).toBeNull();
93
+ expect(findInviteByRawToken(db, "totally-unknown")).toBeNull();
94
+ } finally {
95
+ cleanup();
96
+ }
97
+ });
98
+ });
99
+
100
+ describe("assertInviteRedeemable", () => {
101
+ test("unknown token → InviteNotFoundError", async () => {
102
+ const { db, cleanup } = await makeDb();
103
+ try {
104
+ expect(() => assertInviteRedeemable(db, "nope")).toThrow(InviteNotFoundError);
105
+ } finally {
106
+ cleanup();
107
+ }
108
+ });
109
+
110
+ test("expired token → InviteExpiredError", async () => {
111
+ const { db, adminId, cleanup } = await makeDb();
112
+ try {
113
+ const now = new Date("2026-06-04T00:00:00Z");
114
+ const { rawToken } = issueInvite(db, {
115
+ createdBy: adminId,
116
+ expiresInSeconds: 60,
117
+ now: () => now,
118
+ });
119
+ const later = new Date(now.getTime() + 61_000);
120
+ expect(() => assertInviteRedeemable(db, rawToken, later)).toThrow(InviteExpiredError);
121
+ } finally {
122
+ cleanup();
123
+ }
124
+ });
125
+
126
+ test("used token → InviteUsedError", async () => {
127
+ const { db, adminId, cleanup } = await makeDb();
128
+ try {
129
+ const { rawToken, invite } = issueInvite(db, { createdBy: adminId });
130
+ consumeInvite(db, invite.tokenHash, adminId);
131
+ expect(() => assertInviteRedeemable(db, rawToken)).toThrow(InviteUsedError);
132
+ } finally {
133
+ cleanup();
134
+ }
135
+ });
136
+
137
+ test("revoked token → InviteRevokedError", async () => {
138
+ const { db, adminId, cleanup } = await makeDb();
139
+ try {
140
+ const { rawToken, invite } = issueInvite(db, { createdBy: adminId });
141
+ revokeInvite(db, invite.tokenHash);
142
+ expect(() => assertInviteRedeemable(db, rawToken)).toThrow(InviteRevokedError);
143
+ } finally {
144
+ cleanup();
145
+ }
146
+ });
147
+ });
148
+
149
+ describe("consumeInvite — single-use", () => {
150
+ test("first consume wins; second returns false (replay rejected)", async () => {
151
+ const { db, adminId, cleanup } = await makeDb();
152
+ try {
153
+ const { invite } = issueInvite(db, { createdBy: adminId });
154
+ expect(consumeInvite(db, invite.tokenHash, adminId)).toBe(true);
155
+ expect(consumeInvite(db, invite.tokenHash, adminId)).toBe(false);
156
+ const fresh = db
157
+ .query<{ used_at: string | null; redeemed_user_id: string | null }, [string]>(
158
+ "SELECT used_at, redeemed_user_id FROM invites WHERE token = ?",
159
+ )
160
+ .get(invite.tokenHash);
161
+ expect(fresh?.used_at).not.toBeNull();
162
+ expect(fresh?.redeemed_user_id).toBe(adminId);
163
+ } finally {
164
+ cleanup();
165
+ }
166
+ });
167
+ });
168
+
169
+ describe("revokeInvite", () => {
170
+ test("revokes a pending invite; refuses one already used", async () => {
171
+ const { db, adminId, cleanup } = await makeDb();
172
+ try {
173
+ const a = issueInvite(db, { createdBy: adminId });
174
+ expect(revokeInvite(db, a.invite.tokenHash)).toBe(true);
175
+ expect(revokeInvite(db, a.invite.tokenHash)).toBe(false); // already revoked
176
+
177
+ const b = issueInvite(db, { createdBy: adminId });
178
+ consumeInvite(db, b.invite.tokenHash, adminId);
179
+ expect(revokeInvite(db, b.invite.tokenHash)).toBe(false); // already used
180
+ } finally {
181
+ cleanup();
182
+ }
183
+ });
184
+ });
185
+
186
+ describe("inviteStatus / listInvites", () => {
187
+ test("derives pending / redeemed / expired / revoked", async () => {
188
+ const { db, adminId, cleanup } = await makeDb();
189
+ try {
190
+ const now = new Date("2026-06-04T00:00:00Z");
191
+ const pending = issueInvite(db, { createdBy: adminId, now: () => now });
192
+ const redeemed = issueInvite(db, { createdBy: adminId, now: () => now });
193
+ consumeInvite(db, redeemed.invite.tokenHash, adminId, now);
194
+ const revoked = issueInvite(db, { createdBy: adminId, now: () => now });
195
+ revokeInvite(db, revoked.invite.tokenHash, now);
196
+ const expired = issueInvite(db, {
197
+ createdBy: adminId,
198
+ expiresInSeconds: 10,
199
+ now: () => now,
200
+ });
201
+
202
+ const later = new Date(now.getTime() + 60_000);
203
+ // `consumeInvite`/`revokeInvite` mutate the DB row, not the in-memory
204
+ // snapshot — re-read each to assert status off persisted state.
205
+ const fresh = (raw: string) => findInviteByRawToken(db, raw);
206
+ expect(inviteStatus(fresh(pending.rawToken)!, later)).toBe("pending");
207
+ expect(inviteStatus(fresh(redeemed.rawToken)!, later)).toBe("redeemed");
208
+ expect(inviteStatus(fresh(revoked.rawToken)!, later)).toBe("revoked");
209
+ expect(inviteStatus(fresh(expired.rawToken)!, later)).toBe("expired");
210
+
211
+ const list = listInvites(db, later);
212
+ expect(list.length).toBe(4);
213
+ // listInvites annotates status + newest-first ordering.
214
+ const statuses = new Set(list.map((i) => i.status));
215
+ expect(statuses).toEqual(new Set(["pending", "redeemed", "revoked", "expired"]));
216
+ } finally {
217
+ cleanup();
218
+ }
219
+ });
220
+ });
@@ -0,0 +1,185 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defaultHubUnitDeps } from "../hub-unit.ts";
3
+ import {
4
+ guardServiceManagerCommand,
5
+ isDestructiveServiceManagerCommand,
6
+ } from "../launchctl-guard.ts";
7
+ import { defaultManagedUnitDeps } from "../managed-unit.ts";
8
+
9
+ // ===========================================================================
10
+ // hub#535 — test-isolation boundary guard for destructive service-manager verbs.
11
+ //
12
+ // THE OUTAGE: a hub test on a LIVE machine reached the PRODUCTION default Runner
13
+ // (`Bun.spawnSync(["launchctl","bootout","computer.parachute.hub"])`) — a daemon-
14
+ // op helper was called with the default `deps`, not an injected fake — and
15
+ // `launchctl bootout`'d the real running hub daemon, taking the whole ecosystem
16
+ // down. The guard makes that impossible: under a test runner the default Runner
17
+ // REFUSES destructive launchd/systemd verbs and THROWS, so a test that forgets to
18
+ // inject a fake `run` fails loudly instead of nuking the operator's daemon.
19
+ //
20
+ // These tests run under `bun test` ⇒ NODE_ENV === "test" ⇒ the guard is ACTIVE.
21
+ // ===========================================================================
22
+
23
+ describe("isDestructiveServiceManagerCommand — classification", () => {
24
+ test("launchd destructive verbs are flagged", () => {
25
+ for (const verb of ["bootout", "bootstrap", "load", "unload", "kickstart"]) {
26
+ expect(
27
+ isDestructiveServiceManagerCommand(["launchctl", verb, "gui/501/computer.parachute.hub"]),
28
+ ).toBe(true);
29
+ }
30
+ });
31
+
32
+ test("launchd read-only verbs are NOT flagged", () => {
33
+ // `print` (state descriptor) and `list` are diagnostics — tests legitimately
34
+ // exercise these through the default deps, so the guard must leave them alone.
35
+ expect(
36
+ isDestructiveServiceManagerCommand(["launchctl", "print", "gui/501/computer.parachute.hub"]),
37
+ ).toBe(false);
38
+ expect(isDestructiveServiceManagerCommand(["launchctl", "list"])).toBe(false);
39
+ });
40
+
41
+ test("systemd destructive verbs are flagged (incl. --user / --now flags skipped)", () => {
42
+ expect(
43
+ isDestructiveServiceManagerCommand([
44
+ "systemctl",
45
+ "--user",
46
+ "enable",
47
+ "--now",
48
+ "parachute-hub.service",
49
+ ]),
50
+ ).toBe(true);
51
+ expect(
52
+ isDestructiveServiceManagerCommand([
53
+ "systemctl",
54
+ "disable",
55
+ "--now",
56
+ "parachute-vault.service",
57
+ ]),
58
+ ).toBe(true);
59
+ for (const verb of ["start", "stop", "restart", "daemon-reload", "mask"]) {
60
+ expect(
61
+ isDestructiveServiceManagerCommand(["systemctl", "--user", verb, "parachute-hub.service"]),
62
+ ).toBe(true);
63
+ }
64
+ });
65
+
66
+ test("systemd read-only verbs are NOT flagged", () => {
67
+ expect(
68
+ isDestructiveServiceManagerCommand([
69
+ "systemctl",
70
+ "--user",
71
+ "is-active",
72
+ "parachute-hub.service",
73
+ ]),
74
+ ).toBe(false);
75
+ expect(
76
+ isDestructiveServiceManagerCommand(["systemctl", "is-enabled", "parachute-vault.service"]),
77
+ ).toBe(false);
78
+ });
79
+
80
+ test("unrelated tools / loginctl / journalctl are NOT flagged", () => {
81
+ expect(isDestructiveServiceManagerCommand(["loginctl", "enable-linger", "op"])).toBe(false);
82
+ expect(
83
+ isDestructiveServiceManagerCommand(["journalctl", "--user", "-u", "parachute-hub.service"]),
84
+ ).toBe(false);
85
+ expect(isDestructiveServiceManagerCommand(["ps", "-o", "command=", "-p", "123"])).toBe(false);
86
+ expect(isDestructiveServiceManagerCommand([])).toBe(false);
87
+ });
88
+
89
+ test("absolute-path tool is still classified (basename match)", () => {
90
+ // Defense in depth: in this repo every invocation is bare, but if one ever
91
+ // used an absolute path the guard must still recognize it.
92
+ expect(
93
+ isDestructiveServiceManagerCommand([
94
+ "/bin/launchctl",
95
+ "bootout",
96
+ "gui/0/computer.parachute.hub",
97
+ ]),
98
+ ).toBe(true);
99
+ });
100
+ });
101
+
102
+ describe("guardServiceManagerCommand — throws under a test runner", () => {
103
+ test("throws on launchctl bootout (the exact outage command)", () => {
104
+ expect(() =>
105
+ guardServiceManagerCommand(["launchctl", "bootout", "gui/501/computer.parachute.hub"]),
106
+ ).toThrow(/launchctl-guard.*Refusing to run a destructive service-manager command/s);
107
+ });
108
+
109
+ test("does NOT throw on a read-only command", () => {
110
+ expect(() =>
111
+ guardServiceManagerCommand(["launchctl", "print", "gui/501/computer.parachute.hub"]),
112
+ ).not.toThrow();
113
+ });
114
+
115
+ test("opt-out env var (PARACHUTE_ALLOW_REAL_LAUNCHCTL) lets a deliberate call through", () => {
116
+ const prev = process.env.PARACHUTE_ALLOW_REAL_LAUNCHCTL;
117
+ process.env.PARACHUTE_ALLOW_REAL_LAUNCHCTL = "1";
118
+ try {
119
+ expect(() =>
120
+ guardServiceManagerCommand(["launchctl", "bootout", "gui/501/computer.parachute.SAFE"]),
121
+ ).not.toThrow();
122
+ } finally {
123
+ // Restore — `= undefined` to clear (the codebase convention; biome flags
124
+ // `delete process.env.X` as a perf foot-gun. See hub-settings.test.ts).
125
+ if (prev === undefined) process.env.PARACHUTE_ALLOW_REAL_LAUNCHCTL = undefined;
126
+ else process.env.PARACHUTE_ALLOW_REAL_LAUNCHCTL = prev;
127
+ }
128
+ });
129
+ });
130
+
131
+ // ===========================================================================
132
+ // THE REGRESSION TEST (hub#535 layer c): the PRODUCTION default deps — the ones a
133
+ // daemon-op helper falls back to when a test forgets to inject a fake `run` — must
134
+ // REFUSE the real launchctl under a test runner. If the guard regresses (someone
135
+ // removes it from `defaultManagedUnitDeps.run`), these flip from "throws" to
136
+ // "spawns the real launchctl" and this test fails — BEFORE the change can reach a
137
+ // machine and bootout the live daemon.
138
+ //
139
+ // We assert via `defaultManagedUnitDeps.run` directly (the single chokepoint every
140
+ // bare-launchctl call in the codebase routes through) and via `defaultHubUnitDeps`
141
+ // (which spreads the same `run`), proving both seams are protected.
142
+ // ===========================================================================
143
+ describe("default Runner refuses real launchctl under test (regression — hub#535)", () => {
144
+ test("defaultManagedUnitDeps.run THROWS on `launchctl bootout` instead of spawning", () => {
145
+ expect(() =>
146
+ defaultManagedUnitDeps.run(["launchctl", "bootout", "gui/501/computer.parachute.hub"]),
147
+ ).toThrow(/launchctl-guard/);
148
+ });
149
+
150
+ test("defaultHubUnitDeps.run (inherits the guard via spread) THROWS on `launchctl kickstart`", () => {
151
+ expect(() =>
152
+ defaultHubUnitDeps.run(["launchctl", "kickstart", "-k", "gui/501/computer.parachute.hub"]),
153
+ ).toThrow(/launchctl-guard/);
154
+ });
155
+
156
+ test("default deps THROW on `systemctl --user disable --now` too", () => {
157
+ expect(() =>
158
+ defaultManagedUnitDeps.run([
159
+ "systemctl",
160
+ "--user",
161
+ "disable",
162
+ "--now",
163
+ "parachute-vault.service",
164
+ ]),
165
+ ).toThrow(/launchctl-guard/);
166
+ });
167
+
168
+ test("default deps still RUN a read-only `launchctl print` (guard is verb-scoped, not a blanket block)", () => {
169
+ // The property under test: the GUARD does not block a read-only `print`
170
+ // (verb-scoped, not a blanket launchctl block) — so it never throws the
171
+ // `/launchctl-guard/` error for it. What happens AFTER the guard allows it
172
+ // through is environment-dependent and NOT under test:
173
+ // - dev Mac under the test PATH shim → fake launchctl, exit 0, no throw
174
+ // - real Mac → `launchctl print` of a non-loaded label returns nonzero, no throw
175
+ // - Linux CI (no launchctl on PATH) → the spawn throws ENOENT
176
+ // ("Executable not found in $PATH") — which is NOT the guard, and still
177
+ // proves the guard let the command reach the spawn.
178
+ // So: assert only that no error thrown here is a GUARD throw.
179
+ try {
180
+ defaultManagedUnitDeps.run(["launchctl", "print", "gui/501/computer.parachute.NONEXISTENT"]);
181
+ } catch (err) {
182
+ expect(String((err as { message?: unknown })?.message ?? err)).not.toMatch(/launchctl-guard/);
183
+ }
184
+ });
185
+ });
@@ -67,6 +67,7 @@ function fakeHubUnitDeps(): HubUnitDeps {
67
67
  readFile: () => undefined,
68
68
  exists: () => false,
69
69
  probeHealth: async () => false,
70
+ probeHealthVersion: async () => null,
70
71
  portListening: async () => false,
71
72
  sleep: async () => {},
72
73
  };
@@ -490,6 +491,12 @@ describe("teardownHubUnit (§7.4)", () => {
490
491
  const log: string[] = [];
491
492
  const res = teardownHubUnit({
492
493
  log: (l) => log.push(l),
494
+ // hub#535: inject fake deps so teardown NEVER falls back to the production
495
+ // default Runner (which would shell out to the real launchctl/systemctl).
496
+ // The injected fakes below mean no service-manager call is even attempted,
497
+ // but pinning `deps` removes the latent "forgot to inject → real bootout"
498
+ // hazard at the source.
499
+ deps: fakeHubUnitDeps(),
493
500
  remove: (opts): ManagedUnitRemoveResult => {
494
501
  removeArgs = { launchdLabel: opts.launchdLabel, systemdUnitName: opts.systemdUnitName };
495
502
  return { removed: true, messages: [opts.removedSystemdMessage(opts.systemdUnitName)] };
@@ -514,6 +521,9 @@ describe("teardownHubUnit (§7.4)", () => {
514
521
  const log: string[] = [];
515
522
  const res = teardownHubUnit({
516
523
  log: (l) => log.push(l),
524
+ // hub#535: inject fake deps (see the success-path test above) so this never
525
+ // reaches the production default Runner / real service manager.
526
+ deps: fakeHubUnitDeps(),
517
527
  remove: (): ManagedUnitRemoveResult => ({ removed: false, messages: [] }),
518
528
  disableStaleModuleUnits: () => {
519
529
  staleCalled = true;
@@ -526,6 +536,29 @@ describe("teardownHubUnit (§7.4)", () => {
526
536
  expect(staleCalled).toBe(true);
527
537
  expect(log.join("\n")).toContain("nothing to tear down");
528
538
  });
539
+
540
+ test("removal failure (removed:false WITH messages) → surfaces the reason, not 'nothing installed' (hub#534)", () => {
541
+ // A future / defensive `removeManagedUnit` that returns removed:false WITH a
542
+ // failure reason must NOT be reported as the benign "nothing was installed"
543
+ // line — the operator (and the CLI's non-zero exit) need the real detail.
544
+ const log: string[] = [];
545
+ const res = teardownHubUnit({
546
+ log: (l) => log.push(l),
547
+ deps: fakeHubUnitDeps(),
548
+ remove: (): ManagedUnitRemoveResult => ({
549
+ removed: false,
550
+ messages: ["systemctl disable failed: permission denied"],
551
+ }),
552
+ disableStaleModuleUnits: () => ({ actions: [] }),
553
+ });
554
+ expect(res.removed).toBe(false);
555
+ expect(res.messages).toEqual(["systemctl disable failed: permission denied"]);
556
+ const out = log.join("\n");
557
+ expect(out).toContain("did not complete");
558
+ expect(out).toContain("permission denied");
559
+ // Must NOT claim nothing was installed when there was a real failure.
560
+ expect(out).not.toContain("nothing to tear down");
561
+ });
529
562
  });
530
563
 
531
564
  // ===========================================================================
@@ -183,6 +183,27 @@ describe("driveModuleOp — auth + transport", () => {
183
183
  expect(calls).toHaveLength(0);
184
184
  });
185
185
 
186
+ test("non-2xx with NO error body → fallback names the HTTP status, not bare 'request failed' (hub#536)", async () => {
187
+ h = await makeHarnessWithToken();
188
+ // An empty `{}` body models a handler crash that produced a bodyless 500
189
+ // (pre-fix this collapsed to an unactionable "request failed").
190
+ const { fetch: f } = fakeFetch([{ status: 500, body: {} }]);
191
+ let err: unknown;
192
+ try {
193
+ await driveModuleOp("vault", "start", {
194
+ db: h.db,
195
+ issuer: ISSUER,
196
+ configDir: h.dir,
197
+ fetch: f,
198
+ });
199
+ } catch (e) {
200
+ err = e;
201
+ }
202
+ expect(err).toBeInstanceOf(ModuleOpHttpError);
203
+ expect((err as ModuleOpHttpError).status).toBe(500);
204
+ expect((err as Error).message).toContain("hub returned HTTP 500 with no error detail");
205
+ });
206
+
186
207
  test("non-2xx hub response → ModuleOpHttpError carrying status + code", async () => {
187
208
  h = await makeHarnessWithToken();
188
209
  const { fetch: f } = fakeFetch([
@@ -471,6 +492,53 @@ describe("fetchModuleStates", () => {
471
492
  expect((scribe?.supervisor_start_error as { binary?: string } | null)?.binary).toBe("scribe");
472
493
  });
473
494
 
495
+ test("parses the `supervised` array — non-curated modules' run-state (hub#539)", async () => {
496
+ h = await makeHarnessWithToken();
497
+ const { fetch: f } = fakeFetch([
498
+ {
499
+ status: 200,
500
+ body: {
501
+ supervisor_available: true,
502
+ modules: [], // curated catalog can omit a running module (e.g. surface)…
503
+ supervised: [
504
+ {
505
+ short: "surface",
506
+ installed: true,
507
+ installed_version: null,
508
+ supervisor_status: "running",
509
+ pid: 8739,
510
+ supervisor_start_error: null,
511
+ },
512
+ ],
513
+ },
514
+ },
515
+ ]);
516
+ const result = await fetchModuleStates({
517
+ db: h.db,
518
+ issuer: ISSUER,
519
+ configDir: h.dir,
520
+ fetch: f,
521
+ });
522
+ expect(result.supervised).toHaveLength(1);
523
+ const surf = result.supervised?.find((m) => m.short === "surface");
524
+ expect(surf?.supervisor_status).toBe("running");
525
+ expect(surf?.pid).toBe(8739);
526
+ });
527
+
528
+ test("omitted `supervised` (older hub) parses to [] — hub#539 forward-compat", async () => {
529
+ h = await makeHarnessWithToken();
530
+ const { fetch: f } = fakeFetch([
531
+ { status: 200, body: { supervisor_available: true, modules: [] } },
532
+ ]);
533
+ const result = await fetchModuleStates({
534
+ db: h.db,
535
+ issuer: ISSUER,
536
+ configDir: h.dir,
537
+ fetch: f,
538
+ });
539
+ expect(result.supervised).toEqual([]);
540
+ });
541
+
474
542
  test("no operator token → NoOperatorTokenError before any fetch", async () => {
475
543
  h = await makeHarnessNoToken();
476
544
  const { fetch: f, calls } = fakeFetch([{ status: 200, body: { modules: [] } }]);
@@ -4,6 +4,7 @@ import {
4
4
  NON_REQUESTABLE_SCOPES,
5
5
  SCOPE_EXPLANATIONS,
6
6
  explainScope,
7
+ isNonRequestableScope,
7
8
  isRequestableScope,
8
9
  isWellFormedOrNonVaultScope,
9
10
  scopeIsAdmin,
@@ -162,6 +163,21 @@ describe("isRequestableScope", () => {
162
163
  expect(isRequestableScope("vault:default:read")).toBe(true);
163
164
  expect(isRequestableScope("vault:work:write")).toBe(true);
164
165
  });
166
+
167
+ // Item C — case-insensitive guard. A casing variant of a host-level scope
168
+ // must NOT slip past the exact-string membership check as "requestable."
169
+ test("uppercase / mixed-case host scopes are non-requestable (item C)", () => {
170
+ expect(isRequestableScope("PARACHUTE:HOST:AUTH")).toBe(false);
171
+ expect(isRequestableScope("Parachute:Host:Admin")).toBe(false);
172
+ expect(isRequestableScope("parachute:HOST:vault")).toBe(false);
173
+ // And the direct predicate agrees.
174
+ expect(isNonRequestableScope("PARACHUTE:HOST:AUTH")).toBe(true);
175
+ expect(isNonRequestableScope("parachute:Host:Install")).toBe(true);
176
+ // Canonical lowercase still works unchanged.
177
+ expect(isNonRequestableScope("parachute:host:auth")).toBe(true);
178
+ // A non-host scope (even uppercased) stays requestable.
179
+ expect(isNonRequestableScope("HUB:ADMIN")).toBe(false);
180
+ });
165
181
  });
166
182
 
167
183
  // Mint-time shape guard (defensive hygiene, audit 2026-05-28). Rejects only the
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { bootSupervisedModules } from "../commands/serve-boot.ts";
5
+ import { bootSupervisedModules, buildModuleSpawnRequest } from "../commands/serve-boot.ts";
6
6
  import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
7
7
  import { type SpawnRequest, type SupervisedProc, Supervisor } from "../supervisor.ts";
8
8
 
@@ -164,6 +164,79 @@ describe("bootSupervisedModules", () => {
164
164
  expect(recorder.calls[0]?.env?.SCRIBE_URL).toBe("http://127.0.0.1:3200");
165
165
  });
166
166
 
167
+ test("services.json entry.port wins over a stale .env PORT (hub#537)", async () => {
168
+ // Pre-hub#206 installs wrote `PORT=` into the per-service .env. A leftover
169
+ // PORT there that disagrees with services.json (e.g. scribe's stale 1944 vs
170
+ // canonical 1943) must NOT shadow entry.port — otherwise the supervisor
171
+ // injects + probes the wrong port and records a false `started_but_unbound`.
172
+ writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
173
+ mkdirSync(join(h.dir, "vault"), { recursive: true });
174
+ writeFileSync(join(h.dir, "vault", ".env"), "PORT=1944\nSCRIBE_AUTH_TOKEN=secret-token\n");
175
+
176
+ const recorder = makeRecorder();
177
+ const sup = new Supervisor({ spawnFn: recorder.spawn });
178
+
179
+ await bootSupervisedModules(sup, {
180
+ manifestPath: h.manifestPath,
181
+ configDir: h.dir,
182
+ });
183
+
184
+ // entry.port (1940) wins; the stale .env PORT is dropped. Other .env
185
+ // values still merge.
186
+ expect(recorder.calls[0]?.env?.PORT).toBe("1940");
187
+ expect(recorder.calls[0]?.env?.SCRIBE_AUTH_TOKEN).toBe("secret-token");
188
+ });
189
+
190
+ test("extraEnv PORT still wins over entry.port (layer 4 — test seam / first-boot)", () => {
191
+ // Dropping a stale .env PORT must not affect the documented layer-4 override:
192
+ // an explicit `opts.extraEnv.PORT` (programmatic, not a stale on-disk file)
193
+ // still wins last.
194
+ const req = buildModuleSpawnRequest("vault", VAULT_ENTRY, ["parachute-vault", "serve"], {
195
+ configDir: h.dir,
196
+ extraEnv: { PORT: "9999" },
197
+ });
198
+ expect(req.env?.PORT).toBe("9999");
199
+ });
200
+
201
+ test("injects an enriched PATH carrying the inherited process PATH (hub launchd-PATH fix)", () => {
202
+ // The hub unit bakes a minimal PATH and Bun.spawn defaults to empty env, so
203
+ // without this injection the child can't find operator tools (scribe's
204
+ // parakeet-mlx / ffmpeg). The req must carry a PATH, and it must preserve
205
+ // whatever the hub process inherited.
206
+ const req = buildModuleSpawnRequest("vault", VAULT_ENTRY, ["parachute-vault", "serve"], {
207
+ configDir: h.dir,
208
+ });
209
+ expect(req.env?.PATH).toBeDefined();
210
+ expect(req.env?.PATH?.length).toBeGreaterThan(0);
211
+ // Every entry the hub inherited is still present (enrichment appends, never
212
+ // drops). process.env.PATH is always set in the test runner.
213
+ for (const entry of (process.env.PATH ?? "").split(":").filter((e) => e.length > 0)) {
214
+ expect(req.env?.PATH?.split(":")).toContain(entry);
215
+ }
216
+ });
217
+
218
+ test("a per-service .env PATH wins over the injected enrichment (operator intent)", () => {
219
+ mkdirSync(join(h.dir, "vault"), { recursive: true });
220
+ writeFileSync(join(h.dir, "vault", ".env"), "PATH=/operator/pinned/bin\n");
221
+ const req = buildModuleSpawnRequest("vault", VAULT_ENTRY, ["parachute-vault", "serve"], {
222
+ configDir: h.dir,
223
+ });
224
+ expect(req.env?.PATH).toBe("/operator/pinned/bin");
225
+ });
226
+
227
+ test("the API-start path (buildModuleSpawnRequest reuse) also carries the enriched PATH", () => {
228
+ // handleStart() in api-modules-ops.ts routes through buildModuleSpawnRequest,
229
+ // so the /api/modules/:short/start path inherits the same PATH fix. Assert
230
+ // the shared builder is the single source — extraEnv (the start handler's
231
+ // spawnEnv seam) does NOT clobber PATH unless it explicitly sets one.
232
+ const req = buildModuleSpawnRequest("vault", VAULT_ENTRY, ["parachute-vault", "serve"], {
233
+ configDir: h.dir,
234
+ extraEnv: { SOME_FLAG: "1" },
235
+ });
236
+ expect(req.env?.PATH).toBeDefined();
237
+ expect(req.env?.SOME_FLAG).toBe("1");
238
+ });
239
+
167
240
  test("hubOrigin wins over a stale .env entry on collision", async () => {
168
241
  writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
169
242
  mkdirSync(join(h.dir, "vault"), { recursive: true });