@lifeaitools/clauth 2.10.0 → 2.10.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.
- package/cli/commands/serve.js +12 -26
- package/cli/dashboard/panels/build-status-panel.js +37 -49
- package/cli/index.js +9 -17
- package/cli/supervisor-registry.js +128 -4
- package/cli/supervisor-registry.test.js +106 -0
- package/package.json +1 -1
package/cli/commands/serve.js
CHANGED
|
@@ -103,6 +103,16 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
103
103
|
const CLAUTH_ROOT_DIR = path.join(__dirname, "../..");
|
|
104
104
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../package.json"), "utf8"));
|
|
105
105
|
const VERSION = pkg.version;
|
|
106
|
+
// Computed ONCE per daemon process, at module load -- never polled. Answers
|
|
107
|
+
// "what am I looking at": this build's commit date, and whether this
|
|
108
|
+
// process is the live daemon (LIVE_PORT, not staged) or a dev/test instance.
|
|
109
|
+
// A prior version of this polled a per-request HTTP route with a live git
|
|
110
|
+
// shell-out every 30s, which is exactly the wrong shape for a fact that
|
|
111
|
+
// only changes when the daemon itself restarts.
|
|
112
|
+
let BUILD_DATE = null;
|
|
113
|
+
try {
|
|
114
|
+
BUILD_DATE = execSyncTop("git log -1 --format=%cs HEAD", { cwd: CLAUTH_ROOT_DIR, encoding: "utf8", windowsHide: true }).trim() || null;
|
|
115
|
+
} catch { /* not a git checkout, or git unavailable -- BUILD_DATE stays null */ }
|
|
106
116
|
|
|
107
117
|
// Dashboard client CSS/JS ship as static assets (../dashboard/) rather than
|
|
108
118
|
// inlined in dashboardHtml()'s template literal — read once at module load,
|
|
@@ -946,6 +956,8 @@ export function dashboardHtml(port, whitelist, isStaged = false, initWriteToken
|
|
|
946
956
|
<script>
|
|
947
957
|
window.__CLAUTH_VERSION__ = ${JSON.stringify(VERSION)};
|
|
948
958
|
window.__CLAUTH_INIT_WRITE_TOKEN__ = ${JSON.stringify(initWriteToken)};
|
|
959
|
+
window.__CLAUTH_BUILD_DATE__ = ${JSON.stringify(BUILD_DATE)};
|
|
960
|
+
window.__CLAUTH_IS_LIVE__ = ${JSON.stringify(port === LIVE_PORT && !isStaged)};
|
|
949
961
|
</script>
|
|
950
962
|
<script src="/dashboard/panel-element.js"></script>
|
|
951
963
|
<script src="/dashboard/panels/panel-registry.js"></script>
|
|
@@ -1814,32 +1826,6 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
1814
1826
|
}
|
|
1815
1827
|
});
|
|
1816
1828
|
registerReadOnlyRoute("GET", "/builds", async (req, res) => ok(res, buildStatus));
|
|
1817
|
-
// GET /deploy-status — this daemon's own environment identity, NOT the
|
|
1818
|
-
// regen-root build pipeline /builds surfaces. Two independent axes:
|
|
1819
|
-
// live vs test -- is this the daemon Dave uses directly (LIVE_PORT,
|
|
1820
|
-
// not staged), or a test/isolated/staged instance?
|
|
1821
|
-
// local vs develop -- does the running checkout's HEAD match
|
|
1822
|
-
// origin/develop with a clean working tree (what's
|
|
1823
|
-
// actually published), or does it diverge (uncommitted
|
|
1824
|
-
// changes, or a different commit)?
|
|
1825
|
-
// Answers the dashboard's own "what am I looking at" question -- the
|
|
1826
|
-
// thing that previously read as an unrelated, confusing status pulled
|
|
1827
|
-
// from Supabase's regen-root build_status blob.
|
|
1828
|
-
registerReadOnlyRoute("GET", "/deploy-status", async (req, res) => {
|
|
1829
|
-
const isLive = port === LIVE_PORT && !isStaged;
|
|
1830
|
-
let headSha = null;
|
|
1831
|
-
let synced = null;
|
|
1832
|
-
try {
|
|
1833
|
-
headSha = execSyncTop("git rev-parse --short=7 HEAD", { cwd: CLAUTH_ROOT_DIR, encoding: "utf8" }).trim();
|
|
1834
|
-
const dirty = execSyncTop("git status --porcelain", { cwd: CLAUTH_ROOT_DIR, encoding: "utf8" }).trim().length > 0;
|
|
1835
|
-
let developSha = null;
|
|
1836
|
-
try {
|
|
1837
|
-
developSha = execSyncTop("git rev-parse --short=7 origin/develop", { cwd: CLAUTH_ROOT_DIR, encoding: "utf8" }).trim();
|
|
1838
|
-
} catch { /* no origin/develop ref cached locally -- synced stays unknown */ }
|
|
1839
|
-
synced = developSha !== null ? (!dirty && headSha === developSha) : null;
|
|
1840
|
-
} catch { /* git unavailable -- headSha/synced stay null, panel shows "local" (unknown = not confirmed synced) */ }
|
|
1841
|
-
return ok(res, { is_live: isLive, is_staged: isStaged, port, synced, head_sha: headSha });
|
|
1842
|
-
});
|
|
1843
1829
|
// GET /migrations — migration registry and last run result
|
|
1844
1830
|
registerReadOnlyRoute("GET", "/migrations", async (req, res) => ok(res, {
|
|
1845
1831
|
schema_version: CURRENT_SCHEMA_VERSION,
|
|
@@ -1,31 +1,37 @@
|
|
|
1
1
|
// cli/dashboard/panels/build-status-panel.js
|
|
2
2
|
// The "Deploy" identity badge, as a real Web Component: <clauth-build-status-panel>.
|
|
3
|
-
// Answers ONE question: what am I looking at?
|
|
4
|
-
// Dave uses directly)
|
|
5
|
-
//
|
|
6
|
-
// working tree, or a different commit) vs Develop (HEAD matches
|
|
7
|
-
// origin/develop exactly, clean tree -- what's actually published).
|
|
3
|
+
// Answers ONE question: what am I looking at? Version, build date, and
|
|
4
|
+
// whether this is Live (port 52437, the daemon Dave uses directly) or a
|
|
5
|
+
// dev/test instance.
|
|
8
6
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
7
|
+
// These are all facts that only change when the daemon itself restarts --
|
|
8
|
+
// serve.js computes VERSION/BUILD_DATE/is-live ONCE at module load and
|
|
9
|
+
// injects them as window.__CLAUTH_*__ globals in the page's own inline
|
|
10
|
+
// preamble (same mechanism as window.__CLAUTH_VERSION__). This panel just
|
|
11
|
+
// reads those synchronously. An earlier version of this fetched GET
|
|
12
|
+
// /deploy-status on a 30s poll, which ran three git shell-outs per call on
|
|
13
|
+
// the server -- exactly the shape of "unexplained periodic activity" that
|
|
14
|
+
// should never exist for a fact that doesn't change between daemon
|
|
15
|
+
// restarts. No fetch, no poll, no server route.
|
|
16
|
+
//
|
|
17
|
+
// Previously (before that) this panel polled GET /builds, which is
|
|
18
|
+
// regen-root's OWN build pipeline status (borrowed from Supabase's
|
|
19
|
+
// prt_storage.build_status blob) -- unrelated to clauth's own runtime
|
|
20
|
+
// identity, and the source of "not sure what that sync message is"
|
|
21
|
+
// confusion. /builds and its backing poller are untouched in serve.js in
|
|
22
|
+
// case another consumer still needs regen-root's build status; this panel
|
|
15
23
|
// simply no longer surfaces it.
|
|
16
24
|
//
|
|
17
25
|
// Same conversion shape as every panel before it (tunnel-panel.js is the
|
|
18
26
|
// reference implementation): a class extending window.ClauthPanelElement,
|
|
19
27
|
// its own template()/mount(), instance state instead of module-level
|
|
20
|
-
// globals
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// ClauthPanelRegistry.mountAll(), after every script has loaded.
|
|
28
|
+
// globals. connectedCallback() (panel-element.js) fires during HTML
|
|
29
|
+
// parsing, before dashboard.js has loaded -- but this panel's mount() only
|
|
30
|
+
// reads window globals the inline preamble already set before any panel
|
|
31
|
+
// script runs, so there's no "BASE is not defined" hazard here the way
|
|
32
|
+
// there was for panels that fetch.
|
|
26
33
|
//
|
|
27
|
-
// This panel owns none of WRITE_ACTIONS
|
|
28
|
-
// so it needs no write-guard bridge -- unlike tunnel-panel.js/webdav-panel.js.
|
|
34
|
+
// This panel owns none of WRITE_ACTIONS and needs no write-guard bridge.
|
|
29
35
|
//
|
|
30
36
|
// Loaded as a plain classic <script> (not type="module") BEFORE dashboard.js
|
|
31
37
|
// -- same pattern as every other panel component.
|
|
@@ -44,41 +50,23 @@ class ClauthBuildStatusPanel extends window.ClauthPanelElement {
|
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
mount() {
|
|
47
|
-
|
|
48
|
-
this
|
|
49
|
-
|
|
50
|
-
// (a fresh push or a new local edit shows up within half a minute)
|
|
51
|
-
// without polling as aggressively as a per-request build/test pipeline
|
|
52
|
-
// status would need.
|
|
53
|
-
this.pollTimer = setInterval(() => this.updateDeployStatus(), 30000);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async updateDeployStatus() {
|
|
57
|
-
try {
|
|
58
|
-
const data = await fetch(BASE + "/deploy-status").then(r => r.json());
|
|
59
|
-
const dot = this.$("#deploy-dot");
|
|
60
|
-
const label = this.$("#deploy-label");
|
|
61
|
-
const sha = this.$("#deploy-sha");
|
|
53
|
+
const dot = this.$("#deploy-dot");
|
|
54
|
+
const label = this.$("#deploy-label");
|
|
55
|
+
const sha = this.$("#deploy-sha");
|
|
62
56
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const syncWord = data.synced === true ? "Develop" : "Local";
|
|
68
|
-
const text = envWord + " - " + syncWord;
|
|
57
|
+
const isLive = window.__CLAUTH_IS_LIVE__ === true;
|
|
58
|
+
const envWord = isLive ? "Live" : "Dev";
|
|
59
|
+
const version = window.__CLAUTH_VERSION__ || "?";
|
|
60
|
+
const buildDate = window.__CLAUTH_BUILD_DATE__;
|
|
69
61
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
} catch {
|
|
74
|
-
const label = this.$("#deploy-label");
|
|
75
|
-
if (label) label.textContent = "unknown";
|
|
76
|
-
}
|
|
62
|
+
dot.className = "deploy-dot " + (isLive ? "live" : "test");
|
|
63
|
+
label.textContent = envWord + " · v" + version;
|
|
64
|
+
sha.textContent = buildDate ? "built " + buildDate : "";
|
|
77
65
|
}
|
|
78
66
|
|
|
79
67
|
// No data-action elements in this panel's template -- it is pure display,
|
|
80
|
-
//
|
|
81
|
-
// than implicit-via-undefined.
|
|
68
|
+
// computed once from values the page already loaded with. Declared
|
|
69
|
+
// anyway so the contract is explicit rather than implicit-via-undefined.
|
|
82
70
|
get actions() {
|
|
83
71
|
return {};
|
|
84
72
|
}
|
package/cli/index.js
CHANGED
|
@@ -150,7 +150,7 @@ import { runInstall } from './commands/install.js';
|
|
|
150
150
|
import { runUninstall } from './commands/uninstall.js';
|
|
151
151
|
import { runScrub } from './commands/scrub.js';
|
|
152
152
|
import { runServe, MCP_TOOLS } from './commands/serve.js';
|
|
153
|
-
import { deregisterPlugin, listPlugins, registerPlugin, syncPluginsFromRepos, SYNC_REPO_NAMES, SYNC_SKIP_STATES } from './supervisor-registry.js';
|
|
153
|
+
import { deregisterPlugin, isMcpServerPlugin, listPlugins, registerPlugin, syncPluginsFromRepos, SYNC_REPO_NAMES, SYNC_SKIP_STATES } from './supervisor-registry.js';
|
|
154
154
|
import { runOps } from './commands/ops.js';
|
|
155
155
|
import { runOpsInstall } from './commands/ops-install.js';
|
|
156
156
|
import { runNpm, runPublish } from './commands/npm.js';
|
|
@@ -927,20 +927,6 @@ tunnelCmd
|
|
|
927
927
|
// ──────────────────────────────────────────────
|
|
928
928
|
// clauth mcp list
|
|
929
929
|
// ──────────────────────────────────────────────
|
|
930
|
-
// Known MCP-server plugin ids in the managed fleet. Positive allowlist
|
|
931
|
-
// rather than a naming heuristic (credential-name conventions and health
|
|
932
|
-
// route "kind" vary across these) — dev-center and any future non-MCP
|
|
933
|
-
// pm2-managed surface stay excluded by construction. Deliberately distinct
|
|
934
|
-
// from `clauth list` (vault credential services); this never touches those.
|
|
935
|
-
const MCP_SERVER_PLUGIN_IDS = new Set([
|
|
936
|
-
"fs-mcp",
|
|
937
|
-
"web-research",
|
|
938
|
-
"regen-media",
|
|
939
|
-
"regen-media-local",
|
|
940
|
-
"codeflow-mcp",
|
|
941
|
-
"rdc-skills",
|
|
942
|
-
]);
|
|
943
|
-
|
|
944
930
|
const mcpCmd = program.command("mcp").description("Inspect clauth's own MCP tool catalog and managed MCP-server surfaces");
|
|
945
931
|
|
|
946
932
|
mcpCmd
|
|
@@ -951,7 +937,7 @@ mcpCmd
|
|
|
951
937
|
for (const tool of MCP_TOOLS) {
|
|
952
938
|
console.log(` ${chalk.white(tool.name)} ${chalk.gray(tool.description || "")}`);
|
|
953
939
|
}
|
|
954
|
-
const mcpPlugins = listPlugins().filter(
|
|
940
|
+
const mcpPlugins = listPlugins().filter(isMcpServerPlugin);
|
|
955
941
|
console.log(chalk.cyan(`\n Managed MCP-server surfaces (${mcpPlugins.length}):\n`));
|
|
956
942
|
if (!mcpPlugins.length) {
|
|
957
943
|
console.log(chalk.gray(" none discovered — is the daemon running? clauth serve"));
|
|
@@ -1047,10 +1033,16 @@ pluginCmd
|
|
|
1047
1033
|
.command('deregister <id>')
|
|
1048
1034
|
.description('Remove one managed plugin directory by id and re-run discovery. Removing an unregistered id is a safe no-op.')
|
|
1049
1035
|
.option('--dry-run', 'Resolve and print the directory that would be deleted, without deleting it')
|
|
1036
|
+
.option('--expected-root <path>', 'Refuse unless the registered plugin originated from this package root')
|
|
1037
|
+
.option('--force', 'Recovery only: remove managed metadata even when process cleanup fails; receipt records the bypass')
|
|
1050
1038
|
.action((id, opts) => {
|
|
1051
1039
|
let receipt;
|
|
1052
1040
|
try {
|
|
1053
|
-
receipt = deregisterPlugin(id, 'cli', {
|
|
1041
|
+
receipt = deregisterPlugin(id, 'cli', {
|
|
1042
|
+
dryRun: Boolean(opts.dryRun),
|
|
1043
|
+
expectedRoot: opts.expectedRoot,
|
|
1044
|
+
force: Boolean(opts.force),
|
|
1045
|
+
});
|
|
1054
1046
|
} catch (error) {
|
|
1055
1047
|
console.error(` ✗ deregister_failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1056
1048
|
process.exitCode = 1;
|
|
@@ -20,6 +20,65 @@ const DEFAULT_HEALTH_TIMEOUT_MS = 2500;
|
|
|
20
20
|
const HEALTH_RECONCILE_COOLDOWN_MS = 15000;
|
|
21
21
|
const DOCUMENTATION_FIELDS = ["architecture", "operator_guide", "install", "runbook", "tool_reference", "release", "agent_context"];
|
|
22
22
|
|
|
23
|
+
function hasMcpToken(value) {
|
|
24
|
+
return /(^|[^a-z0-9])mcp([^a-z0-9]|$)/i.test(String(value || ""));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function routeDeclaresMcp(route) {
|
|
28
|
+
if (!route || typeof route !== "object") return false;
|
|
29
|
+
if (hasMcpToken(route.id) || hasMcpToken(route.kind)) return true;
|
|
30
|
+
try {
|
|
31
|
+
return new URL(String(route.url || "")).pathname
|
|
32
|
+
.split("/")
|
|
33
|
+
.some((segment) => segment.toLowerCase() === "mcp");
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeCapabilities(value) {
|
|
40
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return { kinds: [] };
|
|
41
|
+
const kinds = Array.isArray(value.kinds)
|
|
42
|
+
? [...new Set(value.kinds.map((kind) => String(kind || "").trim().toLowerCase())
|
|
43
|
+
.filter((kind) => /^[a-z0-9_.-]+$/.test(kind)))]
|
|
44
|
+
: [];
|
|
45
|
+
return { kinds };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeMcpContract(value) {
|
|
49
|
+
if (value === true) return { declared: true, transport: null, url: null, stdio: [], tools: [] };
|
|
50
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
51
|
+
return {
|
|
52
|
+
declared: true,
|
|
53
|
+
transport: value.transport ? String(value.transport) : null,
|
|
54
|
+
url: value.url ? String(value.url) : null,
|
|
55
|
+
stdio: normalizeCommand(value.stdio, "mcp.stdio"),
|
|
56
|
+
tools: Array.isArray(value.tools)
|
|
57
|
+
? value.tools.map((tool) => String(tool || "").trim()).filter((tool) => /^[a-zA-Z0-9_.-]+$/.test(tool))
|
|
58
|
+
: [],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Classify an MCP server from its own manifest contract. New plugins declare
|
|
64
|
+
* `mcp` or `capabilities.kinds`; route/name/document signals retain backwards
|
|
65
|
+
* compatibility with manifests created before those fields existed.
|
|
66
|
+
*/
|
|
67
|
+
export function isMcpServerPlugin(plugin) {
|
|
68
|
+
if (!plugin || typeof plugin !== "object") return false;
|
|
69
|
+
if (plugin.mcp === true || (plugin.mcp && typeof plugin.mcp === "object")) return true;
|
|
70
|
+
if ((plugin.capabilities?.kinds || []).some((kind) => ["mcp", "mcp-server"].includes(String(kind).toLowerCase()))) return true;
|
|
71
|
+
|
|
72
|
+
const routes = [
|
|
73
|
+
...(Array.isArray(plugin.routes) ? plugin.routes : []),
|
|
74
|
+
...(Array.isArray(plugin.surfaces) ? plugin.surfaces.flatMap((surface) => surface?.routes || []) : []),
|
|
75
|
+
];
|
|
76
|
+
if (routes.some(routeDeclaresMcp)) return true;
|
|
77
|
+
if (hasMcpToken(plugin.id)) return true;
|
|
78
|
+
if ((plugin.surfaces || []).some((surface) => hasMcpToken(surface?.id) || hasMcpToken(surface?.name))) return true;
|
|
79
|
+
return hasMcpToken(plugin.documentation?.agent_context);
|
|
80
|
+
}
|
|
81
|
+
|
|
23
82
|
export function getSupervisorPort() {
|
|
24
83
|
return Number(process.env.CLAUTH_SUPERVISOR_PORT || DEFAULT_SUPERVISOR_PORT);
|
|
25
84
|
}
|
|
@@ -454,6 +513,7 @@ export function validatePluginManifest(manifest, sourcePath = "") {
|
|
|
454
513
|
core: manifest.core === true,
|
|
455
514
|
enable_default: manifest.enable_default === true,
|
|
456
515
|
sourcePath,
|
|
516
|
+
package_root: manifest._clauth?.package_root ? path.resolve(String(manifest._clauth.package_root)) : null,
|
|
457
517
|
destination: normalizeDestination(manifest.destination),
|
|
458
518
|
lifecycle_owner: normalizeLifecycleOwner(manifest.lifecycle_owner),
|
|
459
519
|
credentials: Array.isArray(manifest.credentials) ? manifest.credentials.map((c) => ({
|
|
@@ -462,6 +522,8 @@ export function validatePluginManifest(manifest, sourcePath = "") {
|
|
|
462
522
|
description: String(c.description || ""),
|
|
463
523
|
required: c.required !== false,
|
|
464
524
|
})).filter((c) => /^[a-zA-Z0-9_.-]+$/.test(c.name)) : [],
|
|
525
|
+
capabilities: normalizeCapabilities(manifest.capabilities),
|
|
526
|
+
mcp: normalizeMcpContract(manifest.mcp),
|
|
465
527
|
surfaces: [],
|
|
466
528
|
routes: Array.isArray(manifest.routes) ? manifest.routes : [],
|
|
467
529
|
test: manifest.test && typeof manifest.test === "object" ? {
|
|
@@ -472,6 +534,7 @@ export function validatePluginManifest(manifest, sourcePath = "") {
|
|
|
472
534
|
} : null,
|
|
473
535
|
};
|
|
474
536
|
plugin.surfaces = (Array.isArray(manifest.surfaces) ? manifest.surfaces : []).map((surface) => normalizeSurface(surface, plugin));
|
|
537
|
+
plugin.mcp_server = isMcpServerPlugin(plugin);
|
|
475
538
|
return plugin;
|
|
476
539
|
}
|
|
477
540
|
|
|
@@ -635,7 +698,10 @@ export function registerPlugin(manifestPath, actor = "localhost") {
|
|
|
635
698
|
.replace(/%PACKAGE_ROOT%/gi, packageRoot);
|
|
636
699
|
let manifest;
|
|
637
700
|
try {
|
|
638
|
-
|
|
701
|
+
const parsed = JSON.parse(raw);
|
|
702
|
+
parsed._clauth = { package_root: packageRoot };
|
|
703
|
+
raw = `${JSON.stringify(parsed, null, 2)}\n`;
|
|
704
|
+
manifest = validatePluginManifest(parsed, manifestPath);
|
|
639
705
|
} catch (error) {
|
|
640
706
|
return operation("plugin.register", { manifest_path: manifestPath }, null, {
|
|
641
707
|
ok: false, state: "manifest_invalid", error: error instanceof Error ? error.message : String(error),
|
|
@@ -820,7 +886,7 @@ export function syncPluginsFromRepos(repoRoots = {}, actor = "localhost") {
|
|
|
820
886
|
// reaching this function is attacker-influenced input (a CLI arg or an HTTP
|
|
821
887
|
// field, with no manifest validation upstream to lean on) and this deletes
|
|
822
888
|
// recursively, so both guards below are load-bearing.
|
|
823
|
-
export function deregisterPlugin(id, actor = "localhost", { dryRun = false } = {}) {
|
|
889
|
+
export function deregisterPlugin(id, actor = "localhost", { dryRun = false, expectedRoot = null, force = false } = {}) {
|
|
824
890
|
const pluginId = String(id ?? "").trim();
|
|
825
891
|
// Guard 1 — charset + all-dots, the same rule a manifest id must satisfy.
|
|
826
892
|
//
|
|
@@ -838,7 +904,7 @@ export function deregisterPlugin(id, actor = "localhost", { dryRun = false } = {
|
|
|
838
904
|
}
|
|
839
905
|
const roots = rootEntries();
|
|
840
906
|
const managedRoots = roots.filter((entry) => entry.source === "managed");
|
|
841
|
-
|
|
907
|
+
let prior = (loadSupervisorState().plugins || []).find((plugin) => plugin.id === pluginId) || null;
|
|
842
908
|
|
|
843
909
|
// Resolve the plugin's ACTUAL directory rather than assuming a flat
|
|
844
910
|
// <first-managed-root>/<id> layout. Three real layouts exist that assumption
|
|
@@ -899,6 +965,26 @@ export function deregisterPlugin(id, actor = "localhost", { dryRun = false } = {
|
|
|
899
965
|
ok: false, state: "not_managed", error: "resolved plugin directory is not inside a managed plugin root",
|
|
900
966
|
}, actor);
|
|
901
967
|
}
|
|
968
|
+
if (!prior) {
|
|
969
|
+
prior = discoverPlugins().plugins.find((plugin) => plugin.id === pluginId) || null;
|
|
970
|
+
}
|
|
971
|
+
if (expectedRoot) {
|
|
972
|
+
const expected = path.resolve(String(expectedRoot));
|
|
973
|
+
if (!prior?.package_root) {
|
|
974
|
+
return operation("plugin.deregister", { plugin_id: pluginId }, prior, {
|
|
975
|
+
ok: false, state: "ownership_unverified", error: "registered plugin has no package-root ownership record; re-register it before deregistering",
|
|
976
|
+
}, actor);
|
|
977
|
+
}
|
|
978
|
+
const actual = path.resolve(prior.package_root);
|
|
979
|
+
const matches = process.platform === "win32"
|
|
980
|
+
? actual.toLowerCase() === expected.toLowerCase()
|
|
981
|
+
: actual === expected;
|
|
982
|
+
if (!matches) {
|
|
983
|
+
return operation("plugin.deregister", { plugin_id: pluginId }, prior, {
|
|
984
|
+
ok: false, state: "ownership_mismatch", error: `registered package root ${actual} does not match expected root ${expected}`,
|
|
985
|
+
}, actor);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
902
988
|
if (dryRun) {
|
|
903
989
|
return operation("plugin.deregister.dry_run", { plugin_id: pluginId }, prior, {
|
|
904
990
|
ok: true,
|
|
@@ -906,8 +992,43 @@ export function deregisterPlugin(id, actor = "localhost", { dryRun = false } = {
|
|
|
906
992
|
target_dir: targetDir,
|
|
907
993
|
plugin_state: prior?.state || "unknown",
|
|
908
994
|
surfaces: (prior?.surfaces || []).map((surface) => surface.id),
|
|
995
|
+
package_root: prior?.package_root || null,
|
|
909
996
|
}, actor);
|
|
910
997
|
}
|
|
998
|
+
|
|
999
|
+
const cleanup = [];
|
|
1000
|
+
const seenStopCommands = new Set();
|
|
1001
|
+
for (const surface of prior?.surfaces || []) {
|
|
1002
|
+
if (surface.lifecycle_owner !== "clauth" || surface.destination !== "local/clauth/pm2") continue;
|
|
1003
|
+
const signature = `${surface.cwd || ""}\0${JSON.stringify(surface.stop || [])}`;
|
|
1004
|
+
if (seenStopCommands.has(signature)) continue;
|
|
1005
|
+
seenStopCommands.add(signature);
|
|
1006
|
+
if (!Array.isArray(surface.stop) || surface.stop.length === 0) {
|
|
1007
|
+
const unavailable = { surface_id: surface.id, ok: false, state: "command_missing", forced: Boolean(force) };
|
|
1008
|
+
cleanup.push(unavailable);
|
|
1009
|
+
if (!force) {
|
|
1010
|
+
return operation("plugin.deregister", { plugin_id: pluginId }, prior, {
|
|
1011
|
+
ok: false, state: "surface_cleanup_unavailable", error: `managed surface ${surface.id} has no stop command`, cleanup,
|
|
1012
|
+
}, actor);
|
|
1013
|
+
}
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
const stopReceipt = runSurfaceAction(`${pluginId}:${surface.id}`, "stop", actor);
|
|
1017
|
+
const summary = {
|
|
1018
|
+
surface_id: surface.id,
|
|
1019
|
+
ok: stopReceipt.resulting_state?.ok === true,
|
|
1020
|
+
state: stopReceipt.resulting_state?.state || stopReceipt.error || "unknown",
|
|
1021
|
+
};
|
|
1022
|
+
cleanup.push(summary);
|
|
1023
|
+
if (!summary.ok) {
|
|
1024
|
+
summary.forced = Boolean(force);
|
|
1025
|
+
if (!force) {
|
|
1026
|
+
return operation("plugin.deregister", { plugin_id: pluginId }, prior, {
|
|
1027
|
+
ok: false, state: "surface_cleanup_failed", error: `failed to stop managed surface ${surface.id}`, cleanup,
|
|
1028
|
+
}, actor);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
911
1032
|
try {
|
|
912
1033
|
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
913
1034
|
} catch (error) {
|
|
@@ -919,9 +1040,12 @@ export function deregisterPlugin(id, actor = "localhost", { dryRun = false } = {
|
|
|
919
1040
|
const after = discovery.plugins.find((plugin) => plugin.id === pluginId);
|
|
920
1041
|
return operation("plugin.deregister", { plugin_id: pluginId }, prior, {
|
|
921
1042
|
ok: true,
|
|
922
|
-
state: "deregistered",
|
|
1043
|
+
state: force && cleanup.some((entry) => !entry.ok) ? "deregistered_forced" : "deregistered",
|
|
923
1044
|
plugin_state: after?.state || "not_found",
|
|
924
1045
|
surfaces: (prior?.surfaces || []).map((surface) => surface.id),
|
|
1046
|
+
cleanup,
|
|
1047
|
+
forced: Boolean(force),
|
|
1048
|
+
evidence: force && cleanup.some((entry) => !entry.ok) ? ["force_cleanup_bypass=true"] : [],
|
|
925
1049
|
}, actor);
|
|
926
1050
|
}
|
|
927
1051
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
deregisterPlugin,
|
|
10
10
|
discoverPlugins,
|
|
11
11
|
getClauthPm2Home,
|
|
12
|
+
isMcpServerPlugin,
|
|
12
13
|
listPlugins,
|
|
13
14
|
listSurfaces,
|
|
14
15
|
probeAllSurfaceHealth,
|
|
@@ -88,6 +89,7 @@ function baseManifest(id, overrides = {}) {
|
|
|
88
89
|
lifecycle_owner: "clauth",
|
|
89
90
|
port: 39111,
|
|
90
91
|
health: "/health",
|
|
92
|
+
stop: [process.execPath, "--version"],
|
|
91
93
|
restart: ["node", "--version"],
|
|
92
94
|
}],
|
|
93
95
|
test: { command: ["node", "--version"], port: "auto", health: "/health", selfTest: [["node", "--version"]] },
|
|
@@ -111,6 +113,50 @@ test("validatePluginManifest accepts LIFEAI plugin contract with isolated test c
|
|
|
111
113
|
assert.equal(plugin.documentation.operator_guide, "docs/systems/example/OPERATE.md");
|
|
112
114
|
});
|
|
113
115
|
|
|
116
|
+
test("MCP-server classification is derived from manifest capabilities and legacy contract signals", () => {
|
|
117
|
+
const rtp = validatePluginManifest(baseManifest("rtp", {
|
|
118
|
+
capabilities: { kinds: ["cli", "http-service", "mcp-server"] },
|
|
119
|
+
mcp: {
|
|
120
|
+
transport: "http+stdio",
|
|
121
|
+
url: "http://127.0.0.1:3116/mcp",
|
|
122
|
+
stdio: ["node", "bin/rtp.mjs", "mcp"],
|
|
123
|
+
tools: ["rtp_query", "rtp_parse"],
|
|
124
|
+
},
|
|
125
|
+
}));
|
|
126
|
+
assert.equal(isMcpServerPlugin(rtp), true);
|
|
127
|
+
assert.deepEqual(rtp.capabilities.kinds, ["cli", "http-service", "mcp-server"]);
|
|
128
|
+
assert.deepEqual(rtp.mcp.tools, ["rtp_query", "rtp_parse"]);
|
|
129
|
+
|
|
130
|
+
const legacyMcpManifests = [
|
|
131
|
+
baseManifest("codeflow-mcp"),
|
|
132
|
+
baseManifest("fs-mcp"),
|
|
133
|
+
baseManifest("rdc-skills", {
|
|
134
|
+
routes: [{ id: "provider", kind: "external", url: "https://rdc-skills.example/mcp" }],
|
|
135
|
+
}),
|
|
136
|
+
baseManifest("regen-media", {
|
|
137
|
+
routes: [{ id: "provider", kind: "external", url: "https://media.example/mcp" }],
|
|
138
|
+
}),
|
|
139
|
+
baseManifest("web-research", {
|
|
140
|
+
documentation: {
|
|
141
|
+
architecture: "ARCHITECTURE.md",
|
|
142
|
+
agent_context: ".claude/context/web-research-mcp.md",
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
baseManifest("regen-media-local", {
|
|
146
|
+
documentation: {
|
|
147
|
+
architecture: "ARCHITECTURE.md",
|
|
148
|
+
agent_context: ".claude/context/mcp-endpoint-design.md",
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
];
|
|
152
|
+
for (const manifest of legacyMcpManifests) {
|
|
153
|
+
assert.equal(isMcpServerPlugin(validatePluginManifest(manifest)), true, manifest.id);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
assert.equal(isMcpServerPlugin(validatePluginManifest(baseManifest("dev-center"))), false);
|
|
157
|
+
assert.equal(isMcpServerPlugin(validatePluginManifest(baseManifest("factory-test-plugin"))), false);
|
|
158
|
+
});
|
|
159
|
+
|
|
114
160
|
test("validatePluginManifest accepts empty test command arrays from the v1 template", () => {
|
|
115
161
|
const plugin = validatePluginManifest(baseManifest("empty-test-command", {
|
|
116
162
|
test: { command: [], port: "auto", health: "/health", selfTest: [] },
|
|
@@ -625,6 +671,7 @@ test("registerPlugin validates, writes into the managed root, and discovers the
|
|
|
625
671
|
assert.equal(written, true);
|
|
626
672
|
const found = listPlugins().find((plugin) => plugin.id === "registered-demo");
|
|
627
673
|
assert.equal(found.enabled, true);
|
|
674
|
+
assert.equal(found.package_root, path.resolve(sourceDir));
|
|
628
675
|
|
|
629
676
|
const second = registerPlugin(manifestPath, "test");
|
|
630
677
|
assert.equal(second.resulting_state.state, "unchanged", "re-registering identical content must be a no-op, not a rewrite");
|
|
@@ -763,6 +810,8 @@ test("deregisterPlugin removes only the named plugin and leaves siblings intact"
|
|
|
763
810
|
const receipt = deregisterPlugin("web-research", "test");
|
|
764
811
|
assert.equal(receipt.resulting_state.ok, true);
|
|
765
812
|
assert.equal(receipt.resulting_state.state, "deregistered");
|
|
813
|
+
assert.equal(receipt.resulting_state.cleanup.length, 1);
|
|
814
|
+
assert.equal(receipt.resulting_state.cleanup[0].ok, true);
|
|
766
815
|
assert.equal(fs.existsSync(path.join(managed, "web-research")), false, "the named plugin directory is gone");
|
|
767
816
|
|
|
768
817
|
for (const sibling of ["codeflow-mcp", "dev-center", "regen-media", "rdc-skills"]) {
|
|
@@ -887,6 +936,63 @@ test("deregisterPlugin removes a scoped @scope/pkg plugin instead of falsely rep
|
|
|
887
936
|
assert.equal(after.state, "missing_default");
|
|
888
937
|
}));
|
|
889
938
|
|
|
939
|
+
test("deregisterPlugin proves package-root ownership before stopping or deleting", () => withTempSupervisor((root) => {
|
|
940
|
+
const managed = path.join(root, "managed");
|
|
941
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
942
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
943
|
+
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-owned-source-"));
|
|
944
|
+
const manifestPath = path.join(sourceDir, "clauth-plugin.json");
|
|
945
|
+
fs.writeFileSync(manifestPath, JSON.stringify(baseManifest("owned-demo", { core: true, enable_default: true })), "utf8");
|
|
946
|
+
assert.equal(registerPlugin(manifestPath, "test").resulting_state.ok, true);
|
|
947
|
+
|
|
948
|
+
const wrong = deregisterPlugin("owned-demo", "test", { expectedRoot: path.join(root, "other-install") });
|
|
949
|
+
assert.equal(wrong.resulting_state.ok, false);
|
|
950
|
+
assert.equal(wrong.resulting_state.state, "ownership_mismatch");
|
|
951
|
+
assert.equal(fs.existsSync(path.join(managed, "owned-demo")), true, "ownership mismatch deleted the plugin");
|
|
952
|
+
|
|
953
|
+
const correct = deregisterPlugin("owned-demo", "test", { expectedRoot: sourceDir });
|
|
954
|
+
assert.equal(correct.resulting_state.ok, true);
|
|
955
|
+
assert.equal(correct.resulting_state.state, "deregistered");
|
|
956
|
+
assert.deepEqual(correct.resulting_state.cleanup.map((entry) => entry.ok), [true]);
|
|
957
|
+
assert.equal(fs.existsSync(path.join(managed, "owned-demo")), false);
|
|
958
|
+
fs.rmSync(sourceDir, { recursive: true, force: true });
|
|
959
|
+
}));
|
|
960
|
+
|
|
961
|
+
test("deregisterPlugin requires an explicit audited force to recover stale metadata after its stop executable disappears", () => withTempSupervisor((root) => {
|
|
962
|
+
const managed = path.join(root, "managed");
|
|
963
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
964
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
965
|
+
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-stale-source-"));
|
|
966
|
+
const manifestPath = path.join(sourceDir, "clauth-plugin.json");
|
|
967
|
+
fs.writeFileSync(manifestPath, JSON.stringify(baseManifest("stale-demo", {
|
|
968
|
+
core: true,
|
|
969
|
+
enable_default: true,
|
|
970
|
+
surfaces: [{
|
|
971
|
+
id: "primary",
|
|
972
|
+
destination: "local/clauth/pm2",
|
|
973
|
+
lifecycle_owner: "clauth",
|
|
974
|
+
port: 39111,
|
|
975
|
+
health: "/health",
|
|
976
|
+
stop: [process.execPath, path.join(sourceDir, "removed-stop-script.cjs")],
|
|
977
|
+
}],
|
|
978
|
+
})), "utf8");
|
|
979
|
+
assert.equal(registerPlugin(manifestPath, "test").resulting_state.ok, true);
|
|
980
|
+
|
|
981
|
+
const ordinary = deregisterPlugin("stale-demo", "test", { expectedRoot: sourceDir });
|
|
982
|
+
assert.equal(ordinary.resulting_state.ok, false);
|
|
983
|
+
assert.equal(ordinary.resulting_state.state, "surface_cleanup_failed");
|
|
984
|
+
assert.equal(fs.existsSync(path.join(managed, "stale-demo")), true, "default failure must preserve recovery metadata");
|
|
985
|
+
|
|
986
|
+
const forced = deregisterPlugin("stale-demo", "test", { force: true });
|
|
987
|
+
assert.equal(forced.resulting_state.ok, true);
|
|
988
|
+
assert.equal(forced.resulting_state.state, "deregistered_forced");
|
|
989
|
+
assert.equal(forced.resulting_state.cleanup[0].ok, false);
|
|
990
|
+
assert.equal(forced.resulting_state.cleanup[0].forced, true);
|
|
991
|
+
assert.deepEqual(forced.evidence, ["force_cleanup_bypass=true"]);
|
|
992
|
+
assert.equal(fs.existsSync(path.join(managed, "stale-demo")), false);
|
|
993
|
+
fs.rmSync(sourceDir, { recursive: true, force: true });
|
|
994
|
+
}));
|
|
995
|
+
|
|
890
996
|
test("deregisterPlugin finds a plugin in a non-first managed root and refuses a user-root plugin", () => withTempSupervisor((root) => {
|
|
891
997
|
// CLAUTH_MANAGED_PLUGIN_ROOTS is a path-delimited LIST; honoring only the
|
|
892
998
|
// first entry silently reports a real plugin as absent.
|