@lifeaitools/clauth 1.31.1 → 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.
@@ -7,7 +7,12 @@ import { spawnSync } from "node:child_process";
7
7
  const SCHEMA = "lifeai.plugin.v1";
8
8
  const DEFAULT_TIMEOUT_MS = 3000;
9
9
  const DEFAULT_SUPERVISOR_PORT = 52439;
10
- const DESTINATIONS = new Set(["local/clauth/pm2", "vultr/clauth/pm2", "coolify/clauth/docker"]);
10
+ // local/clauth/daemon: for a surface that is not a separately-startable
11
+ // process at all, but literally embedded in the clauth daemon itself (e.g.
12
+ // fs-mcp) — distinct from local/clauth/pm2 (a separate local process clauth
13
+ // manages via pm2) because there is nothing for a surface action to
14
+ // start/stop/restart; the daemon's own lifecycle IS the surface's lifecycle.
15
+ const DESTINATIONS = new Set(["local/clauth/pm2", "local/clauth/daemon", "vultr/clauth/pm2", "coolify/clauth/docker"]);
11
16
  const OWNERS = new Set(["clauth", "plugin", "external"]);
12
17
  const ACTIONS = new Set(["start", "stop", "restart", "reconcile", "test", "promote", "rollback"]);
13
18
  const DEFAULT_HEALTH_RECONCILE_INTERVAL_MS = 10000;
@@ -162,7 +167,26 @@ function normalizeCommand(command, field) {
162
167
  const [cmd, ...args] = command;
163
168
  if (typeof cmd !== "string" || !cmd.trim()) throw new Error(`${field}[0] is required`);
164
169
  if (/[;&|<>]/.test(cmd)) throw new Error(`${field}[0] must be an executable path/name, not shell syntax`);
165
- return [cmd, ...args.map(String)];
170
+ const normalizedArgs = args.map(String);
171
+ // shell:true (Windows-only, see execute() in runSurfaceAction) hands each
172
+ // arg to cmd.exe verbatim — a shell metacharacter in an arg is exactly as
173
+ // exploitable as one in cmd[0]. A legitimate CLI arg for the pm2/node
174
+ // invocations this schema targets never needs raw shell syntax.
175
+ for (const [i, arg] of normalizedArgs.entries()) {
176
+ if (/[;&|<>]/.test(arg)) throw new Error(`${field}[${i + 1}] must not contain shell syntax`);
177
+ }
178
+ return [cmd, ...normalizedArgs];
179
+ }
180
+
181
+ // Quotes a value for cmd.exe /c when shell:true is active and the value
182
+ // contains whitespace — spawnSync does NOT auto-quote in that mode, so an
183
+ // absolute path like "C:\Program Files\nodejs\node.exe" (or any arg with a
184
+ // space) breaks at the first space unless the caller quotes it. Idempotent:
185
+ // an already-quoted value is left as-is rather than double-quoted.
186
+ export function shellQuote(value, useShell) {
187
+ if (!useShell || !/\s/.test(value)) return value;
188
+ if (value.startsWith('"') && value.endsWith('"')) return value;
189
+ return `"${value}"`;
166
190
  }
167
191
 
168
192
  function expandPathToken(value) {
@@ -189,6 +213,13 @@ function normalizeDestination(destination) {
189
213
  return value;
190
214
  }
191
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
+
192
223
  function normalizeDocumentation(value) {
193
224
  if (value == null) return null;
194
225
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("documentation must be an object");
@@ -224,8 +255,34 @@ function localhostHealth(pathOrUrl, port) {
224
255
  function normalizeSurface(surface, plugin) {
225
256
  if (!surface || typeof surface !== "object") throw new Error("surface must be an object");
226
257
  const id = String(surface.id || "").trim();
227
- if (!/^[a-zA-Z0-9_.-]+$/.test(id)) throw new Error("surface.id may contain only letters, numbers, dot, underscore, and dash");
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");
228
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
+ }
229
286
  const lifecycle_owner = normalizeLifecycleOwner(surface.lifecycle_owner || plugin.lifecycle_owner);
230
287
  const port = surface.port === undefined || surface.port === null || surface.port === "auto" ? surface.port ?? null : Number(surface.port);
231
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");
@@ -249,7 +306,7 @@ export function validatePluginManifest(manifest, sourcePath = "") {
249
306
  if (!manifest || typeof manifest !== "object") throw new Error("manifest must be an object");
250
307
  if (manifest.schema !== SCHEMA) throw new Error(`schema must be ${SCHEMA}`);
251
308
  const id = String(manifest.id || "").trim();
252
- if (!/^[a-zA-Z0-9_.-]+$/.test(id)) throw new Error("id may contain only letters, numbers, dot, underscore, and dash");
309
+ if (!/^[a-zA-Z0-9_.-]+$/.test(id) || /^\.+$/.test(id)) throw new Error("id may contain only letters, numbers, dot, underscore, and dash, and may not be all dots");
253
310
  const version = String(manifest.version || "").trim();
254
311
  if (!version) throw new Error("version is required");
255
312
  const plugin = {
@@ -301,6 +358,17 @@ function findManifestFiles(root) {
301
358
  for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
302
359
  const full = path.join(root, entry.name);
303
360
  if (entry.isDirectory()) {
361
+ // A scoped npm package (@lifeaitools/fs-mcp) installs two levels deep
362
+ // under node_modules/@scope/pkg-name/ — descend one extra level only
363
+ // for @scope directories. Unscoped layout (one level) is unchanged.
364
+ if (entry.name.startsWith("@")) {
365
+ for (const scopedEntry of fs.readdirSync(full, { withFileTypes: true })) {
366
+ if (!scopedEntry.isDirectory()) continue;
367
+ const scopedCandidate = path.join(full, scopedEntry.name, "clauth-plugin.json");
368
+ if (fs.existsSync(scopedCandidate)) out.push(scopedCandidate);
369
+ }
370
+ continue;
371
+ }
304
372
  const candidate = path.join(full, "clauth-plugin.json");
305
373
  if (fs.existsSync(candidate)) out.push(candidate);
306
374
  } else if (entry.isFile() && entry.name === "clauth-plugin.json") {
@@ -365,6 +433,15 @@ export function discoverPlugins() {
365
433
  id,
366
434
  source,
367
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,
368
445
  manifest_hash: hash,
369
446
  state: "manifest_invalid",
370
447
  enabled: false,
@@ -389,6 +466,314 @@ export function discoverPlugins() {
389
466
  return { plugins, surfaces, events };
390
467
  }
391
468
 
469
+ // Registers one plugin manifest into the managed-plugins root, then runs
470
+ // discovery so it's picked up immediately. This is the entry point a product
471
+ // repo's own install/deploy step calls to self-register — the mechanism the
472
+ // PLUGIN-ARCHITECTURE-DECISION.md "each MCP ships its own clauth-plugin.json"
473
+ // model needs for a monorepo workspace member (no npm install lifecycle hook
474
+ // to piggyback on, unlike a standalone published package with postinstall.js).
475
+ // Idempotent: re-registering unchanged content is a safe no-op re-affirm.
476
+ export function registerPlugin(manifestPath, actor = "localhost") {
477
+ let raw;
478
+ try {
479
+ raw = fs.readFileSync(manifestPath, "utf8");
480
+ } catch (error) {
481
+ return operation("plugin.register", { manifest_path: manifestPath }, null, {
482
+ ok: false, state: "manifest_unreadable", error: error instanceof Error ? error.message : String(error),
483
+ }, actor);
484
+ }
485
+ let manifest;
486
+ try {
487
+ manifest = validatePluginManifest(JSON.parse(raw), manifestPath);
488
+ } catch (error) {
489
+ return operation("plugin.register", { manifest_path: manifestPath }, null, {
490
+ ok: false, state: "manifest_invalid", error: error instanceof Error ? error.message : String(error),
491
+ }, actor);
492
+ }
493
+ const [{ root: managedRoot }] = rootEntries();
494
+ const resolvedRoot = path.resolve(managedRoot);
495
+ const targetDir = path.resolve(managedRoot, manifest.id);
496
+ // Belt-and-braces: the id regex already rejects traversal-shaped ids, but
497
+ // this asserts containment at the actual write site so a future regex
498
+ // relaxation can't silently reopen a path escape out of the managed root —
499
+ // this is a credential vault writing files from parsed manifest content.
500
+ if (targetDir !== resolvedRoot && !targetDir.startsWith(resolvedRoot + path.sep)) {
501
+ return operation("plugin.register", { manifest_path: manifestPath, plugin_id: manifest.id }, null, {
502
+ ok: false, state: "manifest_invalid", error: "plugin id escapes the managed plugin root",
503
+ }, actor);
504
+ }
505
+ const targetPath = path.join(targetDir, "clauth-plugin.json");
506
+ const priorRaw = fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : null;
507
+ const unchanged = priorRaw !== null && sha256(priorRaw) === sha256(raw);
508
+ if (!unchanged) {
509
+ try {
510
+ fs.mkdirSync(targetDir, { recursive: true });
511
+ fs.writeFileSync(targetPath, raw, "utf8");
512
+ } catch (error) {
513
+ return operation("plugin.register", { manifest_path: manifestPath, plugin_id: manifest.id }, null, {
514
+ ok: false, state: "write_failed", error: error instanceof Error ? error.message : String(error),
515
+ }, actor);
516
+ }
517
+ }
518
+ const discovery = discoverPlugins();
519
+ const registered = discovery.plugins.find((plugin) => plugin.id === manifest.id);
520
+ return operation("plugin.register", { manifest_path: manifestPath, plugin_id: manifest.id }, null, {
521
+ ok: Boolean(registered) && registered.state !== "manifest_invalid",
522
+ state: unchanged ? "unchanged" : "registered",
523
+ plugin_state: registered?.state || "not_found",
524
+ surfaces: registered?.surfaces?.map((surface) => surface.id) || [],
525
+ }, actor);
526
+ }
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
+
392
777
  export function listPlugins() {
393
778
  return loadSupervisorState().plugins || [];
394
779
  }
@@ -417,7 +802,7 @@ export function readSupervisorEvents(limit = 100) {
417
802
  });
418
803
  }
419
804
 
420
- function operation(action, target, prior, result, actor = "localhost") {
805
+ export function operation(action, target, prior, result, actor = "localhost") {
421
806
  const receipt = {
422
807
  operationId: crypto.randomUUID(),
423
808
  actor,
@@ -517,10 +902,22 @@ export function runSurfaceAction(id, action, actor = "localhost") {
517
902
  }
518
903
  const execute = (selectedCommand) => {
519
904
  const [cmd, ...args] = selectedCommand;
520
- return spawnSync(cmd, args, {
905
+ const useShell = process.platform === "win32";
906
+ // With shell:true on Windows, spawnSync hands cmd/args to cmd.exe /c
907
+ // verbatim and does NOT auto-quote — an absolute path or arg containing
908
+ // a space (e.g. "C:\Program Files\nodejs\node.exe") breaks at the first
909
+ // space unless quoted. Bare shim names (pm2, npm) never contain spaces,
910
+ // so this only ever affects absolute-path values, and only on Windows.
911
+ const resolvedCmd = shellQuote(cmd, useShell);
912
+ const resolvedArgs = args.map((arg) => shellQuote(arg, useShell));
913
+ return spawnSync(resolvedCmd, resolvedArgs, {
521
914
  cwd: surface.cwd || undefined,
522
915
  env: { ...process.env, CLAUTH_PM2_HOME: getClauthPm2Home(), PM2_HOME: getClauthPm2Home() },
523
916
  windowsHide: true,
917
+ // Windows resolves CLI shims (pm2, npm, etc.) to .cmd files that
918
+ // spawnSync cannot exec directly without a shell — ENOENT otherwise.
919
+ // POSIX targets (Vultr/Coolify) need no shell and keep prior behavior.
920
+ shell: useShell,
524
921
  encoding: "utf8",
525
922
  timeout: Number(surface.timeoutMs || 30000),
526
923
  });