@openparachute/hub 0.6.3 → 0.6.4-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +609 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +125 -4
- package/src/__tests__/api-invites.test.ts +180 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +187 -1
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +5 -4
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +628 -13
- package/src/__tests__/hub-unit.test.ts +4 -0
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +32 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +177 -0
- package/src/__tests__/users.test.ts +27 -0
- package/src/account-home-ui.ts +82 -9
- package/src/account-setup.ts +342 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +27 -2
- package/src/admin-login-ui.ts +94 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +54 -1
- package/src/api-invites.ts +347 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +122 -32
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +399 -41
- package/src/hub-unit.ts +4 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +8 -3
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +84 -7
- package/src/users.ts +42 -4
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
|
@@ -260,6 +260,10 @@ describe("installAndStartHubUnit — init bringup (§3.3 / §4.2)", () => {
|
|
|
260
260
|
expect(written).toBeDefined();
|
|
261
261
|
expect(written).toContain("Environment=PARACHUTE_HOME=/home/op/.parachute");
|
|
262
262
|
expect(written).toContain("src/cli.ts serve");
|
|
263
|
+
// The default unit PATH is enriched with operator-tool dirs so the managed
|
|
264
|
+
// hub + its supervised children can find scribe's parakeet-mlx / ffmpeg
|
|
265
|
+
// (hub launchd-PATH regression). $HOME/.local/bin is the Linux operator dir.
|
|
266
|
+
expect(written).toContain("/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin:/home/op/.local/bin");
|
|
263
267
|
// enable --now drove the start.
|
|
264
268
|
expect(f.calls).toContainEqual([
|
|
265
269
|
"systemctl",
|
|
@@ -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
|
+
});
|
|
@@ -490,6 +490,12 @@ describe("teardownHubUnit (§7.4)", () => {
|
|
|
490
490
|
const log: string[] = [];
|
|
491
491
|
const res = teardownHubUnit({
|
|
492
492
|
log: (l) => log.push(l),
|
|
493
|
+
// hub#535: inject fake deps so teardown NEVER falls back to the production
|
|
494
|
+
// default Runner (which would shell out to the real launchctl/systemctl).
|
|
495
|
+
// The injected fakes below mean no service-manager call is even attempted,
|
|
496
|
+
// but pinning `deps` removes the latent "forgot to inject → real bootout"
|
|
497
|
+
// hazard at the source.
|
|
498
|
+
deps: fakeHubUnitDeps(),
|
|
493
499
|
remove: (opts): ManagedUnitRemoveResult => {
|
|
494
500
|
removeArgs = { launchdLabel: opts.launchdLabel, systemdUnitName: opts.systemdUnitName };
|
|
495
501
|
return { removed: true, messages: [opts.removedSystemdMessage(opts.systemdUnitName)] };
|
|
@@ -514,6 +520,9 @@ describe("teardownHubUnit (§7.4)", () => {
|
|
|
514
520
|
const log: string[] = [];
|
|
515
521
|
const res = teardownHubUnit({
|
|
516
522
|
log: (l) => log.push(l),
|
|
523
|
+
// hub#535: inject fake deps (see the success-path test above) so this never
|
|
524
|
+
// reaches the production default Runner / real service manager.
|
|
525
|
+
deps: fakeHubUnitDeps(),
|
|
517
526
|
remove: (): ManagedUnitRemoveResult => ({ removed: false, messages: [] }),
|
|
518
527
|
disableStaleModuleUnits: () => {
|
|
519
528
|
staleCalled = true;
|
|
@@ -526,6 +535,29 @@ describe("teardownHubUnit (§7.4)", () => {
|
|
|
526
535
|
expect(staleCalled).toBe(true);
|
|
527
536
|
expect(log.join("\n")).toContain("nothing to tear down");
|
|
528
537
|
});
|
|
538
|
+
|
|
539
|
+
test("removal failure (removed:false WITH messages) → surfaces the reason, not 'nothing installed' (hub#534)", () => {
|
|
540
|
+
// A future / defensive `removeManagedUnit` that returns removed:false WITH a
|
|
541
|
+
// failure reason must NOT be reported as the benign "nothing was installed"
|
|
542
|
+
// line — the operator (and the CLI's non-zero exit) need the real detail.
|
|
543
|
+
const log: string[] = [];
|
|
544
|
+
const res = teardownHubUnit({
|
|
545
|
+
log: (l) => log.push(l),
|
|
546
|
+
deps: fakeHubUnitDeps(),
|
|
547
|
+
remove: (): ManagedUnitRemoveResult => ({
|
|
548
|
+
removed: false,
|
|
549
|
+
messages: ["systemctl disable failed: permission denied"],
|
|
550
|
+
}),
|
|
551
|
+
disableStaleModuleUnits: () => ({ actions: [] }),
|
|
552
|
+
});
|
|
553
|
+
expect(res.removed).toBe(false);
|
|
554
|
+
expect(res.messages).toEqual(["systemctl disable failed: permission denied"]);
|
|
555
|
+
const out = log.join("\n");
|
|
556
|
+
expect(out).toContain("did not complete");
|
|
557
|
+
expect(out).toContain("permission denied");
|
|
558
|
+
// Must NOT claim nothing was installed when there was a real failure.
|
|
559
|
+
expect(out).not.toContain("nothing to tear down");
|
|
560
|
+
});
|
|
529
561
|
});
|
|
530
562
|
|
|
531
563
|
// ===========================================================================
|
|
@@ -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 });
|