@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.
- package/.clauth-skill/SKILL.md +31 -0
- package/.clauth-skill/references/keys-guide.md +270 -270
- package/.clauth-skill/references/operator-guide.md +27 -0
- package/README.md +48 -0
- package/cli/api.js +238 -238
- package/cli/commands/install.js +396 -396
- package/cli/commands/ops-install.js +211 -0
- package/cli/commands/ops.js +69 -0
- package/cli/commands/serve.js +1381 -1644
- package/cli/commands/uninstall.js +164 -164
- package/cli/commands/watchdog.js +1 -1
- package/cli/index.js +165 -1
- package/cli/ops/coolify-adapter.js +80 -0
- package/cli/ops/deployment-adapter.js +63 -0
- package/cli/ops/job-store.js +116 -0
- package/cli/ops/operation-policy.js +51 -0
- package/cli/ops/pm2-adapter.js +128 -0
- package/cli/ops/serialized-executor.js +9 -0
- package/cli/supervisor-registry.js +403 -6
- package/cli/supervisor-registry.test.js +496 -4
- package/cli/supervisor-ui.test.js +436 -0
- package/cli/watchdog-registry.js +30 -2
- package/cli/watchdog-registry.test.js +28 -5
- package/install.ps1 +102 -102
- package/install.sh +49 -49
- package/package.json +4 -3
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
- package/scripts/bootstrap.cjs +121 -121
- package/supabase/functions/auth-vault/index.ts +350 -350
- package/supabase/migrations/001_clauth_schema.sql +94 -94
- package/supabase/migrations/002_vault_helpers.sql +90 -90
- package/supabase/migrations/20260317_lockout.sql +26 -26
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|