@openparachute/hub 0.7.4-rc.16 → 0.7.4-rc.18

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.
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { type CheckResult, type DoctorDeps, doctor } from "../commands/doctor.ts";
@@ -462,3 +462,294 @@ describe("doctor — version drift (cosmetic; never FAIL)", () => {
462
462
  }
463
463
  });
464
464
  });
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Canonical-port-drift detection + `doctor --fix` repair (#267 doctor sub-item)
468
+ // ---------------------------------------------------------------------------
469
+
470
+ /** Write a services.json with the given rows (verbatim — for drift fixtures). */
471
+ function writeManifestRows(manifestPath: string, services: unknown[]): void {
472
+ writeFileSync(manifestPath, JSON.stringify({ services }, null, 2));
473
+ }
474
+
475
+ /** Read services.json back as parsed rows for post-fix assertions. */
476
+ function readRows(manifestPath: string): Record<string, unknown>[] {
477
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as {
478
+ services: Record<string, unknown>[];
479
+ };
480
+ return parsed.services;
481
+ }
482
+
483
+ /** Run `doctor --fix`, capturing printed lines + exit code. */
484
+ async function runFix(
485
+ h: Harness,
486
+ over: Partial<DoctorDeps> = {},
487
+ flags: { yes?: boolean } = {},
488
+ ): Promise<{ code: number; lines: string[] }> {
489
+ const lines: string[] = [];
490
+ const code = await doctor({
491
+ configDir: h.configDir,
492
+ manifestPath: h.manifestPath,
493
+ print: (l) => lines.push(l),
494
+ fix: true,
495
+ yes: flags.yes ?? false,
496
+ deps: healthyDeps(over),
497
+ });
498
+ return { code, lines };
499
+ }
500
+
501
+ describe("doctor — canonical-port-drift detection (read-only)", () => {
502
+ test("a non-canonical port + a duplicate-port pair → port-drift WARNs naming the services", async () => {
503
+ const h = makeHarness();
504
+ try {
505
+ // scribe drifted off 1943 onto 1944; agent also squats 1944 (a collision).
506
+ writeManifestRows(h.manifestPath, [
507
+ {
508
+ name: "parachute-vault",
509
+ port: 1940,
510
+ paths: ["/vault/default"],
511
+ health: "/h",
512
+ version: "1",
513
+ },
514
+ { name: "parachute-scribe", port: 1944, paths: ["/scribe"], health: "/h", version: "1" },
515
+ { name: "parachute-agent", port: 1944, paths: ["/agent"], health: "/h", version: "1" },
516
+ ]);
517
+ seedOperatorToken(h.configDir);
518
+ const { code, checks } = await runDoctor(h, healthyDeps());
519
+ const pd = byName(checks, "port-drift");
520
+ expect(pd?.status).toBe("warn");
521
+ // Names the drifted service AND the colliding pair.
522
+ expect(pd?.detail).toContain("scribe");
523
+ expect(pd?.detail).toContain("1944");
524
+ expect(pd?.detail).toContain("parachute-scribe + parachute-agent");
525
+ expect(pd?.fix).toBe("parachute doctor --fix");
526
+ // Drift is advisory — exit stays 0 (a WARN, not a FAIL). The duplicate
527
+ // rows also trip modules-alive (both can't bind 1944) but that's expected
528
+ // for this fixture; we only assert on port-drift here.
529
+ expect([0, 1]).toContain(code);
530
+ } finally {
531
+ h.cleanup();
532
+ }
533
+ });
534
+
535
+ test("a clean file → port-drift PASSES with no drift", async () => {
536
+ const h = makeHarness();
537
+ try {
538
+ seedCurrentManifest(h.manifestPath);
539
+ seedOperatorToken(h.configDir);
540
+ const { code, checks } = await runDoctor(h, healthyDeps());
541
+ const pd = byName(checks, "port-drift");
542
+ expect(pd?.status).toBe("pass");
543
+ expect(pd?.detail).toContain("canonical");
544
+ expect(code).toBe(0);
545
+ expectNoUnexpectedNonPass(checks, []);
546
+ } finally {
547
+ h.cleanup();
548
+ }
549
+ });
550
+
551
+ test("a third-party service with NO canonical port is not flagged", async () => {
552
+ const h = makeHarness();
553
+ try {
554
+ // An unknown module on a non-1939–1949 port — no canonical to drift from.
555
+ writeManifestRows(h.manifestPath, [
556
+ {
557
+ name: "parachute-vault",
558
+ port: 1940,
559
+ paths: ["/vault/default"],
560
+ health: "/h",
561
+ version: "1",
562
+ },
563
+ { name: "acme-thing", port: 5000, paths: ["/acme"], health: "/h", version: "1" },
564
+ ]);
565
+ seedOperatorToken(h.configDir);
566
+ const { code, checks } = await runDoctor(h, healthyDeps());
567
+ const pd = byName(checks, "port-drift");
568
+ expect(pd?.status).toBe("pass");
569
+ expect(code).toBe(0);
570
+ } finally {
571
+ h.cleanup();
572
+ }
573
+ });
574
+
575
+ test("a named multi-vault row sharing 1940 is NOT flagged (drift or duplicate)", async () => {
576
+ const h = makeHarness();
577
+ try {
578
+ // A legit multi-vault setup: the canonical vault row plus a second named
579
+ // vault instance, both on 1940 (the documented carve-out). Neither should
580
+ // be flagged as drifted (named vault rows have no canonical port) nor as a
581
+ // duplicate-port collision (all-vault-on-1940 is by design).
582
+ writeManifestRows(h.manifestPath, [
583
+ {
584
+ name: "parachute-vault",
585
+ port: 1940,
586
+ paths: ["/vault/default"],
587
+ health: "/h",
588
+ version: "1",
589
+ },
590
+ {
591
+ name: "parachute-vault-work",
592
+ port: 1940,
593
+ paths: ["/vault/work"],
594
+ health: "/h",
595
+ version: "1",
596
+ },
597
+ ]);
598
+ seedOperatorToken(h.configDir);
599
+ const { code, checks } = await runDoctor(h, healthyDeps());
600
+ const pd = byName(checks, "port-drift");
601
+ // PASS (clean) — neither flagged as drifted nor as a duplicate collision.
602
+ expect(pd?.status).toBe("pass");
603
+ expect(pd?.detail).toContain("canonical");
604
+ expect(code).toBe(0);
605
+ } finally {
606
+ h.cleanup();
607
+ }
608
+ });
609
+ });
610
+
611
+ describe("doctor --fix — canonical-port repair (confirm-gated, idempotent, non-tty-safe)", () => {
612
+ test("--fix on a clean file → 'no drift', exit 0, file unchanged (idempotent)", async () => {
613
+ const h = makeHarness();
614
+ try {
615
+ seedCurrentManifest(h.manifestPath);
616
+ const before = readFileSync(h.manifestPath, "utf8");
617
+ const { code, lines } = await runFix(h, { isInteractive: () => true }, { yes: true });
618
+ expect(code).toBe(0);
619
+ expect(lines.join("\n").toLowerCase()).toContain("nothing to fix");
620
+ expect(readFileSync(h.manifestPath, "utf8")).toBe(before);
621
+ } finally {
622
+ h.cleanup();
623
+ }
624
+ });
625
+
626
+ test("--fix on an absent services.json (fresh install) → 'nothing to fix', exit 0", async () => {
627
+ const h = makeHarness();
628
+ try {
629
+ // No services.json at all — the truly-fresh case. --fix must NOT report a
630
+ // corrupt-file error; it's the idempotent no-op path.
631
+ const { code, lines } = await runFix(h, { isInteractive: () => false }, { yes: false });
632
+ expect(code).toBe(0);
633
+ expect(lines.join("\n").toLowerCase()).toContain("nothing to fix");
634
+ expect(lines.join("\n").toLowerCase()).not.toContain("can't read");
635
+ } finally {
636
+ h.cleanup();
637
+ }
638
+ });
639
+
640
+ test("--fix --yes rewrites the drifted port to canonical + preserves other fields", async () => {
641
+ const h = makeHarness();
642
+ try {
643
+ // scribe drifted onto 1944; carries an optional displayName/tagline that
644
+ // must survive the rewrite.
645
+ writeManifestRows(h.manifestPath, [
646
+ {
647
+ name: "parachute-scribe",
648
+ port: 1944,
649
+ paths: ["/scribe"],
650
+ health: "/scribe/health",
651
+ version: "0.7.4",
652
+ displayName: "Scribe",
653
+ tagline: "Local audio transcription.",
654
+ stripPrefix: true,
655
+ },
656
+ ]);
657
+ const { code, lines } = await runFix(h, {}, { yes: true });
658
+ expect(code).toBe(0);
659
+ expect(lines.join("\n")).toContain("→ :1943");
660
+ const rows = readRows(h.manifestPath);
661
+ const scribe = rows.find((r) => r.name === "parachute-scribe");
662
+ expect(scribe?.port).toBe(1943);
663
+ // Optional + unknown fields preserved verbatim.
664
+ expect(scribe?.displayName).toBe("Scribe");
665
+ expect(scribe?.tagline).toBe("Local audio transcription.");
666
+ expect(scribe?.stripPrefix).toBe(true);
667
+
668
+ // Re-run → idempotent: now clean, exit 0, nothing to fix.
669
+ const again = await runFix(h, {}, { yes: true });
670
+ expect(again.code).toBe(0);
671
+ expect(again.lines.join("\n").toLowerCase()).toContain("nothing to fix");
672
+ } finally {
673
+ h.cleanup();
674
+ }
675
+ });
676
+
677
+ test("--fix in a TTY without --yes prompts; 'y' applies the rewrite", async () => {
678
+ const h = makeHarness();
679
+ try {
680
+ writeManifestRows(h.manifestPath, [
681
+ { name: "parachute-scribe", port: 1944, paths: ["/scribe"], health: "/h", version: "1" },
682
+ ]);
683
+ const { code } = await runFix(
684
+ h,
685
+ { isInteractive: () => true, readLine: async () => "y" },
686
+ { yes: false },
687
+ );
688
+ expect(code).toBe(0);
689
+ expect(readRows(h.manifestPath).find((r) => r.name === "parachute-scribe")?.port).toBe(1943);
690
+ } finally {
691
+ h.cleanup();
692
+ }
693
+ });
694
+
695
+ test("--fix in a TTY answered 'n' → aborts, exit non-zero, file UNCHANGED", async () => {
696
+ const h = makeHarness();
697
+ try {
698
+ writeManifestRows(h.manifestPath, [
699
+ { name: "parachute-scribe", port: 1944, paths: ["/scribe"], health: "/h", version: "1" },
700
+ ]);
701
+ const before = readFileSync(h.manifestPath, "utf8");
702
+ const { code, lines } = await runFix(
703
+ h,
704
+ { isInteractive: () => true, readLine: async () => "n" },
705
+ { yes: false },
706
+ );
707
+ expect(code).not.toBe(0);
708
+ expect(lines.join("\n").toLowerCase()).toContain("unchanged");
709
+ expect(readFileSync(h.manifestPath, "utf8")).toBe(before);
710
+ } finally {
711
+ h.cleanup();
712
+ }
713
+ });
714
+
715
+ test("--fix in a NON-TTY without --yes → bails, exit non-zero, file UNCHANGED", async () => {
716
+ const h = makeHarness();
717
+ try {
718
+ writeManifestRows(h.manifestPath, [
719
+ { name: "parachute-scribe", port: 1944, paths: ["/scribe"], health: "/h", version: "1" },
720
+ ]);
721
+ const before = readFileSync(h.manifestPath, "utf8");
722
+ const { code, lines } = await runFix(h, { isInteractive: () => false }, { yes: false });
723
+ expect(code).not.toBe(0);
724
+ expect(lines.join("\n")).toContain("--yes");
725
+ // The load-bearing guarantee: no write happened.
726
+ expect(readFileSync(h.manifestPath, "utf8")).toBe(before);
727
+ } finally {
728
+ h.cleanup();
729
+ }
730
+ });
731
+
732
+ test("--fix reports a duplicate-port collision but does not auto-resolve it", async () => {
733
+ const h = makeHarness();
734
+ try {
735
+ // Two services collide on 1944; neither is on its canonical slot. The
736
+ // diff fixes the canonical drift; the collision is reported, not guessed.
737
+ writeManifestRows(h.manifestPath, [
738
+ { name: "parachute-scribe", port: 1944, paths: ["/scribe"], health: "/h", version: "1" },
739
+ { name: "parachute-agent", port: 1944, paths: ["/agent"], health: "/h", version: "1" },
740
+ ]);
741
+ const { code, lines } = await runFix(h, {}, { yes: true });
742
+ const text = lines.join("\n");
743
+ expect(text.toLowerCase()).toContain("shared by");
744
+ expect(text).toContain("parachute-scribe + parachute-agent");
745
+ // scribe → 1943 and agent → 1941 are both off 1944, so after the rewrite
746
+ // they no longer collide; fix applied, exit 0.
747
+ expect(code).toBe(0);
748
+ const rows = readRows(h.manifestPath);
749
+ expect(rows.find((r) => r.name === "parachute-scribe")?.port).toBe(1943);
750
+ expect(rows.find((r) => r.name === "parachute-agent")?.port).toBe(1941);
751
+ } finally {
752
+ h.cleanup();
753
+ }
754
+ });
755
+ });
package/src/cli.ts CHANGED
@@ -581,15 +581,26 @@ async function main(argv: string[]): Promise<number> {
581
581
  return 0;
582
582
  }
583
583
  const json = rest.includes("--json");
584
- const unknown = rest.find((a) => a !== "--json");
584
+ const fix = rest.includes("--fix");
585
+ const yes = rest.includes("--yes") || rest.includes("-y");
586
+ const known = new Set(["--json", "--fix", "--yes", "-y"]);
587
+ const unknown = rest.find((a) => !known.has(a));
585
588
  if (unknown !== undefined) {
586
589
  console.error(`parachute doctor: unknown argument "${unknown}"`);
587
- console.error("usage: parachute doctor [--json]");
590
+ console.error("usage: parachute doctor [--json] [--fix [--yes]]");
591
+ return 1;
592
+ }
593
+ if (json && fix) {
594
+ console.error("parachute doctor: --json and --fix are mutually exclusive");
595
+ return 1;
596
+ }
597
+ if (yes && !fix) {
598
+ console.error("parachute doctor: --yes has no effect without --fix");
588
599
  return 1;
589
600
  }
590
601
  const mod = await loadCommand("doctor", () => import("./commands/doctor.ts"));
591
602
  if (!mod) return 1;
592
- return await mod.doctor({ json });
603
+ return await mod.doctor({ json, fix, yes });
593
604
  }
594
605
 
595
606
  case "expose": {
package/src/clients.ts CHANGED
@@ -238,6 +238,135 @@ export function deleteClient(db: Database, clientId: string): boolean {
238
238
  })();
239
239
  }
240
240
 
241
+ /**
242
+ * Default reaper age threshold: 30 days. A client registered more recently
243
+ * than this is NEVER reaped — it may be mid-first-OAuth-flow (registered →
244
+ * user is on the consent screen → no grant/token/code written yet). The
245
+ * threshold buys generous headroom over the worst-case human consent latency.
246
+ */
247
+ export const DEFAULT_REAP_AGE_MS = 30 * 24 * 60 * 60 * 1000;
248
+
249
+ /** A client the reaper has proven dead. Carries enough to display a dry-run row. */
250
+ export interface ReapableClient {
251
+ clientId: string;
252
+ clientName: string | null;
253
+ registeredAt: string;
254
+ status: ClientStatus;
255
+ /** Whole days since registration, floored. For the dry-run/`--json` display. */
256
+ ageDays: number;
257
+ }
258
+
259
+ export interface FindReapableOpts {
260
+ /** Reap only clients older than this many ms. Defaults to 30 days. */
261
+ olderThanMs?: number;
262
+ /** Injectable clock — all age + liveness comparisons use this. */
263
+ now?: () => Date;
264
+ }
265
+
266
+ /**
267
+ * Find OAuth clients that are PROVABLY DEAD and safe to garbage-collect.
268
+ *
269
+ * THE SAFETY GATE (maximally conservative — see hub#640). DCR churn (Notes /
270
+ * Claude mint a fresh `client_id` per reconnect) accumulates dead `clients`
271
+ * rows; this reaps them. But a reaper that deletes a LIVE client breaks a
272
+ * user's active connection, and we've been bitten by over-eager pruning
273
+ * before (a derived-key divergence wrongly pruned still-wanted grants). So a
274
+ * client is reapable IFF **ALL** of:
275
+ *
276
+ * 1. ZERO rows in `grants` — no standing consent. A grant means the user
277
+ * approved this client; never touch it.
278
+ * 2. ZERO live tokens — no row in `tokens` with `expires_at > now AND
279
+ * revoked_at IS NULL`. A live token means an active session.
280
+ * 3. ZERO live auth_codes — no row in `auth_codes` with `expires_at > now`.
281
+ * A live code means an OAuth exchange is in flight RIGHT NOW.
282
+ * 4. `registered_at` older than `olderThanMs` (default 30d) — a freshly-
283
+ * registered client may be mid-first-flow before any of the above rows
284
+ * land.
285
+ *
286
+ * This reaps abandoned-never-consented `pending` DCR registrations and fully-
287
+ * dead (revoked/expired) clients, and NEVER a client with a live grant, live
288
+ * token, or in-flight code. Pure read — touches nothing. `reapClient` does the
289
+ * deletion.
290
+ *
291
+ * Implemented as a single `NOT EXISTS` SELECT so the gate is evaluated
292
+ * atomically per row against one consistent DB snapshot (no read-modify-write
293
+ * window where a token could land between the check and a later delete — the
294
+ * delete re-derives nothing; callers re-run the gate, see below).
295
+ */
296
+ export function findReapableClients(db: Database, opts: FindReapableOpts = {}): ReapableClient[] {
297
+ const now = opts.now?.() ?? new Date();
298
+ const olderThanMs = opts.olderThanMs ?? DEFAULT_REAP_AGE_MS;
299
+ const nowIso = now.toISOString();
300
+ // Registered strictly before this cutoff to qualify on age (condition 4).
301
+ const cutoffIso = new Date(now.getTime() - olderThanMs).toISOString();
302
+
303
+ const rows = db
304
+ .query<Row, [string, string, string]>(
305
+ `SELECT c.* FROM clients c
306
+ WHERE c.registered_at < ?1
307
+ AND NOT EXISTS (SELECT 1 FROM grants g WHERE g.client_id = c.client_id)
308
+ AND NOT EXISTS (
309
+ SELECT 1 FROM tokens t
310
+ WHERE t.client_id = c.client_id
311
+ AND t.revoked_at IS NULL
312
+ AND t.expires_at > ?2
313
+ )
314
+ AND NOT EXISTS (
315
+ SELECT 1 FROM auth_codes a
316
+ WHERE a.client_id = c.client_id
317
+ AND a.expires_at > ?3
318
+ )
319
+ ORDER BY c.registered_at`,
320
+ )
321
+ .all(cutoffIso, nowIso, nowIso);
322
+
323
+ return rows.map((r) => {
324
+ const client = rowToClient(r);
325
+ const ageMs = now.getTime() - new Date(client.registeredAt).getTime();
326
+ return {
327
+ clientId: client.clientId,
328
+ clientName: client.clientName,
329
+ registeredAt: client.registeredAt,
330
+ status: client.status,
331
+ ageDays: Math.floor(ageMs / (24 * 60 * 60 * 1000)),
332
+ };
333
+ });
334
+ }
335
+
336
+ /**
337
+ * Garbage-collect a single PROVABLY-DEAD client. Wraps the `deleteClient`
338
+ * cascade (grants + auth_codes + client row) AND a `DELETE FROM tokens` for
339
+ * the same client_id, all in ONE transaction.
340
+ *
341
+ * Why also delete tokens here when `deleteClient` deliberately leaves them?
342
+ * `deleteClient` is the general deregistration path — it can run against a
343
+ * client that still has LIVE tokens (an operator force-removing an app mid-
344
+ * session), so it leaves the `tokens` rows to expire on their own (they carry
345
+ * no FK to `clients`). The reaper, by contrast, only ever runs on a client the
346
+ * gate has PROVEN has no live tokens — every remaining `tokens` row for it is
347
+ * already expired or revoked (dead). Deleting them completes the GC instead of
348
+ * leaving dead registry rows behind forever. Callers MUST pass only client_ids
349
+ * returned by `findReapableClients` (the safety gate); see the CLI's reap loop.
350
+ *
351
+ * TOCTOU note: `reapClient` does NOT re-check the gate — it trusts the caller's
352
+ * list. There is a theoretical window where a grant/token lands between the
353
+ * `findReapableClients` snapshot and this delete, reaping a client that just
354
+ * went live. On the single-operator hub this is cosmetically impossible: the
355
+ * OAuth flow that would create a grant runs synchronously on the same SQLite
356
+ * connection and never races a CLI invocation. Re-running `findReapableClients`
357
+ * immediately before each call would close the window at the cost of N extra
358
+ * round-trips; not worth it under the current trust model.
359
+ */
360
+ export function reapClient(db: Database, clientId: string): boolean {
361
+ return db.transaction(() => {
362
+ // Dead token rows first — the gate proved none are live; this completes
363
+ // the GC (deleteClient leaves tokens alone for the general delete path).
364
+ db.prepare("DELETE FROM tokens WHERE client_id = ?").run(clientId);
365
+ // The cascade: auth_codes + grants (both dead per the gate) + the client.
366
+ return deleteClient(db, clientId);
367
+ })();
368
+ }
369
+
241
370
  /**
242
371
  * Returns the registered redirect URI matching `candidate` exactly, or throws.
243
372
  * RFC 8252 + 6749 require exact-match for redirect URIs (no wildcards, no