@lifeaitools/clauth 2.0.0 → 2.0.2

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