@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
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
|
-
|
|
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 ??
|
|
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
|
+
}
|
package/src/managed-unit.ts
CHANGED
|
@@ -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
|
|
663
|
-
*
|
|
664
|
-
*
|
|
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
|
package/src/migrate-offer.ts
CHANGED
|
@@ -22,11 +22,15 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { existsSync } from "node:fs";
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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";
|
package/src/module-ops-client.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
:
|
|
409
|
-
|
|
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 :
|
|
450
|
+
typeof b.error_description === "string" ? b.error_description : fallback;
|
|
426
451
|
return { error, error_description };
|
|
427
452
|
}
|
|
428
|
-
return { error: "error", error_description:
|
|
453
|
+
return { error: "error", error_description: fallback };
|
|
429
454
|
}
|
|
430
455
|
|
|
431
456
|
function extractOperationId(body: unknown): string | undefined {
|