@lifeaitools/clauth 1.31.1 → 2.0.0
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/operator-guide.md +27 -0
- package/README.md +48 -0
- package/cli/commands/ops-install.js +211 -0
- package/cli/commands/ops.js +69 -0
- package/cli/commands/serve.js +1241 -1676
- package/cli/commands/watchdog.js +1 -1
- package/cli/index.js +93 -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 +112 -6
- package/cli/supervisor-registry.test.js +151 -0
- package/cli/watchdog-registry.js +30 -2
- package/cli/watchdog-registry.test.js +28 -5
- package/package.json +3 -2
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
|
@@ -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) {
|
|
@@ -224,7 +248,7 @@ function localhostHealth(pathOrUrl, port) {
|
|
|
224
248
|
function normalizeSurface(surface, plugin) {
|
|
225
249
|
if (!surface || typeof surface !== "object") throw new Error("surface must be an object");
|
|
226
250
|
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");
|
|
251
|
+
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
252
|
const destination = normalizeDestination(surface.destination || plugin.destination);
|
|
229
253
|
const lifecycle_owner = normalizeLifecycleOwner(surface.lifecycle_owner || plugin.lifecycle_owner);
|
|
230
254
|
const port = surface.port === undefined || surface.port === null || surface.port === "auto" ? surface.port ?? null : Number(surface.port);
|
|
@@ -249,7 +273,7 @@ export function validatePluginManifest(manifest, sourcePath = "") {
|
|
|
249
273
|
if (!manifest || typeof manifest !== "object") throw new Error("manifest must be an object");
|
|
250
274
|
if (manifest.schema !== SCHEMA) throw new Error(`schema must be ${SCHEMA}`);
|
|
251
275
|
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");
|
|
276
|
+
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
277
|
const version = String(manifest.version || "").trim();
|
|
254
278
|
if (!version) throw new Error("version is required");
|
|
255
279
|
const plugin = {
|
|
@@ -301,6 +325,17 @@ function findManifestFiles(root) {
|
|
|
301
325
|
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
302
326
|
const full = path.join(root, entry.name);
|
|
303
327
|
if (entry.isDirectory()) {
|
|
328
|
+
// A scoped npm package (@lifeaitools/fs-mcp) installs two levels deep
|
|
329
|
+
// under node_modules/@scope/pkg-name/ — descend one extra level only
|
|
330
|
+
// for @scope directories. Unscoped layout (one level) is unchanged.
|
|
331
|
+
if (entry.name.startsWith("@")) {
|
|
332
|
+
for (const scopedEntry of fs.readdirSync(full, { withFileTypes: true })) {
|
|
333
|
+
if (!scopedEntry.isDirectory()) continue;
|
|
334
|
+
const scopedCandidate = path.join(full, scopedEntry.name, "clauth-plugin.json");
|
|
335
|
+
if (fs.existsSync(scopedCandidate)) out.push(scopedCandidate);
|
|
336
|
+
}
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
304
339
|
const candidate = path.join(full, "clauth-plugin.json");
|
|
305
340
|
if (fs.existsSync(candidate)) out.push(candidate);
|
|
306
341
|
} else if (entry.isFile() && entry.name === "clauth-plugin.json") {
|
|
@@ -389,6 +424,65 @@ export function discoverPlugins() {
|
|
|
389
424
|
return { plugins, surfaces, events };
|
|
390
425
|
}
|
|
391
426
|
|
|
427
|
+
// Registers one plugin manifest into the managed-plugins root, then runs
|
|
428
|
+
// discovery so it's picked up immediately. This is the entry point a product
|
|
429
|
+
// repo's own install/deploy step calls to self-register — the mechanism the
|
|
430
|
+
// PLUGIN-ARCHITECTURE-DECISION.md "each MCP ships its own clauth-plugin.json"
|
|
431
|
+
// model needs for a monorepo workspace member (no npm install lifecycle hook
|
|
432
|
+
// to piggyback on, unlike a standalone published package with postinstall.js).
|
|
433
|
+
// Idempotent: re-registering unchanged content is a safe no-op re-affirm.
|
|
434
|
+
export function registerPlugin(manifestPath, actor = "localhost") {
|
|
435
|
+
let raw;
|
|
436
|
+
try {
|
|
437
|
+
raw = fs.readFileSync(manifestPath, "utf8");
|
|
438
|
+
} catch (error) {
|
|
439
|
+
return operation("plugin.register", { manifest_path: manifestPath }, null, {
|
|
440
|
+
ok: false, state: "manifest_unreadable", error: error instanceof Error ? error.message : String(error),
|
|
441
|
+
}, actor);
|
|
442
|
+
}
|
|
443
|
+
let manifest;
|
|
444
|
+
try {
|
|
445
|
+
manifest = validatePluginManifest(JSON.parse(raw), manifestPath);
|
|
446
|
+
} catch (error) {
|
|
447
|
+
return operation("plugin.register", { manifest_path: manifestPath }, null, {
|
|
448
|
+
ok: false, state: "manifest_invalid", error: error instanceof Error ? error.message : String(error),
|
|
449
|
+
}, actor);
|
|
450
|
+
}
|
|
451
|
+
const [{ root: managedRoot }] = rootEntries();
|
|
452
|
+
const resolvedRoot = path.resolve(managedRoot);
|
|
453
|
+
const targetDir = path.resolve(managedRoot, manifest.id);
|
|
454
|
+
// Belt-and-braces: the id regex already rejects traversal-shaped ids, but
|
|
455
|
+
// this asserts containment at the actual write site so a future regex
|
|
456
|
+
// relaxation can't silently reopen a path escape out of the managed root —
|
|
457
|
+
// this is a credential vault writing files from parsed manifest content.
|
|
458
|
+
if (targetDir !== resolvedRoot && !targetDir.startsWith(resolvedRoot + path.sep)) {
|
|
459
|
+
return operation("plugin.register", { manifest_path: manifestPath, plugin_id: manifest.id }, null, {
|
|
460
|
+
ok: false, state: "manifest_invalid", error: "plugin id escapes the managed plugin root",
|
|
461
|
+
}, actor);
|
|
462
|
+
}
|
|
463
|
+
const targetPath = path.join(targetDir, "clauth-plugin.json");
|
|
464
|
+
const priorRaw = fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : null;
|
|
465
|
+
const unchanged = priorRaw !== null && sha256(priorRaw) === sha256(raw);
|
|
466
|
+
if (!unchanged) {
|
|
467
|
+
try {
|
|
468
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
469
|
+
fs.writeFileSync(targetPath, raw, "utf8");
|
|
470
|
+
} catch (error) {
|
|
471
|
+
return operation("plugin.register", { manifest_path: manifestPath, plugin_id: manifest.id }, null, {
|
|
472
|
+
ok: false, state: "write_failed", error: error instanceof Error ? error.message : String(error),
|
|
473
|
+
}, actor);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const discovery = discoverPlugins();
|
|
477
|
+
const registered = discovery.plugins.find((plugin) => plugin.id === manifest.id);
|
|
478
|
+
return operation("plugin.register", { manifest_path: manifestPath, plugin_id: manifest.id }, null, {
|
|
479
|
+
ok: Boolean(registered) && registered.state !== "manifest_invalid",
|
|
480
|
+
state: unchanged ? "unchanged" : "registered",
|
|
481
|
+
plugin_state: registered?.state || "not_found",
|
|
482
|
+
surfaces: registered?.surfaces?.map((surface) => surface.id) || [],
|
|
483
|
+
}, actor);
|
|
484
|
+
}
|
|
485
|
+
|
|
392
486
|
export function listPlugins() {
|
|
393
487
|
return loadSupervisorState().plugins || [];
|
|
394
488
|
}
|
|
@@ -417,7 +511,7 @@ export function readSupervisorEvents(limit = 100) {
|
|
|
417
511
|
});
|
|
418
512
|
}
|
|
419
513
|
|
|
420
|
-
function operation(action, target, prior, result, actor = "localhost") {
|
|
514
|
+
export function operation(action, target, prior, result, actor = "localhost") {
|
|
421
515
|
const receipt = {
|
|
422
516
|
operationId: crypto.randomUUID(),
|
|
423
517
|
actor,
|
|
@@ -517,10 +611,22 @@ export function runSurfaceAction(id, action, actor = "localhost") {
|
|
|
517
611
|
}
|
|
518
612
|
const execute = (selectedCommand) => {
|
|
519
613
|
const [cmd, ...args] = selectedCommand;
|
|
520
|
-
|
|
614
|
+
const useShell = process.platform === "win32";
|
|
615
|
+
// With shell:true on Windows, spawnSync hands cmd/args to cmd.exe /c
|
|
616
|
+
// verbatim and does NOT auto-quote — an absolute path or arg containing
|
|
617
|
+
// a space (e.g. "C:\Program Files\nodejs\node.exe") breaks at the first
|
|
618
|
+
// space unless quoted. Bare shim names (pm2, npm) never contain spaces,
|
|
619
|
+
// so this only ever affects absolute-path values, and only on Windows.
|
|
620
|
+
const resolvedCmd = shellQuote(cmd, useShell);
|
|
621
|
+
const resolvedArgs = args.map((arg) => shellQuote(arg, useShell));
|
|
622
|
+
return spawnSync(resolvedCmd, resolvedArgs, {
|
|
521
623
|
cwd: surface.cwd || undefined,
|
|
522
624
|
env: { ...process.env, CLAUTH_PM2_HOME: getClauthPm2Home(), PM2_HOME: getClauthPm2Home() },
|
|
523
625
|
windowsHide: true,
|
|
626
|
+
// Windows resolves CLI shims (pm2, npm, etc.) to .cmd files that
|
|
627
|
+
// spawnSync cannot exec directly without a shell — ENOENT otherwise.
|
|
628
|
+
// POSIX targets (Vultr/Coolify) need no shell and keep prior behavior.
|
|
629
|
+
shell: useShell,
|
|
524
630
|
encoding: "utf8",
|
|
525
631
|
timeout: Number(surface.timeoutMs || 30000),
|
|
526
632
|
});
|
|
@@ -11,10 +11,12 @@ import {
|
|
|
11
11
|
listPlugins,
|
|
12
12
|
listSurfaces,
|
|
13
13
|
reconcileSurfaceHealth,
|
|
14
|
+
registerPlugin,
|
|
14
15
|
runPluginAction,
|
|
15
16
|
runSurfaceAction,
|
|
16
17
|
removeTunnelRoute,
|
|
17
18
|
setPluginEnabled,
|
|
19
|
+
shellQuote,
|
|
18
20
|
supervisorHealth,
|
|
19
21
|
validatePluginManifest,
|
|
20
22
|
} from "./supervisor-registry.js";
|
|
@@ -50,6 +52,15 @@ function writePlugin(root, source, id, manifest) {
|
|
|
50
52
|
return dir;
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
// A scoped npm package installs at <root>/@scope/pkg-name/clauth-plugin.json —
|
|
56
|
+
// two levels deep, mirroring node_modules/@lifeaitools/<pkg>/.
|
|
57
|
+
function writeScopedPlugin(root, source, scope, id, manifest) {
|
|
58
|
+
const dir = path.join(root, source, scope, id);
|
|
59
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
60
|
+
fs.writeFileSync(path.join(dir, "clauth-plugin.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
61
|
+
return dir;
|
|
62
|
+
}
|
|
63
|
+
|
|
53
64
|
function baseManifest(id, overrides = {}) {
|
|
54
65
|
return {
|
|
55
66
|
schema: "lifeai.plugin.v1",
|
|
@@ -94,6 +105,32 @@ test("validatePluginManifest accepts empty test command arrays from the v1 templ
|
|
|
94
105
|
assert.equal(plugin.test.port, "auto");
|
|
95
106
|
});
|
|
96
107
|
|
|
108
|
+
test("shellQuote quotes a whitespace-bearing value only when shell is active, and is idempotent", () => {
|
|
109
|
+
assert.equal(shellQuote("pm2", true), "pm2");
|
|
110
|
+
assert.equal(shellQuote("C:/Program Files/nodejs/node.exe", true), '"C:/Program Files/nodejs/node.exe"');
|
|
111
|
+
assert.equal(shellQuote("C:/Program Files/nodejs/node.exe", false), "C:/Program Files/nodejs/node.exe");
|
|
112
|
+
assert.equal(shellQuote("one two", false), "one two");
|
|
113
|
+
assert.equal(shellQuote('"already quoted value"', true), '"already quoted value"');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("validatePluginManifest rejects shell metacharacters in command ARGS, not just command[0]", () => {
|
|
117
|
+
// The [;&|<>] guard on command[0] alone is defeated by moving the payload
|
|
118
|
+
// one index right — args must be validated the same way (verified live
|
|
119
|
+
// during code review: spawnSync(['cmd','/c','echo','A & echo INJECTED'],
|
|
120
|
+
// {shell:true}) executed a second command from inside one arg string).
|
|
121
|
+
assert.throws(() => validatePluginManifest(baseManifest("inject", {
|
|
122
|
+
surfaces: [{ id: "primary", lifecycle_owner: "clauth", restart: ["pm2", "start svc & calc.exe"] }],
|
|
123
|
+
})), /shell syntax/);
|
|
124
|
+
assert.throws(() => validatePluginManifest(baseManifest("inject2", {
|
|
125
|
+
surfaces: [{ id: "primary", lifecycle_owner: "clauth", restart: ["pm2", "restart", "name; rm -rf /"] }],
|
|
126
|
+
})), /shell syntax/);
|
|
127
|
+
// A legitimate arg with no shell metacharacters still passes.
|
|
128
|
+
const ok = validatePluginManifest(baseManifest("legit", {
|
|
129
|
+
surfaces: [{ id: "primary", lifecycle_owner: "clauth", restart: ["pm2", "restart", "my-app-name"] }],
|
|
130
|
+
}), "clauth-plugin.json");
|
|
131
|
+
assert.deepEqual(ok.surfaces[0].restart, ["pm2", "restart", "my-app-name"]);
|
|
132
|
+
});
|
|
133
|
+
|
|
97
134
|
test("validatePluginManifest rejects invalid manifests and non-local health URLs", () => {
|
|
98
135
|
assert.throws(() => validatePluginManifest({ ...baseManifest("bad"), schema: "bad" }), /schema/);
|
|
99
136
|
assert.throws(() => validatePluginManifest(baseManifest("bad", {
|
|
@@ -126,6 +163,50 @@ test("discovery merges managed and user roots without silent managed-id shadowin
|
|
|
126
163
|
assert.match(invalid.error, /shadow/);
|
|
127
164
|
}));
|
|
128
165
|
|
|
166
|
+
test("discovery finds both a scoped npm-package layout and an unscoped layout from one root", () => withTempSupervisor((root) => {
|
|
167
|
+
const managed = path.join(root, "managed");
|
|
168
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
169
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
170
|
+
// Unscoped: managed/plain-plugin/clauth-plugin.json (one level — existing behavior)
|
|
171
|
+
writePlugin(root, "managed", "plain-plugin", baseManifest("plain-plugin"));
|
|
172
|
+
// Scoped: managed/@lifeaitools/fs-mcp/clauth-plugin.json (two levels — the WP-1 fix)
|
|
173
|
+
writeScopedPlugin(root, "managed", "@lifeaitools", "fs-mcp", baseManifest("fs-mcp"));
|
|
174
|
+
|
|
175
|
+
const result = discoverPlugins();
|
|
176
|
+
const plain = result.plugins.find((plugin) => plugin.id === "plain-plugin");
|
|
177
|
+
const scoped = result.plugins.find((plugin) => plugin.id === "fs-mcp");
|
|
178
|
+
assert.ok(plain, "unscoped layout must still be discovered");
|
|
179
|
+
assert.equal(plain.state, "awaiting_enable");
|
|
180
|
+
assert.ok(scoped, "scoped @scope/pkg layout must now be discovered");
|
|
181
|
+
assert.equal(scoped.state, "awaiting_enable");
|
|
182
|
+
assert.equal(scoped.manifest_hash?.length > 0, true);
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
test("discovery skips a malformed manifest without aborting the rest of the scan", () => withTempSupervisor((root) => {
|
|
186
|
+
const managed = path.join(root, "managed");
|
|
187
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
188
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
189
|
+
writePlugin(root, "managed", "good-plugin", baseManifest("good-plugin"));
|
|
190
|
+
const brokenDir = path.join(managed, "broken-plugin");
|
|
191
|
+
fs.mkdirSync(brokenDir, { recursive: true });
|
|
192
|
+
fs.writeFileSync(path.join(brokenDir, "clauth-plugin.json"), "{ this is not valid json", "utf8");
|
|
193
|
+
// Also prove a malformed SCOPED manifest doesn't abort the scoped-dir descent either.
|
|
194
|
+
const brokenScopedDir = path.join(managed, "@lifeaitools", "broken-scoped");
|
|
195
|
+
fs.mkdirSync(brokenScopedDir, { recursive: true });
|
|
196
|
+
fs.writeFileSync(path.join(brokenScopedDir, "clauth-plugin.json"), "{ also not valid json", "utf8");
|
|
197
|
+
writeScopedPlugin(root, "managed", "@lifeaitools", "good-scoped", baseManifest("good-scoped"));
|
|
198
|
+
|
|
199
|
+
const result = discoverPlugins();
|
|
200
|
+
const good = result.plugins.find((plugin) => plugin.id === "good-plugin");
|
|
201
|
+
const broken = result.plugins.find((plugin) => plugin.id === "broken-plugin");
|
|
202
|
+
const goodScoped = result.plugins.find((plugin) => plugin.id === "good-scoped");
|
|
203
|
+
const brokenScoped = result.plugins.find((plugin) => plugin.id === "broken-scoped");
|
|
204
|
+
assert.equal(good.state, "awaiting_enable");
|
|
205
|
+
assert.equal(broken.state, "manifest_invalid");
|
|
206
|
+
assert.equal(goodScoped.state, "awaiting_enable");
|
|
207
|
+
assert.equal(brokenScoped.state, "manifest_invalid");
|
|
208
|
+
}));
|
|
209
|
+
|
|
129
210
|
test("trusted managed core plugins auto-enable while user plugins remain opt-in", () => withTempSupervisor((root) => {
|
|
130
211
|
const managed = path.join(root, "managed");
|
|
131
212
|
const user = path.join(root, "user");
|
|
@@ -378,6 +459,76 @@ test("supervisor log DTO strips raw operation payloads", () => {
|
|
|
378
459
|
assert.equal(json.includes("--token"), false);
|
|
379
460
|
});
|
|
380
461
|
|
|
462
|
+
test("registerPlugin validates, writes into the managed root, and discovers the plugin — idempotent on re-register", () => withTempSupervisor((root) => {
|
|
463
|
+
const managed = path.join(root, "managed");
|
|
464
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
465
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
466
|
+
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-register-source-"));
|
|
467
|
+
const manifestPath = path.join(sourceDir, "clauth-plugin.json");
|
|
468
|
+
fs.writeFileSync(manifestPath, JSON.stringify(baseManifest("registered-demo", { core: true, enable_default: true })), "utf8");
|
|
469
|
+
|
|
470
|
+
const first = registerPlugin(manifestPath, "test");
|
|
471
|
+
assert.equal(first.resulting_state.ok, true);
|
|
472
|
+
assert.equal(first.resulting_state.state, "registered");
|
|
473
|
+
assert.equal(first.resulting_state.plugin_state, "current");
|
|
474
|
+
const written = fs.existsSync(path.join(managed, "registered-demo", "clauth-plugin.json"));
|
|
475
|
+
assert.equal(written, true);
|
|
476
|
+
const found = listPlugins().find((plugin) => plugin.id === "registered-demo");
|
|
477
|
+
assert.equal(found.enabled, true);
|
|
478
|
+
|
|
479
|
+
const second = registerPlugin(manifestPath, "test");
|
|
480
|
+
assert.equal(second.resulting_state.state, "unchanged", "re-registering identical content must be a no-op, not a rewrite");
|
|
481
|
+
|
|
482
|
+
fs.rmSync(sourceDir, { recursive: true, force: true });
|
|
483
|
+
}));
|
|
484
|
+
|
|
485
|
+
test("registerPlugin rejects a plugin id that would escape the managed-plugins root", () => withTempSupervisor((root) => {
|
|
486
|
+
// Code-review finding (confidence 95, live PoC): manifest.id of ".." passed
|
|
487
|
+
// the old id regex (dot is in the allowed character class with no
|
|
488
|
+
// exclusion of all-dots forms) and path.join(managedRoot, "..") wrote
|
|
489
|
+
// clauth-plugin.json one level ABOVE the managed root. Two independent
|
|
490
|
+
// fixes now close this: the id regex rejects all-dots ids, and
|
|
491
|
+
// registerPlugin asserts containment at the write site so a future regex
|
|
492
|
+
// relaxation can't reopen it.
|
|
493
|
+
const managed = path.join(root, "managed");
|
|
494
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
495
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
496
|
+
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-register-traversal-"));
|
|
497
|
+
const manifestPath = path.join(sourceDir, "clauth-plugin.json");
|
|
498
|
+
fs.writeFileSync(manifestPath, JSON.stringify({ schema: "lifeai.plugin.v1", id: "..", version: "1.0.0", publisher: "t", documentation: { architecture: "a.md" }, surfaces: [] }), "utf8");
|
|
499
|
+
|
|
500
|
+
const receipt = registerPlugin(manifestPath, "test");
|
|
501
|
+
assert.equal(receipt.resulting_state.ok, false);
|
|
502
|
+
assert.equal(receipt.resulting_state.state, "manifest_invalid");
|
|
503
|
+
const escapedPath = path.join(managed, "..", "clauth-plugin.json");
|
|
504
|
+
assert.equal(fs.existsSync(escapedPath), false, "must never write outside the managed-plugins root");
|
|
505
|
+
|
|
506
|
+
fs.rmSync(sourceDir, { recursive: true, force: true });
|
|
507
|
+
}));
|
|
508
|
+
|
|
509
|
+
test("validatePluginManifest rejects an all-dots plugin or surface id", () => {
|
|
510
|
+
assert.throws(() => validatePluginManifest(baseManifest("..", {}), "clauth-plugin.json"), /may not be all dots/);
|
|
511
|
+
assert.throws(() => validatePluginManifest(baseManifest("valid-id", {
|
|
512
|
+
surfaces: [{ id: ".", lifecycle_owner: "clauth" }],
|
|
513
|
+
}), "clauth-plugin.json"), /may not be all dots/);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("registerPlugin rejects an invalid manifest without writing anything", () => withTempSupervisor((root) => {
|
|
517
|
+
const managed = path.join(root, "managed");
|
|
518
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
519
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
520
|
+
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-register-bad-"));
|
|
521
|
+
const manifestPath = path.join(sourceDir, "clauth-plugin.json");
|
|
522
|
+
fs.writeFileSync(manifestPath, JSON.stringify({ schema: "wrong", id: "bad" }), "utf8");
|
|
523
|
+
|
|
524
|
+
const receipt = registerPlugin(manifestPath, "test");
|
|
525
|
+
assert.equal(receipt.resulting_state.ok, false);
|
|
526
|
+
assert.equal(receipt.resulting_state.state, "manifest_invalid");
|
|
527
|
+
assert.equal(fs.existsSync(path.join(managed, "bad")), false);
|
|
528
|
+
|
|
529
|
+
fs.rmSync(sourceDir, { recursive: true, force: true });
|
|
530
|
+
}));
|
|
531
|
+
|
|
381
532
|
test("tunnel route add and remove produce reversible operation receipts", () => withTempSupervisor((root) => {
|
|
382
533
|
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
|
|
383
534
|
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
package/cli/watchdog-registry.js
CHANGED
|
@@ -178,7 +178,30 @@ export function readWatchdogEvents(limit = 100) {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
|
|
181
|
+
async function verifyRestartHealth(service) {
|
|
182
|
+
if (!service.health?.url) return { ok: true, health_status: "not_configured" };
|
|
183
|
+
|
|
184
|
+
const attempts = Number(service.health.readyAttempts || 20);
|
|
185
|
+
const delayMs = Number(service.health.readyDelayMs || 250);
|
|
186
|
+
let observed;
|
|
187
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
188
|
+
observed = await evaluateWatchdogService(service);
|
|
189
|
+
if (observed.status === "healthy") {
|
|
190
|
+
return { ok: true, health_status: observed.status, health_http_status: observed.httpStatus, attempts: attempt };
|
|
191
|
+
}
|
|
192
|
+
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
error: "restart_health_unreachable",
|
|
197
|
+
health_status: observed?.status || "unknown",
|
|
198
|
+
health_http_status: observed?.httpStatus,
|
|
199
|
+
health_error: observed?.error,
|
|
200
|
+
attempts,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function restartWatchdogService(id) {
|
|
182
205
|
const service = loadRegistry().services.find((candidate) => candidate.id === id);
|
|
183
206
|
if (!service) return { ok: false, error: "service_not_registered" };
|
|
184
207
|
if (!service.restart) return { ok: false, error: "restart_not_configured" };
|
|
@@ -192,18 +215,23 @@ export function restartWatchdogService(id) {
|
|
|
192
215
|
encoding: "utf8",
|
|
193
216
|
timeout: Number(service.restart.timeoutMs || 30000),
|
|
194
217
|
});
|
|
218
|
+
const health = result.status === 0 ? await verifyRestartHealth(service) : { ok: false, health_status: "not_checked" };
|
|
195
219
|
const event = {
|
|
196
220
|
kind: "restart",
|
|
197
221
|
service_id: id,
|
|
198
222
|
status: result.status,
|
|
223
|
+
health_status: health.health_status,
|
|
224
|
+
health_http_status: health.health_http_status,
|
|
225
|
+
health_error: health.health_error,
|
|
199
226
|
error: result.error ? result.error.message : undefined,
|
|
200
227
|
};
|
|
201
228
|
appendEvent(event);
|
|
202
229
|
return {
|
|
203
|
-
ok: result.status === 0,
|
|
230
|
+
ok: result.status === 0 && health.ok,
|
|
204
231
|
status: result.status,
|
|
205
232
|
stdout: result.stdout,
|
|
206
233
|
stderr: result.stderr,
|
|
207
234
|
error: result.error ? result.error.message : undefined,
|
|
235
|
+
...health,
|
|
208
236
|
};
|
|
209
237
|
}
|
|
@@ -13,12 +13,12 @@ import {
|
|
|
13
13
|
validateWatchdogService,
|
|
14
14
|
} from "./watchdog-registry.js";
|
|
15
15
|
|
|
16
|
-
function withTempRegistry(fn) {
|
|
16
|
+
async function withTempRegistry(fn) {
|
|
17
17
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-watchdog-"));
|
|
18
18
|
const old = process.env.CLAUTH_WATCHDOG_DIR;
|
|
19
19
|
process.env.CLAUTH_WATCHDOG_DIR = dir;
|
|
20
20
|
try {
|
|
21
|
-
return fn(dir);
|
|
21
|
+
return await fn(dir);
|
|
22
22
|
} finally {
|
|
23
23
|
if (old === undefined) delete process.env.CLAUTH_WATCHDOG_DIR;
|
|
24
24
|
else process.env.CLAUTH_WATCHDOG_DIR = old;
|
|
@@ -78,12 +78,35 @@ test("registerWatchdogManifest upserts services by id", () => withTempRegistry((
|
|
|
78
78
|
assert.equal(registry.services.find((service) => service.id === "codeflow").label, "CodeFlow Updated");
|
|
79
79
|
}));
|
|
80
80
|
|
|
81
|
-
test("restartWatchdogService rejects missing and unapproved services", () => withTempRegistry(() => {
|
|
82
|
-
assert.deepEqual(restartWatchdogService("missing"), { ok: false, error: "service_not_registered" });
|
|
81
|
+
test("restartWatchdogService rejects missing and unapproved services", async () => withTempRegistry(async () => {
|
|
82
|
+
assert.deepEqual(await restartWatchdogService("missing"), { ok: false, error: "service_not_registered" });
|
|
83
83
|
registerWatchdogManifest({
|
|
84
84
|
services: [
|
|
85
85
|
{ id: "dev-center", label: "Dev Center", kind: "process", restart: { cmd: "node", args: ["--version"] } },
|
|
86
86
|
],
|
|
87
87
|
});
|
|
88
|
-
assert.deepEqual(restartWatchdogService("dev-center"), { ok: false, error: "approval_required" });
|
|
88
|
+
assert.deepEqual(await restartWatchdogService("dev-center"), { ok: false, error: "approval_required" });
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
test("restartWatchdogService requires registered health after launching", async () => withTempRegistry(async () => {
|
|
92
|
+
registerWatchdogManifest({
|
|
93
|
+
services: [{
|
|
94
|
+
id: "health-gated",
|
|
95
|
+
label: "Health gated",
|
|
96
|
+
kind: "http",
|
|
97
|
+
health: { url: "http://127.0.0.1:3109/health", readyAttempts: 2, readyDelayMs: 0 },
|
|
98
|
+
restart: { cmd: process.execPath, args: ["--version"] },
|
|
99
|
+
approvalRequired: false,
|
|
100
|
+
}],
|
|
101
|
+
});
|
|
102
|
+
const originalFetch = globalThis.fetch;
|
|
103
|
+
globalThis.fetch = async () => ({ ok: false, status: 503 });
|
|
104
|
+
try {
|
|
105
|
+
const result = await restartWatchdogService("health-gated");
|
|
106
|
+
assert.equal(result.ok, false);
|
|
107
|
+
assert.equal(result.error, "restart_health_unreachable");
|
|
108
|
+
assert.equal(result.health_status, "degraded");
|
|
109
|
+
} finally {
|
|
110
|
+
globalThis.fetch = originalFetch;
|
|
111
|
+
}
|
|
89
112
|
}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lifeaitools/clauth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
"test:agent-pool": "node test/agent-pool.test.mjs",
|
|
14
14
|
"test:call-agent-10": "node test/call-agent-10-skills.test.mjs",
|
|
15
15
|
"test:call-agent-guard": "node test/call-agent-guard.test.mjs",
|
|
16
|
-
"test:tintin-settings": "node test/tintin-settings.test.mjs",
|
|
17
16
|
"postinstall": "node scripts/postinstall.js",
|
|
18
17
|
"worker:start": "node cli/index.js serve",
|
|
19
18
|
"worker:stop": "curl -s http://127.0.0.1:52437/shutdown 2>nul || taskkill /F /IM cloudflared.exe 2>nul & exit 0",
|
|
@@ -28,6 +27,7 @@
|
|
|
28
27
|
"inquirer": "^10.1.0",
|
|
29
28
|
"node-fetch": "^3.3.2",
|
|
30
29
|
"ora": "^8.1.0",
|
|
30
|
+
"pm2": "^7.0.3",
|
|
31
31
|
"typescript": "^5.9.3"
|
|
32
32
|
},
|
|
33
33
|
"engines": {
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"scripts/build.mjs",
|
|
57
57
|
"scripts/build.sh",
|
|
58
58
|
"scripts/postinstall.js",
|
|
59
|
+
"cli/commands/ops-install.js",
|
|
59
60
|
"supabase/",
|
|
60
61
|
".clauth-skill/",
|
|
61
62
|
"install.sh",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|