@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.
@@ -213,6 +213,13 @@ function normalizeDestination(destination) {
213
213
  return value;
214
214
  }
215
215
 
216
+ // "Remote" means "not running on this box", read off the validated destination
217
+ // enum. Expressed as NOT-local so a destination added to DESTINATIONS later is
218
+ // treated as remote by default rather than silently escaping the port rule.
219
+ function isRemoteDestination(destination) {
220
+ return !String(destination).startsWith("local/");
221
+ }
222
+
216
223
  function normalizeDocumentation(value) {
217
224
  if (value == null) return null;
218
225
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("documentation must be an object");
@@ -250,6 +257,32 @@ function normalizeSurface(surface, plugin) {
250
257
  const id = String(surface.id || "").trim();
251
258
  if (!/^[a-zA-Z0-9_.-]+$/.test(id) || /^\.+$/.test(id)) throw new Error("surface.id may contain only letters, numbers, dot, underscore, and dash, and may not be all dots");
252
259
  const destination = normalizeDestination(surface.destination || plugin.destination);
260
+ // A remote surface names a service running somewhere else (Vultr/Coolify).
261
+ // It is reached by URL and its port is the deployment registry's fact, not
262
+ // the manifest's. A local surface is the opposite case and keeps its port on
263
+ // purpose: that port describes how the service runs on a developer box,
264
+ // which is intrinsic to the service and correctly lives in the product repo.
265
+ //
266
+ // Keyed on the VALIDATED destination enum, not on the surface's free-text
267
+ // id/role. Keying on the label `remote` was tried and is wrong twice over:
268
+ // it lets the drift back in under any other surface name, and it falsely
269
+ // rejects a genuinely local surface that happens to be named "remote". A
270
+ // ported non-local surface is also actively harmful, not just untidy —
271
+ // localhostHealth() below would synthesize http://127.0.0.1:<port>/health
272
+ // for it, pointing the health reconciler at the wrong box entirely.
273
+ if (isRemoteDestination(destination) && surface.port !== undefined && surface.port !== null) {
274
+ throw new Error(`a remote surface must not declare a port — destination ${destination} is not on this box, so its port is the deployment registry's fact, not the manifest's`);
275
+ }
276
+ // Same rule, second route to the same harm. Banning `port` alone closes only
277
+ // the narrower half: localhostHealth() below accepts an ABSOLUTE health URL
278
+ // and permits localhost hosts only, so any absolute health on a non-local
279
+ // destination is by construction pointed at the wrong box — the identical
280
+ // defect the port rule exists to prevent, arriving through a different field.
281
+ // A remote surface's health is reached by its public route, not by a
282
+ // loopback URL this box could dial.
283
+ if (isRemoteDestination(destination) && surface.health && /^https?:\/\//i.test(String(surface.health))) {
284
+ throw new Error(`a remote surface must not declare an absolute health URL — destination ${destination} is not on this box, so a localhost health URL would probe the wrong machine`);
285
+ }
253
286
  const lifecycle_owner = normalizeLifecycleOwner(surface.lifecycle_owner || plugin.lifecycle_owner);
254
287
  const port = surface.port === undefined || surface.port === null || surface.port === "auto" ? surface.port ?? null : Number(surface.port);
255
288
  if (port !== null && port !== "auto" && (!Number.isInteger(port) || port < 1 || port > 65535)) throw new Error("surface.port must be auto or a TCP port");
@@ -400,6 +433,15 @@ export function discoverPlugins() {
400
433
  id,
401
434
  source,
402
435
  sourcePath: manifestPath,
436
+ // discovery_root must be recorded even on the quarantine path.
437
+ // deregisterPlugin() resolves its target via `prior?.sourcePath &&
438
+ // prior?.discovery_root`; omitting it here made that check false for
439
+ // every manifest_invalid row, so deregister fell back to the flat
440
+ // managed probe, missed the real directory, and returned a
441
+ // false-success "not_registered" while the plugin stayed on disk.
442
+ // A broken manifest is precisely what an operator reaches for
443
+ // deregister to remove, so this path must not be the weak one.
444
+ discovery_root: root,
403
445
  manifest_hash: hash,
404
446
  state: "manifest_invalid",
405
447
  enabled: false,
@@ -483,6 +525,255 @@ export function registerPlugin(manifestPath, actor = "localhost") {
483
525
  }, actor);
484
526
  }
485
527
 
528
+ // ─────────────────────────────────────────────────────────────────────────────
529
+ // plugin sync — SWEEP, NOT A CATALOG.
530
+ //
531
+ // This reads the ORIGINAL clauth-plugin.json in each product repo and hands it
532
+ // to registerPlugin. It deliberately stores NO inventory: no list of which
533
+ // plugins exist, no copy of manifest content, no port assignments, no versions.
534
+ // The table below is a list of PLACES TO LOOK, not a record of what is there —
535
+ // every fact still comes from the product repo's own manifest, read fresh.
536
+ //
537
+ // That distinction is load-bearing. A central catalog of copied manifests
538
+ // (lifeai-env's services/plugins/catalog.json + generate-catalog.mjs + its 7
539
+ // generated manifests) was just retired precisely because a copy drifts from
540
+ // the original and then two homes disagree about one fact. If a future cleanup
541
+ // pass is tempted to "consolidate" this into a file that lists which plugins
542
+ // exist, or to cache what was found, that rebuilds the thing that was deleted —
543
+ // stop instead.
544
+ // ─────────────────────────────────────────────────────────────────────────────
545
+ const PRODUCT_REPO_MANIFESTS = [
546
+ { repo: "regen-root", manifest: "packages/codeflow/clauth-plugin.json" },
547
+ { repo: "regen-root", manifest: "apps/dev-center/clauth-plugin.json" },
548
+ { repo: "regen-root", manifest: "mcp-servers/regen-media/clauth-plugin.json" },
549
+ { repo: "regen-root", manifest: "mcp-servers/web-research/clauth-plugin.json" },
550
+ { repo: "rdc-skills", manifest: "clauth-plugin.json" },
551
+ ];
552
+
553
+ // Sweep outcomes that mean "nothing was there to sync", as distinct from
554
+ // "syncing it failed". Absence is expected on a partial checkout and must not
555
+ // fail the sweep; a malformed manifest or a typo'd repo name must. Exported so
556
+ // the CLI classifies receipts from the same list the audit receipt counts from.
557
+ export const SYNC_SKIP_STATES = Object.freeze(["repo_root_missing", "manifest_missing", "repo_root_unknown"]);
558
+
559
+ // The repo names syncPluginsFromRepos understands. Exported so a caller can
560
+ // validate an override key up front rather than having a typo'd repo name
561
+ // silently ignored and the sweep quietly read the default checkout instead.
562
+ export const SYNC_REPO_NAMES = Object.freeze([...new Set(PRODUCT_REPO_MANIFESTS.map((entry) => entry.repo))]);
563
+
564
+ // Mirrors expandPathToken()'s REGEN_ROOT resolution so a box that has already
565
+ // pointed the manifest ${REGEN_ROOT} token somewhere resolves the sweep to the
566
+ // same checkout rather than needing a second, differently-named env var.
567
+ function defaultRepoRoot(repo) {
568
+ if (repo === "regen-root") return process.env.REGEN_ROOT || process.env.LIFEAI_REPO_ROOT || "C:/Dev/regen-root";
569
+ if (repo === "rdc-skills") return process.env.RDC_SKILLS_ROOT || "C:/Dev/rdc-skills";
570
+ return null;
571
+ }
572
+
573
+ /**
574
+ * Sweep the known product-repo manifest locations and register each one via the
575
+ * existing registerPlugin path (which owns validation, idempotence, and the
576
+ * managed-root containment assertion — none of it is re-implemented here).
577
+ *
578
+ * A missing repo root or a missing manifest WARNS AND CONTINUES. A box that has
579
+ * never checked out regen-root must still be able to sync whatever it does have,
580
+ * so absence is a reportable observation, never a thrown error.
581
+ *
582
+ * @param {Record<string,string>} [repoRoots] partial map of repo name -> root
583
+ * path; any repo omitted falls back to its env/default root.
584
+ * @returns {Array<{repo:string,path:string,id:string|null,state:string,ok:boolean}>}
585
+ * one receipt per ATTEMPTED manifest — this array is the return value, not a
586
+ * persisted inventory.
587
+ */
588
+ export function syncPluginsFromRepos(repoRoots = {}, actor = "localhost") {
589
+ const overrides = repoRoots && typeof repoRoots === "object" && !Array.isArray(repoRoots) ? repoRoots : {};
590
+ const receipts = [];
591
+ // An override key naming no known repo is a caller error, not a silent
592
+ // no-op: dropping it would sweep the DEFAULT checkout while reporting ✓ on
593
+ // every line, so the operator sees success from the wrong repo.
594
+ for (const key of Object.keys(overrides)) {
595
+ if (!SYNC_REPO_NAMES.includes(key)) {
596
+ receipts.push({ repo: key, path: null, id: null, state: "unknown_repo_name", ok: false, error: `unknown repo name — expected one of ${SYNC_REPO_NAMES.join(", ")}` });
597
+ appendSupervisorEvent({ kind: "plugin_sync_rejected", repo: key, manifest: null, reason: "unknown_repo_name" });
598
+ }
599
+ }
600
+
601
+ for (const { repo, manifest } of PRODUCT_REPO_MANIFESTS) {
602
+ const root = overrides[repo] || defaultRepoRoot(repo);
603
+ // Validate BEFORE path.resolve() — a non-string root makes path.resolve
604
+ // throw, which would abort the whole sweep and lose every later repo's
605
+ // receipt. This function's contract is that a bad or absent root is a
606
+ // reported observation, never a thrown error, and callers include HTTP.
607
+ if (typeof root !== "string" || !root.trim()) {
608
+ receipts.push({ repo, path: null, id: null, state: "repo_root_unknown", ok: false });
609
+ appendSupervisorEvent({ kind: "plugin_sync_skipped", repo, manifest, reason: "repo_root_unknown" });
610
+ continue;
611
+ }
612
+ const manifestPath = path.resolve(root, manifest);
613
+ if (!fs.existsSync(root)) {
614
+ receipts.push({ repo, path: manifestPath, id: null, state: "repo_root_missing", ok: false });
615
+ appendSupervisorEvent({ kind: "plugin_sync_skipped", repo, manifest: manifestPath, reason: "repo_root_missing" });
616
+ continue;
617
+ }
618
+ if (!fs.existsSync(manifestPath)) {
619
+ receipts.push({ repo, path: manifestPath, id: null, state: "manifest_missing", ok: false });
620
+ appendSupervisorEvent({ kind: "plugin_sync_skipped", repo, manifest: manifestPath, reason: "manifest_missing" });
621
+ continue;
622
+ }
623
+ let receipt;
624
+ try {
625
+ receipt = registerPlugin(manifestPath, actor);
626
+ } catch (error) {
627
+ // registerPlugin already returns receipts for the failure modes it knows
628
+ // about; this only catches an unforeseen throw so one bad manifest can
629
+ // never abort the rest of the sweep.
630
+ receipts.push({
631
+ repo,
632
+ path: manifestPath,
633
+ id: null,
634
+ state: "register_threw",
635
+ ok: false,
636
+ error: error instanceof Error ? error.message : String(error),
637
+ });
638
+ continue;
639
+ }
640
+ receipts.push({
641
+ repo,
642
+ path: manifestPath,
643
+ id: receipt.target?.plugin_id || null,
644
+ state: receipt.resulting_state?.state || "unknown",
645
+ ok: receipt.resulting_state?.ok === true,
646
+ operation_id: receipt.operationId,
647
+ error: receipt.resulting_state?.error || null,
648
+ });
649
+ }
650
+
651
+ // One audit receipt for the sweep itself (who swept, when, what happened).
652
+ // Counts only — deliberately not a stored list of what exists.
653
+ const isSkip = (entry) => SYNC_SKIP_STATES.includes(entry.state);
654
+ operation("plugin.sync", { repos: SYNC_REPO_NAMES }, null, {
655
+ ok: receipts.every((entry) => entry.ok || isSkip(entry)),
656
+ state: "sync_completed",
657
+ attempted: receipts.length,
658
+ registered: receipts.filter((entry) => entry.state === "registered").length,
659
+ unchanged: receipts.filter((entry) => entry.state === "unchanged").length,
660
+ skipped: receipts.filter(isSkip).length,
661
+ failed: receipts.filter((entry) => !entry.ok && !isSkip(entry)).length,
662
+ }, actor);
663
+
664
+ return receipts;
665
+ }
666
+
667
+ // Removes exactly one managed-plugin directory by id, then re-runs discovery so
668
+ // the removal is reflected immediately. The inverse of registerPlugin — an id
669
+ // reaching this function is attacker-influenced input (a CLI arg or an HTTP
670
+ // field, with no manifest validation upstream to lean on) and this deletes
671
+ // recursively, so both guards below are load-bearing.
672
+ export function deregisterPlugin(id, actor = "localhost", { dryRun = false } = {}) {
673
+ const pluginId = String(id ?? "").trim();
674
+ // Guard 1 — charset + all-dots, the same rule a manifest id must satisfy.
675
+ //
676
+ // This one is NOT redundant with the containment assert below, and the
677
+ // containment assert is NOT a safety net for relaxing it. Measured: because
678
+ // path.resolve() normalizes `..` away and Windows re-anchors drive-relative
679
+ // paths, `sub/../web-research`, `C:web-research` and `web-research::$DATA`
680
+ // all resolve back INSIDE the root and sail through containment. Only the
681
+ // charset rule stops them. Relaxing it to admit a separator, a colon or a
682
+ // drive letter reopens a real hole — the tests pin all three forms.
683
+ if (!/^[a-zA-Z0-9_.-]+$/.test(pluginId) || /^\.+$/.test(pluginId)) {
684
+ return operation("plugin.deregister", { plugin_id: pluginId }, null, {
685
+ ok: false, state: "invalid_plugin_id", error: "plugin id may contain only letters, numbers, dot, underscore, and dash, and may not be all dots",
686
+ }, actor);
687
+ }
688
+ const roots = rootEntries();
689
+ const managedRoots = roots.filter((entry) => entry.source === "managed");
690
+ const prior = (loadSupervisorState().plugins || []).find((plugin) => plugin.id === pluginId) || null;
691
+
692
+ // Resolve the plugin's ACTUAL directory rather than assuming a flat
693
+ // <first-managed-root>/<id> layout. Three real layouts exist that assumption
694
+ // misses, and in every one of them a bare <root>/<id> probe finds nothing and
695
+ // would report a green "not_registered" while the plugin stays installed and
696
+ // ENABLED — the one receipt a removal verb must never get wrong:
697
+ // 1. a scoped npm package at <root>/@scope/pkg/ (findManifestFiles descends
698
+ // one extra level for these; see the @scope arm above),
699
+ // 2. a plugin in the 2nd..Nth entry of a path-delimited
700
+ // CLAUTH_MANAGED_PLUGIN_ROOTS,
701
+ // 3. a plugin in the USER root, which must be refused explicitly rather
702
+ // than silently reported as absent.
703
+ // Discovery already records discovery_root + sourcePath per plugin, so the
704
+ // location comes from there when state knows the plugin.
705
+ let targetDir = null;
706
+ let containingRoot = null;
707
+ if (prior?.sourcePath && prior?.discovery_root) {
708
+ if (prior.source === "user") {
709
+ return operation("plugin.deregister", { plugin_id: pluginId }, prior, {
710
+ ok: false,
711
+ state: "not_managed",
712
+ error: "plugin is installed in a user plugin root; deregister only removes managed plugins",
713
+ }, actor);
714
+ }
715
+ targetDir = path.resolve(path.dirname(prior.sourcePath));
716
+ containingRoot = path.resolve(prior.discovery_root);
717
+ } else {
718
+ // State does not know this id (never discovered, or state was reset). Fall
719
+ // back to probing the flat layout in every managed root, not just the first.
720
+ for (const { root } of managedRoots) {
721
+ const candidate = path.resolve(root, pluginId);
722
+ if (fs.existsSync(candidate)) {
723
+ targetDir = candidate;
724
+ containingRoot = path.resolve(root);
725
+ break;
726
+ }
727
+ }
728
+ }
729
+
730
+ if (!targetDir || !containingRoot || !fs.existsSync(targetDir)) {
731
+ return operation("plugin.deregister", { plugin_id: pluginId }, prior, {
732
+ ok: true, state: "not_registered", plugin_state: "not_found", surfaces: [],
733
+ }, actor);
734
+ }
735
+
736
+ // Guard 2 — containment assert at the delete site, against the root that
737
+ // actually contains the plugin. Note the difference from registerPlugin:
738
+ // targetDir === containingRoot is a REJECT here, not an accept. This deletes
739
+ // a directory recursively, so a path resolving to the plugin root itself
740
+ // would take the entire root with it.
741
+ if (targetDir === containingRoot || !targetDir.startsWith(containingRoot + path.sep)) {
742
+ return operation("plugin.deregister", { plugin_id: pluginId }, prior, {
743
+ ok: false, state: "invalid_plugin_id", error: "resolved plugin directory escapes its plugin root",
744
+ }, actor);
745
+ }
746
+ if (!managedRoots.some((entry) => path.resolve(entry.root) === containingRoot)) {
747
+ return operation("plugin.deregister", { plugin_id: pluginId }, prior, {
748
+ ok: false, state: "not_managed", error: "resolved plugin directory is not inside a managed plugin root",
749
+ }, actor);
750
+ }
751
+ if (dryRun) {
752
+ return operation("plugin.deregister.dry_run", { plugin_id: pluginId }, prior, {
753
+ ok: true,
754
+ state: "would_deregister",
755
+ target_dir: targetDir,
756
+ plugin_state: prior?.state || "unknown",
757
+ surfaces: (prior?.surfaces || []).map((surface) => surface.id),
758
+ }, actor);
759
+ }
760
+ try {
761
+ fs.rmSync(targetDir, { recursive: true, force: true });
762
+ } catch (error) {
763
+ return operation("plugin.deregister", { plugin_id: pluginId }, prior, {
764
+ ok: false, state: "remove_failed", error: error instanceof Error ? error.message : String(error),
765
+ }, actor);
766
+ }
767
+ const discovery = discoverPlugins();
768
+ const after = discovery.plugins.find((plugin) => plugin.id === pluginId);
769
+ return operation("plugin.deregister", { plugin_id: pluginId }, prior, {
770
+ ok: true,
771
+ state: "deregistered",
772
+ plugin_state: after?.state || "not_found",
773
+ surfaces: (prior?.surfaces || []).map((surface) => surface.id),
774
+ }, actor);
775
+ }
776
+
486
777
  export function listPlugins() {
487
778
  return loadSupervisorState().plugins || [];
488
779
  }