@lifeaitools/clauth 2.0.0 → 2.0.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.
@@ -6,6 +6,7 @@ import test from "node:test";
6
6
 
7
7
  import {
8
8
  addTunnelRoute,
9
+ deregisterPlugin,
9
10
  discoverPlugins,
10
11
  getClauthPm2Home,
11
12
  listPlugins,
@@ -18,6 +19,9 @@ import {
18
19
  setPluginEnabled,
19
20
  shellQuote,
20
21
  supervisorHealth,
22
+ syncPluginsFromRepos,
23
+ SYNC_REPO_NAMES,
24
+ SYNC_SKIP_STATES,
21
25
  validatePluginManifest,
22
26
  } from "./supervisor-registry.js";
23
27
  import { isLoopbackAddress, supervisorLogDto, supervisorRequiresWriteToken } from "./commands/serve.js";
@@ -29,6 +33,14 @@ function withTempSupervisor(fn) {
29
33
  const oldUser = process.env.CLAUTH_USER_PLUGIN_ROOTS;
30
34
  const oldPm2 = process.env.CLAUTH_PM2_HOME;
31
35
  process.env.CLAUTH_SUPERVISOR_DIR = root;
36
+ // CLAUTH_MANAGED_PLUGIN_ROOTS / CLAUTH_USER_PLUGIN_ROOTS are set as PERSISTENT
37
+ // MACHINE-LEVEL env vars on developer boxes, pointing at the LIVE daemon's
38
+ // plugin directories. rootEntries() prefers them over CLAUTH_SUPERVISOR_DIR,
39
+ // so overriding the supervisor dir ALONE leaves registerPlugin/deregisterPlugin
40
+ // writing to (and deleting from) the live fleet. Pin all three to the temp
41
+ // root here so a test cannot reach the live daemon by forgetting to.
42
+ process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed-plugins");
43
+ process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user-plugins");
32
44
  delete process.env.CLAUTH_PM2_HOME;
33
45
  try {
34
46
  return fn(root);
@@ -398,17 +410,46 @@ test("health reconciliation never restarts external or plugin-owned surfaces", a
398
410
  process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
399
411
  process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
400
412
  try {
413
+ // Both fixtures are deliberately PROBE-ABLE — local destination + port, so
414
+ // healthUrlForSurface() yields a real URL for each. That is what gives this
415
+ // test teeth.
416
+ //
417
+ // An earlier revision made the external fixture `vultr/` with no port. It
418
+ // read as equivalent and was not: portless + relative health resolves to a
419
+ // null URL, so reconcileSurfaceHealth's `if (!url) continue` skipped it
420
+ // BEFORE the owner/destination guard was consulted. Delete the guard this
421
+ // test is named for and it still passed green — it proved the surface was
422
+ // unprobe-able, not that it was probe-able and correctly left alone.
423
+ //
424
+ // Now the ONLY thing that can exclude external-demo is lifecycle_owner, and
425
+ // clauth-owned-demo is the positive control proving the probe machinery
426
+ // actually runs against this fixture shape. If `inspected` ever comes back
427
+ // empty, the control failed and the negative result means nothing.
401
428
  writePlugin(root, "managed", "external-demo", baseManifest("external-demo", {
402
429
  core: true,
403
430
  enable_default: true,
404
- destination: "vultr/clauth/pm2",
431
+ destination: "local/clauth/pm2",
405
432
  lifecycle_owner: "external",
406
- surfaces: [{ id: "primary", destination: "vultr/clauth/pm2", lifecycle_owner: "external", port: 39112, health: "/health" }],
433
+ surfaces: [{ id: "primary", destination: "local/clauth/pm2", lifecycle_owner: "external", port: 39112, health: "/health" }],
434
+ }));
435
+ writePlugin(root, "managed", "clauth-owned-demo", baseManifest("clauth-owned-demo", {
436
+ core: true,
437
+ enable_default: true,
438
+ destination: "local/clauth/pm2",
439
+ lifecycle_owner: "clauth",
440
+ surfaces: [{ id: "primary", destination: "local/clauth/pm2", lifecycle_owner: "clauth", port: 39113, health: "/health" }],
407
441
  }));
408
442
  discoverPlugins();
409
443
  const result = await reconcileSurfaceHealth({ fetchImpl: async () => ({ ok: false, status: 503 }) });
410
- assert.deepEqual(result.inspected, []);
411
- assert.equal(listSurfaces()[0].state, "current");
444
+ const inspectedIds = result.inspected.map((s) => (typeof s === "string" ? s : s.surface_id));
445
+ // Positive control: the probe machinery ran at all.
446
+ assert.ok(inspectedIds.some((id) => String(id).includes("clauth-owned-demo")),
447
+ `control failed — clauth-owned surface was not probed, so "external was skipped" proves nothing (inspected: ${JSON.stringify(result.inspected)})`);
448
+ // The actual assertion: an externally-owned surface is never touched.
449
+ assert.ok(!inspectedIds.some((id) => String(id).includes("external-demo")),
450
+ `external-demo was probed — the lifecycle_owner guard is not excluding it (inspected: ${JSON.stringify(result.inspected)})`);
451
+ const external = listSurfaces().find((s) => s.plugin_id === "external-demo");
452
+ assert.equal(external.state, "current");
412
453
  } finally {
413
454
  if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
414
455
  else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
@@ -529,6 +570,306 @@ test("registerPlugin rejects an invalid manifest without writing anything", () =
529
570
  fs.rmSync(sourceDir, { recursive: true, force: true });
530
571
  }));
531
572
 
573
+ // Builds throwaway product repos laid out like the real ones, so a sync sweep
574
+ // exercises the real relative manifest paths without reading a live checkout.
575
+ function withTempProductRepos(fn) {
576
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-sync-repos-"));
577
+ const regenRoot = path.join(base, "regen-root");
578
+ const rdcSkills = path.join(base, "rdc-skills");
579
+ const writeManifest = (repoRoot, relPath, manifest) => {
580
+ const full = path.join(repoRoot, relPath);
581
+ fs.mkdirSync(path.dirname(full), { recursive: true });
582
+ fs.writeFileSync(full, typeof manifest === "string" ? manifest : `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
583
+ return full;
584
+ };
585
+ writeManifest(regenRoot, "packages/codeflow/clauth-plugin.json", baseManifest("codeflow-mcp"));
586
+ writeManifest(regenRoot, "apps/dev-center/clauth-plugin.json", baseManifest("dev-center"));
587
+ writeManifest(regenRoot, "mcp-servers/regen-media/clauth-plugin.json", baseManifest("regen-media"));
588
+ writeManifest(regenRoot, "mcp-servers/web-research/clauth-plugin.json", baseManifest("web-research"));
589
+ writeManifest(rdcSkills, "clauth-plugin.json", baseManifest("rdc-skills"));
590
+ try {
591
+ return fn({ base, regenRoot, rdcSkills, writeManifest, roots: { "regen-root": regenRoot, "rdc-skills": rdcSkills } });
592
+ } finally {
593
+ fs.rmSync(base, { recursive: true, force: true });
594
+ }
595
+ }
596
+
597
+ test("plugin sync inherits registerPlugin idempotence — a second sweep reports every manifest unchanged", () => withTempSupervisor(() => withTempProductRepos(({ roots }) => {
598
+ const first = syncPluginsFromRepos(roots, "test");
599
+ assert.equal(first.length, 5, "one receipt per attempted manifest");
600
+ assert.deepEqual(
601
+ first.filter((entry) => !entry.ok).map((entry) => `${entry.repo}:${entry.state}`),
602
+ [],
603
+ "every manifest in a complete checkout must register",
604
+ );
605
+ assert.equal(first.every((entry) => entry.state === "registered"), true);
606
+
607
+ // registerPlugin sha256-compares before writing; sync must not defeat that by
608
+ // re-writing or re-hashing on its own.
609
+ const second = syncPluginsFromRepos(roots, "test");
610
+ assert.equal(second.length, 5);
611
+ assert.equal(second.every((entry) => entry.ok && entry.state === "unchanged"), true, "re-sweeping identical content must be a no-op");
612
+ })));
613
+
614
+ test("plugin sync warns and continues over a missing repo root instead of throwing", () => withTempSupervisor(() => withTempProductRepos(({ base, rdcSkills }) => {
615
+ // A box that never checked out regen-root must still sync what it does have.
616
+ const absent = path.join(base, "no-such-checkout");
617
+ assert.equal(fs.existsSync(absent), false);
618
+ let receipts;
619
+ assert.doesNotThrow(() => {
620
+ receipts = syncPluginsFromRepos({ "regen-root": absent, "rdc-skills": rdcSkills }, "test");
621
+ });
622
+ assert.equal(receipts.length, 5, "a skipped repo still yields a receipt per attempted manifest");
623
+ const missing = receipts.filter((entry) => entry.state === "repo_root_missing");
624
+ assert.equal(missing.length, 4, "all four regen-root manifests report the missing root");
625
+ assert.equal(missing.every((entry) => entry.ok === false), true);
626
+ const skills = receipts.find((entry) => entry.repo === "rdc-skills");
627
+ assert.equal(skills.ok, true);
628
+ assert.equal(skills.state, "registered");
629
+ assert.ok(listPlugins().find((plugin) => plugin.id === "rdc-skills"), "the reachable repo still registered");
630
+ })));
631
+
632
+ test("plugin sync registers the remaining manifests when one is malformed", () => withTempSupervisor(() => withTempProductRepos(({ regenRoot, roots, writeManifest }) => {
633
+ writeManifest(regenRoot, "apps/dev-center/clauth-plugin.json", "{ this is not valid json");
634
+
635
+ const receipts = syncPluginsFromRepos(roots, "test");
636
+ assert.equal(receipts.length, 5);
637
+ const bad = receipts.find((entry) => entry.path.includes("dev-center"));
638
+ assert.equal(bad.ok, false);
639
+ assert.equal(bad.state, "manifest_invalid");
640
+ const good = receipts.filter((entry) => entry !== bad);
641
+ assert.equal(good.length, 4);
642
+ assert.equal(good.every((entry) => entry.ok && entry.state === "registered"), true, "one bad manifest must not abort the sweep");
643
+ const ids = new Set(listPlugins().map((plugin) => plugin.id));
644
+ for (const id of ["codeflow-mcp", "regen-media", "web-research", "rdc-skills"]) {
645
+ assert.ok(ids.has(id), `${id} must still be registered`);
646
+ }
647
+ })));
648
+
649
+ test("deregisterPlugin removes only the named plugin and leaves siblings intact", () => withTempSupervisor(() => withTempProductRepos(({ roots }) => {
650
+ syncPluginsFromRepos(roots, "test");
651
+ const managed = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
652
+ assert.equal(fs.existsSync(path.join(managed, "web-research")), true);
653
+
654
+ const receipt = deregisterPlugin("web-research", "test");
655
+ assert.equal(receipt.resulting_state.ok, true);
656
+ assert.equal(receipt.resulting_state.state, "deregistered");
657
+ assert.equal(fs.existsSync(path.join(managed, "web-research")), false, "the named plugin directory is gone");
658
+
659
+ for (const sibling of ["codeflow-mcp", "dev-center", "regen-media", "rdc-skills"]) {
660
+ assert.equal(fs.existsSync(path.join(managed, sibling)), true, `${sibling} must survive`);
661
+ }
662
+ // discovery re-ran, so the removed managed plugin is reported missing, not current
663
+ const after = listPlugins().find((plugin) => plugin.id === "web-research");
664
+ assert.equal(after.state, "missing_default");
665
+ assert.equal(after.enabled, false);
666
+ })));
667
+
668
+ test("deregisterPlugin on an unregistered id is a safe no-op, not an error", () => withTempSupervisor(() => withTempProductRepos(({ roots }) => {
669
+ syncPluginsFromRepos(roots, "test");
670
+ const managed = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
671
+ const before = fs.readdirSync(managed).sort();
672
+
673
+ const receipt = deregisterPlugin("never-registered-plugin", "test");
674
+ assert.equal(receipt.resulting_state.ok, true, "a no-op is success, not failure");
675
+ assert.equal(receipt.resulting_state.state, "not_registered");
676
+ assert.deepEqual(fs.readdirSync(managed).sort(), before, "nothing else may be touched");
677
+ })));
678
+
679
+ test("deregisterPlugin rejects an id that would escape the managed-plugins root", () => withTempSupervisor(() => withTempProductRepos(({ roots }) => {
680
+ // Mirrors the registerPlugin traversal test. The id here is raw CLI/HTTP
681
+ // input with no manifest validation upstream, and the operation DELETES
682
+ // recursively — so an id resolving to the root itself must reject too, which
683
+ // is where this is strictly stricter than registerPlugin's assertion.
684
+ syncPluginsFromRepos(roots, "test");
685
+ const managed = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
686
+ const sentinel = path.join(managed, "..", "sentinel-outside-root.txt");
687
+ fs.writeFileSync(sentinel, "must survive", "utf8");
688
+ const before = fs.readdirSync(managed).sort();
689
+
690
+ const evilIds = [
691
+ "..", "../..", "../sentinel-outside-root.txt", "..\\..", "/etc", "", ".",
692
+ // These three are stopped by the CHARSET guard alone — the containment
693
+ // assert waves all of them through, because path.resolve() normalizes the
694
+ // `..` away and Windows re-anchors a drive-relative path, so each lands
695
+ // back INSIDE the root (measured during code review). They are pinned here
696
+ // so the two guards are independently tested and nobody "simplifies" the
697
+ // charset rule believing containment is a safety net for it.
698
+ "sub/../web-research",
699
+ "C:web-research",
700
+ "web-research::$DATA",
701
+ ];
702
+ for (const evil of evilIds) {
703
+ const receipt = deregisterPlugin(evil, "test");
704
+ assert.equal(receipt.resulting_state.ok, false, `id ${JSON.stringify(evil)} must be rejected`);
705
+ assert.equal(receipt.resulting_state.state, "invalid_plugin_id");
706
+ }
707
+ assert.equal(fs.existsSync(path.join(managed, "web-research")), true, "a charset-rejected alias must not have deleted the real plugin");
708
+
709
+ assert.equal(fs.existsSync(sentinel), true, "must never delete outside the managed-plugins root");
710
+ assert.equal(fs.existsSync(managed), true, "must never delete the managed-plugins root itself");
711
+ assert.deepEqual(fs.readdirSync(managed).sort(), before, "no registered plugin may be removed by a rejected id");
712
+ })));
713
+
714
+ test("validatePluginManifest rejects a port on any non-local destination, and keeps local ports", () => {
715
+ // A remote surface is reached by URL; its port is the deployment registry's
716
+ // fact. Copying it into the manifest creates a second home for one fact that
717
+ // then drifts (regen-media 3121 vs 3120, dev-center 3012 vs 3003). Worse, a
718
+ // ported remote surface makes localhostHealth() synthesize
719
+ // http://127.0.0.1:<port>/health for a service on another box, pointing the
720
+ // health reconciler at the wrong machine.
721
+ //
722
+ // The rule is keyed on the validated DESTINATION enum, never on the surface's
723
+ // free-text id/role — keying on the label was tried in review and failed both
724
+ // ways: renaming the surface bypassed it, and a local surface named "remote"
725
+ // was falsely rejected. Both directions are asserted below.
726
+ for (const destination of ["vultr/clauth/pm2", "coolify/clauth/docker"]) {
727
+ for (const port of [3110, "auto", 0]) {
728
+ assert.throws(() => validatePluginManifest(baseManifest("remote-port", {
729
+ surfaces: [{ id: "remote", destination, lifecycle_owner: "external", port }],
730
+ })), /must not declare a port/, `${destination} + port ${JSON.stringify(port)} must be rejected`);
731
+ }
732
+ }
733
+ // Renaming the surface must NOT bypass the rule — this is the bypass that
734
+ // keying on the `remote` label allowed.
735
+ for (const id of ["vultr", "prod", "primary", "local"]) {
736
+ assert.throws(() => validatePluginManifest(baseManifest("renamed-remote", {
737
+ surfaces: [{ id, destination: "vultr/clauth/pm2", lifecycle_owner: "external", port: 3110 }],
738
+ })), /must not declare a port/, `a non-local surface named "${id}" must still be rejected`);
739
+ }
740
+
741
+ // A remote surface WITHOUT a port is accepted — the shape all four shipped
742
+ // remote surfaces already use.
743
+ const remote = validatePluginManifest(baseManifest("remote-ok", {
744
+ surfaces: [{ id: "remote", destination: "vultr/clauth/pm2", lifecycle_owner: "external" }],
745
+ }), "clauth-plugin.json");
746
+ assert.equal(remote.surfaces[0].port, null);
747
+
748
+ // A LOCAL surface keeps its port on purpose — that port describes how the
749
+ // service runs on a developer box. This is not drift; do not "fix" it. A
750
+ // local surface merely NAMED "remote" is local, and must not be rejected.
751
+ for (const [id, destination] of [["local", "local/clauth/pm2"], ["primary", "local/clauth/pm2"], ["remote", "local/clauth/daemon"]]) {
752
+ const localSurface = validatePluginManifest(baseManifest(`${id}-ok`, {
753
+ surfaces: [{ id, destination, lifecycle_owner: "clauth", port: 3109, health: "/health" }],
754
+ }), "clauth-plugin.json");
755
+ assert.equal(localSurface.surfaces[0].port, 3109, `${destination} surface must keep its port`);
756
+ assert.equal(localSurface.surfaces[0].health, "http://127.0.0.1:3109/health");
757
+ }
758
+ });
759
+
760
+ test("deregisterPlugin removes a scoped @scope/pkg plugin instead of falsely reporting it absent", () => withTempSupervisor((root) => {
761
+ // A removal verb that reports ✓ while the plugin stays installed AND enabled
762
+ // is the one receipt this must never get wrong. The flat <root>/<id> probe
763
+ // misses the scoped layout entirely, so the location is resolved from the
764
+ // discovery_root/sourcePath discovery already records.
765
+ const managed = path.join(root, "managed");
766
+ process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
767
+ process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
768
+ writeScopedPlugin(root, "managed", "@lifeaitools", "fs-mcp", baseManifest("fs-mcp", { core: true, enable_default: true }));
769
+ discoverPlugins();
770
+ assert.equal(listPlugins().find((plugin) => plugin.id === "fs-mcp").enabled, true, "precondition: scoped plugin is enabled");
771
+
772
+ const receipt = deregisterPlugin("fs-mcp", "test");
773
+ assert.equal(receipt.resulting_state.state, "deregistered", "must not report not_registered for a scoped plugin");
774
+ assert.equal(receipt.resulting_state.ok, true);
775
+ assert.equal(fs.existsSync(path.join(managed, "@lifeaitools", "fs-mcp")), false, "the scoped package directory is actually gone");
776
+ const after = listPlugins().find((plugin) => plugin.id === "fs-mcp");
777
+ assert.equal(after.enabled, false, "a deregistered plugin must not remain enabled");
778
+ assert.equal(after.state, "missing_default");
779
+ }));
780
+
781
+ test("deregisterPlugin finds a plugin in a non-first managed root and refuses a user-root plugin", () => withTempSupervisor((root) => {
782
+ // CLAUTH_MANAGED_PLUGIN_ROOTS is a path-delimited LIST; honoring only the
783
+ // first entry silently reports a real plugin as absent.
784
+ const managedA = path.join(root, "managed-a");
785
+ const managedB = path.join(root, "managed-b");
786
+ const user = path.join(root, "user");
787
+ process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = [managedA, managedB].join(path.delimiter);
788
+ process.env.CLAUTH_USER_PLUGIN_ROOTS = user;
789
+ writePlugin(root, "managed-b", "second-root-plugin", baseManifest("second-root-plugin"));
790
+ writePlugin(root, "user", "user-only-plugin", baseManifest("user-only-plugin"));
791
+ discoverPlugins();
792
+
793
+ const second = deregisterPlugin("second-root-plugin", "test");
794
+ assert.equal(second.resulting_state.state, "deregistered", "a plugin in the 2nd managed root must be found");
795
+ assert.equal(fs.existsSync(path.join(managedB, "second-root-plugin")), false);
796
+
797
+ // A user-root plugin is out of this verb's remit — refuse it explicitly
798
+ // rather than reporting a green "not_registered" that implies it is gone.
799
+ const userReceipt = deregisterPlugin("user-only-plugin", "test");
800
+ assert.equal(userReceipt.resulting_state.ok, false);
801
+ assert.equal(userReceipt.resulting_state.state, "not_managed");
802
+ assert.equal(fs.existsSync(path.join(user, "user-only-plugin")), true, "the user plugin must be left intact");
803
+ }));
804
+
805
+ test("deregisterPlugin --dry-run resolves the target without deleting it", () => withTempSupervisor(() => withTempProductRepos(({ roots }) => {
806
+ syncPluginsFromRepos(roots, "test");
807
+ const managed = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
808
+
809
+ const receipt = deregisterPlugin("web-research", "test", { dryRun: true });
810
+ assert.equal(receipt.resulting_state.ok, true);
811
+ assert.equal(receipt.resulting_state.state, "would_deregister");
812
+ assert.equal(receipt.resulting_state.target_dir, path.resolve(managed, "web-research"));
813
+ assert.equal(fs.existsSync(path.join(managed, "web-research")), true, "a dry run must not delete anything");
814
+ assert.equal(listPlugins().find((plugin) => plugin.id === "web-research").state !== "missing_default", true);
815
+ })));
816
+
817
+ test("plugin sync reports an unknown repo-root override instead of silently sweeping the default checkout", () => withTempSupervisor(() => withTempProductRepos(({ roots, base }) => {
818
+ // A typo'd override name dropped on the floor means the sweep reads the
819
+ // DEFAULT checkout while printing ✓ on every line — success from the wrong repo.
820
+ const receipts = syncPluginsFromRepos({ ...roots, regen_root: path.join(base, "typo-checkout") }, "test");
821
+ const rejected = receipts.filter((entry) => entry.state === "unknown_repo_name");
822
+ assert.equal(rejected.length, 1);
823
+ assert.equal(rejected[0].repo, "regen_root");
824
+ assert.equal(rejected[0].ok, false);
825
+ assert.match(rejected[0].error, /unknown repo name/);
826
+ assert.equal(SYNC_SKIP_STATES.includes("unknown_repo_name"), false, "a typo'd repo name must fail the sweep, not be skipped");
827
+ assert.deepEqual([...SYNC_REPO_NAMES].sort(), ["rdc-skills", "regen-root"]);
828
+ })));
829
+
830
+ test("plugin sync never throws on a malformed repo root — the contract absence must not break", () => withTempSupervisor(() => withTempProductRepos(({ rdcSkills }) => {
831
+ // path.resolve() throws on a non-string; doing that before the existence
832
+ // guard aborted the whole sweep and lost every later repo's receipt.
833
+ for (const badRoot of [123, " ", {}, [], true]) {
834
+ let receipts;
835
+ assert.doesNotThrow(() => {
836
+ receipts = syncPluginsFromRepos({ "regen-root": badRoot, "rdc-skills": rdcSkills }, "test");
837
+ }, `root ${JSON.stringify(badRoot)} must not throw`);
838
+ assert.equal(receipts.length, 5, "every attempted manifest still yields a receipt");
839
+ assert.equal(
840
+ receipts.filter((entry) => entry.repo === "regen-root" && entry.state === "repo_root_unknown").length,
841
+ 4,
842
+ `root ${JSON.stringify(badRoot)} must be reported, not thrown`,
843
+ );
844
+ const skills = receipts.find((entry) => entry.repo === "rdc-skills");
845
+ assert.equal(skills.ok, true, "a later repo must still be swept after a bad earlier root");
846
+ }
847
+ })));
848
+
849
+ test("regression: scoped @scope/pkg and unscoped discovery both still work alongside deregister", () => withTempSupervisor((root) => {
850
+ // Epic 63d4d778 WP-1 taught findManifestFiles to descend one extra level for
851
+ // an @scope directory (node_modules/@lifeaitools/fs-mcp). Neither the sync
852
+ // sweep nor deregisterPlugin may regress that.
853
+ const managed = path.join(root, "managed");
854
+ process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
855
+ process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
856
+ writePlugin(root, "managed", "plain-plugin", baseManifest("plain-plugin"));
857
+ writeScopedPlugin(root, "managed", "@lifeaitools", "fs-mcp", baseManifest("fs-mcp"));
858
+
859
+ const discovered = discoverPlugins();
860
+ assert.ok(discovered.plugins.find((plugin) => plugin.id === "plain-plugin"), "unscoped layout still discovered");
861
+ assert.ok(discovered.plugins.find((plugin) => plugin.id === "fs-mcp"), "scoped layout still discovered");
862
+
863
+ // Deregistering the unscoped plugin must not disturb the scoped tree.
864
+ const receipt = deregisterPlugin("plain-plugin", "test");
865
+ assert.equal(receipt.resulting_state.ok, true);
866
+ assert.equal(fs.existsSync(path.join(managed, "plain-plugin")), false);
867
+ assert.equal(fs.existsSync(path.join(managed, "@lifeaitools", "fs-mcp", "clauth-plugin.json")), true, "scoped package must be untouched");
868
+
869
+ const after = discoverPlugins();
870
+ assert.ok(after.plugins.find((plugin) => plugin.id === "fs-mcp" && plugin.state !== "missing_default"), "scoped plugin still discoverable after a sibling deregister");
871
+ }));
872
+
532
873
  test("tunnel route add and remove produce reversible operation receipts", () => withTempSupervisor((root) => {
533
874
  process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
534
875
  process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");