@openparachute/hub 0.6.5-rc.4 → 0.6.5-rc.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.6.5-rc.4",
3
+ "version": "0.6.5-rc.6",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -1,6 +1,14 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { describe, expect, test } from "bun:test";
3
- import { classifyDbError, createDbHolder, probeDbLiveness } from "../hub-db-liveness.ts";
3
+ import {
4
+ type DbInode,
5
+ type StatInodeFn,
6
+ classifyDbError,
7
+ classifyPathLiveness,
8
+ createDbHolder,
9
+ probeDbLiveness,
10
+ startDbPathLivenessTimer,
11
+ } from "../hub-db-liveness.ts";
4
12
 
5
13
  /** Build a `SQLiteError`-shaped object with the given code + message. */
6
14
  function sqliteErr(code: string, message: string): Error & { code: string } {
@@ -137,3 +145,209 @@ describe("createDbHolder (#594)", () => {
137
145
  initial.close();
138
146
  });
139
147
  });
148
+
149
+ const INODE_A: DbInode = { dev: 1, ino: 100 };
150
+ const INODE_B: DbInode = { dev: 1, ino: 200 };
151
+
152
+ describe("classifyPathLiveness (#610)", () => {
153
+ test("same inode → ok", () => {
154
+ expect(classifyPathLiveness({ expected: INODE_A, current: INODE_A })).toBe("ok");
155
+ expect(classifyPathLiveness({ expected: INODE_A, current: { ...INODE_A } })).toBe("ok");
156
+ });
157
+ test("ENOENT on the path (current undefined) → gone", () => {
158
+ expect(classifyPathLiveness({ expected: INODE_A, current: undefined })).toBe("gone");
159
+ });
160
+ test("different inode → replaced", () => {
161
+ expect(classifyPathLiveness({ expected: INODE_A, current: INODE_B })).toBe("replaced");
162
+ // a different device counts too
163
+ expect(classifyPathLiveness({ expected: INODE_A, current: { dev: 2, ino: 100 } })).toBe(
164
+ "replaced",
165
+ );
166
+ });
167
+ test("no baseline snapshot (expected undefined) → unknown, never self-heals", () => {
168
+ expect(classifyPathLiveness({ expected: undefined, current: INODE_A })).toBe("unknown");
169
+ expect(classifyPathLiveness({ expected: undefined, current: undefined })).toBe("unknown");
170
+ });
171
+ });
172
+
173
+ describe("DbHolder.probePath (#610 proactive detection)", () => {
174
+ /** A holder whose path stat is driven by the injected `statInode`. */
175
+ function makeHolder(opts: {
176
+ initialInode: DbInode | undefined;
177
+ statInode: StatInodeFn;
178
+ onReopen?: () => Database;
179
+ }) {
180
+ const initial = new Database(":memory:");
181
+ let reopens = 0;
182
+ let exits = 0;
183
+ let exitCode: number | undefined;
184
+ const holder = createDbHolder(initial, {
185
+ dbPath: "/fake/hub.db",
186
+ initialInode: opts.initialInode,
187
+ statInode: opts.statInode,
188
+ reopen: () => {
189
+ reopens += 1;
190
+ return opts.onReopen ? opts.onReopen() : new Database(":memory:");
191
+ },
192
+ exit: (code) => {
193
+ exits += 1;
194
+ exitCode = code;
195
+ },
196
+ log: () => {},
197
+ });
198
+ return {
199
+ holder,
200
+ stats: () => ({ reopens, exits, exitCode }),
201
+ cleanup: () => {
202
+ try {
203
+ initial.close();
204
+ } catch {}
205
+ },
206
+ };
207
+ }
208
+
209
+ test("healthy path (same inode) → no reopen, no exit", () => {
210
+ const h = makeHolder({ initialInode: INODE_A, statInode: () => INODE_A });
211
+ expect(h.holder.probePath()).toBe("ok");
212
+ expect(h.stats().reopens).toBe(0);
213
+ expect(h.stats().exits).toBe(0);
214
+ h.cleanup();
215
+ });
216
+
217
+ test("path GONE (ENOENT) → reopen attempted; reopen verify fails → exit(1)", () => {
218
+ // Reopen returns a closed handle (the dir is still gone) → SELECT 1 throws
219
+ // → exit. This is the genuine `rm -rf ~/.parachute` field shape.
220
+ const dead = new Database(":memory:");
221
+ dead.close();
222
+ const h = makeHolder({
223
+ initialInode: INODE_A,
224
+ statInode: () => undefined, // ENOENT
225
+ onReopen: () => dead,
226
+ });
227
+ expect(h.holder.probePath()).toBe("gone");
228
+ expect(h.stats().reopens).toBe(1);
229
+ expect(h.stats().exits).toBe(1);
230
+ expect(h.stats().exitCode).toBe(1);
231
+ h.cleanup();
232
+ });
233
+
234
+ test("path REPLACED (different inode) → reopen + swap (heals, no exit)", () => {
235
+ const h = makeHolder({
236
+ initialInode: INODE_A,
237
+ statInode: () => INODE_B, // path now resolves to a different inode
238
+ onReopen: () => new Database(":memory:"),
239
+ });
240
+ expect(h.holder.probePath()).toBe("replaced");
241
+ expect(h.stats().reopens).toBe(1);
242
+ expect(h.stats().exits).toBe(0);
243
+ h.cleanup();
244
+ });
245
+
246
+ test("NEVER fires on a transient stat throw (EACCES) — returns ok, no reopen/exit", () => {
247
+ const h = makeHolder({
248
+ initialInode: INODE_A,
249
+ statInode: () => {
250
+ const e = new Error("permission denied") as Error & { code: string };
251
+ e.code = "EACCES";
252
+ throw e;
253
+ },
254
+ });
255
+ expect(h.holder.probePath()).toBe("ok");
256
+ expect(h.stats().reopens).toBe(0);
257
+ expect(h.stats().exits).toBe(0);
258
+ h.cleanup();
259
+ });
260
+
261
+ test("no baseline inode → unknown, never self-heals (safe degradation)", () => {
262
+ const h = makeHolder({ initialInode: undefined, statInode: () => undefined });
263
+ expect(h.holder.probePath()).toBe("unknown");
264
+ expect(h.stats().reopens).toBe(0);
265
+ expect(h.stats().exits).toBe(0);
266
+ h.cleanup();
267
+ });
268
+
269
+ test("no dbPath configured → probePath is a no-op (unknown)", () => {
270
+ const initial = new Database(":memory:");
271
+ const holder = createDbHolder(initial, {
272
+ reopen: () => new Database(":memory:"),
273
+ exit: () => {},
274
+ log: () => {},
275
+ });
276
+ expect(holder.probePath()).toBe("unknown");
277
+ initial.close();
278
+ });
279
+
280
+ test("after a heal (replaced), the inode baseline is re-snapshotted to the new file", () => {
281
+ // First probe sees INODE_B (replaced) → reopen; statInode then returns
282
+ // INODE_B again so the NEXT probe sees the SAME inode → ok (not a loop).
283
+ let exits = 0;
284
+ const initial = new Database(":memory:");
285
+ const holder = createDbHolder(initial, {
286
+ dbPath: "/fake/hub.db",
287
+ initialInode: INODE_A,
288
+ statInode: () => INODE_B,
289
+ reopen: () => new Database(":memory:"),
290
+ exit: () => {
291
+ exits += 1;
292
+ },
293
+ log: () => {},
294
+ });
295
+ expect(holder.probePath()).toBe("replaced"); // A → B, heal
296
+ expect(holder.probePath()).toBe("ok"); // B → B, no further action
297
+ expect(exits).toBe(0);
298
+ initial.close();
299
+ });
300
+ });
301
+
302
+ describe("startDbPathLivenessTimer (#610 bounded watchdog)", () => {
303
+ test("each tick calls probePath exactly once; stop() clears the timer", () => {
304
+ let probes = 0;
305
+ const fakeHolder = {
306
+ get: () => new Database(":memory:"),
307
+ healOrExit: () => "ignored" as const,
308
+ probePath: () => {
309
+ probes += 1;
310
+ return "ok" as const;
311
+ },
312
+ };
313
+ let registered: (() => void) | undefined;
314
+ let cleared = false;
315
+ const timer = startDbPathLivenessTimer<number>(fakeHolder, {
316
+ setIntervalFn: (cb) => {
317
+ registered = cb;
318
+ return 42;
319
+ },
320
+ clearIntervalFn: (h) => {
321
+ expect(h).toBe(42);
322
+ cleared = true;
323
+ },
324
+ });
325
+ expect(registered).toBeDefined();
326
+ registered?.();
327
+ registered?.();
328
+ expect(probes).toBe(2);
329
+ timer.stop();
330
+ expect(cleared).toBe(true);
331
+ });
332
+
333
+ test("a probe that throws is swallowed (the timer callback never crashes the process)", () => {
334
+ const fakeHolder = {
335
+ get: () => new Database(":memory:"),
336
+ healOrExit: () => "ignored" as const,
337
+ probePath: (): "ok" => {
338
+ throw new Error("unexpected");
339
+ },
340
+ };
341
+ let registered: (() => void) | undefined;
342
+ startDbPathLivenessTimer<number>(fakeHolder, {
343
+ setIntervalFn: (cb) => {
344
+ registered = cb;
345
+ return 1;
346
+ },
347
+ clearIntervalFn: () => {},
348
+ log: () => {},
349
+ });
350
+ // Must NOT throw out of the callback.
351
+ expect(() => registered?.()).not.toThrow();
352
+ });
353
+ });
@@ -110,6 +110,69 @@ describe("hubFetch routing", () => {
110
110
  }
111
111
  });
112
112
 
113
+ test("/health reports db:ok when getDb is live and the proactive path probe is ok (#610)", async () => {
114
+ const h = makeHarness();
115
+ try {
116
+ const db = openHubDb(hubDbPath(h.dir));
117
+ try {
118
+ const res = await hubFetch(h.dir, {
119
+ getDb: () => db,
120
+ probeDbPath: () => "ok",
121
+ })(req("/health"));
122
+ expect(res.status).toBe(200);
123
+ const body = (await res.json()) as { status: string; db: string };
124
+ expect(body.status).toBe("ok");
125
+ expect(body.db).toBe("ok");
126
+ } finally {
127
+ db.close();
128
+ }
129
+ } finally {
130
+ h.cleanup();
131
+ }
132
+ });
133
+
134
+ test("/health surfaces db:error:path-gone when the proactive probe sees a wiped path (#610)", async () => {
135
+ // The ghost-fd lie: SELECT 1 still succeeds against the unlinked inode, so
136
+ // probeDbLiveness alone would report ok. probeDbPath stat()s the PATH and
137
+ // returns "gone" → /health must report the fault instead of lying.
138
+ const h = makeHarness();
139
+ try {
140
+ const db = openHubDb(hubDbPath(h.dir));
141
+ try {
142
+ const res = await hubFetch(h.dir, {
143
+ getDb: () => db,
144
+ probeDbPath: () => "gone",
145
+ })(req("/health"));
146
+ expect(res.status).toBe(200); // /health stays 200 (process liveness)
147
+ const body = (await res.json()) as { db: string };
148
+ expect(body.db).toBe("error: path-gone");
149
+ } finally {
150
+ db.close();
151
+ }
152
+ } finally {
153
+ h.cleanup();
154
+ }
155
+ });
156
+
157
+ test("/health surfaces db:error:path-replaced when the proactive probe sees an inode swap (#610)", async () => {
158
+ const h = makeHarness();
159
+ try {
160
+ const db = openHubDb(hubDbPath(h.dir));
161
+ try {
162
+ const res = await hubFetch(h.dir, {
163
+ getDb: () => db,
164
+ probeDbPath: () => "replaced",
165
+ })(req("/health"));
166
+ const body = (await res.json()) as { db: string };
167
+ expect(body.db).toBe("error: path-replaced");
168
+ } finally {
169
+ db.close();
170
+ }
171
+ } finally {
172
+ h.cleanup();
173
+ }
174
+ });
175
+
113
176
  test("/ renders the signed-out indicator dynamically when DB is configured but no session cookie (rc.13)", async () => {
114
177
  // The dynamic path takes over from the static disk file the moment a
115
178
  // DB is configured. With no session cookie, we still render — just
@@ -377,6 +377,127 @@ describe("install", () => {
377
377
  }
378
378
  });
379
379
 
380
+ test("ADOPT-KILLS an attributable same-module orphan on the canonical port + reclaims it (#609)", async () => {
381
+ // Wipe-recovery: `rm -rf ~/.parachute` + re-`init` leaves the supervised
382
+ // vault child running on :1940. The fresh install must reclaim the canonical
383
+ // port (adopt-kill the attributable orphan) rather than port-walk to 1944.
384
+ const { path, configDir, cleanup } = makeTempPath();
385
+ try {
386
+ const logs: string[] = [];
387
+ const kills: Array<{ pid: number; signal: string | number }> = [];
388
+ // installDir = /opt/.parachute/vault → the orphan's cmdline carries it, so
389
+ // attribution (per-module marker = installDir) succeeds.
390
+ const installDirPkg = "/opt/.parachute/vault/package.json";
391
+ // Port 1940 is held UNTIL the SIGTERM lands; after the kill the re-probe
392
+ // (collectOccupiedPorts) sees it free, so the assignment lands on 1940.
393
+ let killed = false;
394
+ const code = await install("vault", {
395
+ runner: async () => 0,
396
+ manifestPath: path,
397
+ configDir,
398
+ startService: async () => 0,
399
+ isLinked: () => false,
400
+ findGlobalInstall: () => installDirPkg,
401
+ portProbe: async (p) => p === 1940 && !killed,
402
+ pidOnPort: (p) => (p === 1940 && !killed ? 7777 : undefined),
403
+ ownerOfPid: (pid) =>
404
+ pid === 7777 ? "parachute-vault --port 1940 (/opt/.parachute/vault)" : undefined,
405
+ killPid: (pid, signal) => {
406
+ kills.push({ pid, signal });
407
+ killed = true; // the orphan releases the port on SIGTERM
408
+ },
409
+ sleep: async () => {},
410
+ reclaimDelayMs: 0,
411
+ log: (l) => logs.push(l),
412
+ });
413
+ expect(code).toBe(0);
414
+ const joined = logs.join("\n");
415
+ // We adopt-killed the attributable orphan…
416
+ expect(joined).toMatch(/attributable prior vault instance \(pid 7777/);
417
+ expect(joined).toMatch(/reclaiming it \(adopt-kill\)/);
418
+ expect(kills.map((k) => k.signal)).toContain("SIGTERM");
419
+ // …and the fresh install landed on the CANONICAL port, not a fallback.
420
+ const entry = findService("parachute-vault", path);
421
+ expect(entry?.port).toBe(1940);
422
+ expect(joined).not.toMatch(/is in use; assigned/);
423
+ } finally {
424
+ cleanup();
425
+ }
426
+ });
427
+
428
+ test("escalates to SIGKILL when the orphan ignores SIGTERM (#609)", async () => {
429
+ const { path, configDir, cleanup } = makeTempPath();
430
+ try {
431
+ const logs: string[] = [];
432
+ const signals: Array<string | number> = [];
433
+ const code = await install("vault", {
434
+ runner: async () => 0,
435
+ manifestPath: path,
436
+ configDir,
437
+ startService: async () => 0,
438
+ isLinked: () => false,
439
+ findGlobalInstall: () => "/opt/.parachute/vault/package.json",
440
+ // Orphan never releases 1940 → install ultimately walks (degrades
441
+ // gracefully), but it MUST have escalated to SIGKILL first.
442
+ portProbe: async (p) => p === 1940,
443
+ pidOnPort: (p) => (p === 1940 ? 8888 : undefined),
444
+ ownerOfPid: (pid) => (pid === 8888 ? "parachute-vault (/opt/.parachute/vault)" : undefined),
445
+ killPid: (_pid, signal) => {
446
+ signals.push(signal);
447
+ },
448
+ sleep: async () => {},
449
+ reclaimDelayMs: 0,
450
+ log: (l) => logs.push(l),
451
+ });
452
+ expect(code).toBe(0);
453
+ expect(signals).toContain("SIGTERM");
454
+ expect(signals).toContain("SIGKILL");
455
+ expect(logs.join("\n")).toMatch(/escalated to SIGKILL/);
456
+ } finally {
457
+ cleanup();
458
+ }
459
+ });
460
+
461
+ test("does NOT kill a FOREIGN holder on the canonical port — walks + warns instead (#609 safety)", async () => {
462
+ // The crux: a non-attributable holder (an operator's unrelated process, or a
463
+ // sibling module) on :1940 must NEVER be killed. We fall through to the #590
464
+ // warn-and-walk path unchanged.
465
+ const { path, configDir, cleanup } = makeTempPath();
466
+ try {
467
+ const logs: string[] = [];
468
+ let killCalled = false;
469
+ const code = await install("vault", {
470
+ runner: async () => 0,
471
+ manifestPath: path,
472
+ configDir,
473
+ startService: async () => 0,
474
+ isLinked: () => false,
475
+ findGlobalInstall: () => "/opt/.parachute/vault/package.json",
476
+ portProbe: async (p) => p === 1940,
477
+ pidOnPort: (p) => (p === 1940 ? 5555 : undefined),
478
+ // Foreign cmdline — does NOT contain the vault installDir marker.
479
+ ownerOfPid: (pid) => (pid === 5555 ? "/usr/bin/python3 /opt/my-own-server.py" : undefined),
480
+ killPid: () => {
481
+ killCalled = true;
482
+ },
483
+ sleep: async () => {},
484
+ reclaimDelayMs: 0,
485
+ log: (l) => logs.push(l),
486
+ });
487
+ expect(code).toBe(0);
488
+ expect(killCalled).toBe(false); // NEVER kill a foreign process
489
+ const joined = logs.join("\n");
490
+ // The #590 warn-and-walk path is unchanged for the foreign holder.
491
+ expect(joined).toMatch(/canonical port 1940 is in use; assigned/);
492
+ expect(joined).toContain("pid 5555 (/usr/bin/python3 /opt/my-own-server.py)");
493
+ expect(joined).toMatch(/stale pre-supervisor daemon/);
494
+ const entry = findService("parachute-vault", path);
495
+ expect(entry?.port).not.toBe(1940); // walked, not reclaimed
496
+ } finally {
497
+ cleanup();
498
+ }
499
+ });
500
+
380
501
  test("squatter pid present but command line unreadable → names the pid alone (#590)", async () => {
381
502
  const { path, configDir, cleanup } = makeTempPath();
382
503
  try {
@@ -179,6 +179,46 @@ describe("deriveWizardState", () => {
179
179
  }
180
180
  });
181
181
 
182
+ test("vault step when admin exists and only the SEED placeholder vault row is present (hub#607)", async () => {
183
+ // `parachute init` seeds a `parachute-vault` placeholder into
184
+ // services.json at SEED_VERSION ("0.0.0-linked") under hub#168 Cut 1
185
+ // (`noCreate`): the MODULE is installed, but no instance exists yet.
186
+ // Pre-#607, `hasVault` keyed off a bare `findService(...) !== undefined`
187
+ // check, which the placeholder satisfied — so the wizard silently
188
+ // skipped its vault step on EVERY init'd box and the operator finished
189
+ // setup with no vault. The placeholder must NOT count as a real vault.
190
+ const db = openHubDb(hubDbPath(h.dir));
191
+ try {
192
+ await createUser(db, "owner", "pw");
193
+ writeManifest(
194
+ {
195
+ services: [
196
+ {
197
+ name: "parachute-vault",
198
+ version: "0.0.0-linked",
199
+ port: 1940,
200
+ paths: ["/vault/default"],
201
+ health: "/health",
202
+ },
203
+ ],
204
+ },
205
+ h.manifestPath,
206
+ );
207
+ const s = deriveWizardState({
208
+ db,
209
+ manifestPath: h.manifestPath,
210
+ readExposeStateFn: h.readExposeStateFn,
211
+ });
212
+ // The placeholder is module-installed-but-no-instance, so the wizard
213
+ // still owns vault creation: it presents the create/import/skip step.
214
+ expect(s.step).toBe("vault");
215
+ expect(s.hasAdmin).toBe(true);
216
+ expect(s.hasVault).toBe(false);
217
+ } finally {
218
+ db.close();
219
+ }
220
+ });
221
+
182
222
  test("expose step when admin + vault exist but expose mode not set yet (hub#268 Item 2)", async () => {
183
223
  const db = openHubDb(hubDbPath(h.dir));
184
224
  try {
@@ -1506,6 +1546,105 @@ describe("handleSetupVaultPost", () => {
1506
1546
  }
1507
1547
  });
1508
1548
 
1549
+ test("create on a SEED-placeholder box: does NOT short-circuit + drives the supervisor to start vault (hub#607 + hub#608)", async () => {
1550
+ // hub#607 + hub#608 coupled fresh-operator flow. On an init'd box,
1551
+ // services.json already carries a `parachute-vault` placeholder at
1552
+ // SEED_VERSION ("0.0.0-linked") — the MODULE is installed, no instance
1553
+ // exists. With the hub#607 `hasVault` discrimination, the wizard's vault
1554
+ // step still appears (the placeholder isn't a real vault), so a
1555
+ // `mode=create` POST must NOT be treated as "already provisioned" and
1556
+ // short-circuit to expose. It runs `runInstall`, which seeds/stamps the
1557
+ // row and — the hub#608 fix — drives `supervisor.start(...)` so the
1558
+ // freshly-created vault is ACTIVE immediately, not inactive-until-
1559
+ // restart. We assert both: the install op fired (no short-circuit) AND
1560
+ // the supervisor now reports a live vault child.
1561
+ const db = openHubDb(hubDbPath(h.dir));
1562
+ try {
1563
+ const user = await createUser(db, "owner", "pw");
1564
+ const { createSession, SESSION_COOKIE_NAME: SC } = await import("../sessions.ts");
1565
+ const session = createSession(db, { userId: user.id });
1566
+ // Simulate `parachute init`: the vault MODULE is seeded as a
1567
+ // placeholder, no supervisor entry yet (init ran with noStart).
1568
+ writeManifest(
1569
+ {
1570
+ services: [
1571
+ {
1572
+ name: "parachute-vault",
1573
+ version: "0.0.0-linked",
1574
+ port: 1940,
1575
+ paths: ["/vault/default"],
1576
+ health: "/health",
1577
+ },
1578
+ ],
1579
+ },
1580
+ h.manifestPath,
1581
+ );
1582
+ const get = handleSetupGet(req("/admin/setup"), {
1583
+ db,
1584
+ manifestPath: h.manifestPath,
1585
+ configDir: h.dir,
1586
+ readExposeStateFn: h.readExposeStateFn,
1587
+ issuer: "https://hub.example",
1588
+ registry: getDefaultOperationsRegistry(),
1589
+ });
1590
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
1591
+ const supervisor = makeSupervisor();
1592
+ // Sanity: no vault child before the wizard create.
1593
+ expect(supervisor.get("vault")).toBeUndefined();
1594
+ const runCalls: string[][] = [];
1595
+ const stubbedRun = async (cmd: readonly string[]) => {
1596
+ runCalls.push([...cmd]);
1597
+ return 0;
1598
+ };
1599
+ const post = await handleSetupVaultPost(
1600
+ req("/admin/setup/vault", {
1601
+ method: "POST",
1602
+ body: new URLSearchParams({
1603
+ [CSRF_FIELD_NAME]: csrf,
1604
+ mode: "create",
1605
+ vault_name: "myvault",
1606
+ scribe_provider: "none",
1607
+ }).toString(),
1608
+ headers: {
1609
+ "content-type": "application/x-www-form-urlencoded",
1610
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SC}=${session.id}`,
1611
+ },
1612
+ }),
1613
+ {
1614
+ db,
1615
+ manifestPath: h.manifestPath,
1616
+ configDir: h.dir,
1617
+ readExposeStateFn: h.readExposeStateFn,
1618
+ issuer: "https://hub.example",
1619
+ supervisor,
1620
+ registry: getDefaultOperationsRegistry(),
1621
+ run: stubbedRun,
1622
+ isLinked: () => false,
1623
+ },
1624
+ );
1625
+ // Not short-circuited: the placeholder is not a real vault, so the
1626
+ // POST enqueues an install op rather than redirecting to expose.
1627
+ expect(post.status).toBe(303);
1628
+ const location = post.headers.get("location") ?? "";
1629
+ expect(location).toMatch(/^\/admin\/setup\?op=/);
1630
+ // Let the background runInstall promise reach the runner + supervisor.
1631
+ await new Promise((r) => setTimeout(r, 50));
1632
+ // #607 proof: the install actually ran (not the "already provisioned"
1633
+ // short-circuit, which fires no `bun add`).
1634
+ expect(runCalls.some((c) => c.join(" ").includes("bun add -g @openparachute/vault"))).toBe(
1635
+ true,
1636
+ );
1637
+ // #608 proof: the supervisor was driven to start the vault child, so
1638
+ // the vault is live immediately after the wizard create — no manual
1639
+ // `parachute start vault` / hub restart needed.
1640
+ const vaultState = supervisor.get("vault");
1641
+ expect(vaultState).toBeDefined();
1642
+ expect(["starting", "running", "restarting"]).toContain(vaultState?.status ?? "");
1643
+ } finally {
1644
+ db.close();
1645
+ }
1646
+ });
1647
+
1509
1648
  // --- scribe cleanup sub-form (2026-05-27) -----------------------------
1510
1649
  //
1511
1650
  // The vault step's scribe sub-form was extended with a second radio