@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.
@@ -7,7 +7,12 @@ import { spawnSync } from "node:child_process";
7
7
  const SCHEMA = "lifeai.plugin.v1";
8
8
  const DEFAULT_TIMEOUT_MS = 3000;
9
9
  const DEFAULT_SUPERVISOR_PORT = 52439;
10
- const DESTINATIONS = new Set(["local/clauth/pm2", "vultr/clauth/pm2", "coolify/clauth/docker"]);
10
+ // local/clauth/daemon: for a surface that is not a separately-startable
11
+ // process at all, but literally embedded in the clauth daemon itself (e.g.
12
+ // fs-mcp) — distinct from local/clauth/pm2 (a separate local process clauth
13
+ // manages via pm2) because there is nothing for a surface action to
14
+ // start/stop/restart; the daemon's own lifecycle IS the surface's lifecycle.
15
+ const DESTINATIONS = new Set(["local/clauth/pm2", "local/clauth/daemon", "vultr/clauth/pm2", "coolify/clauth/docker"]);
11
16
  const OWNERS = new Set(["clauth", "plugin", "external"]);
12
17
  const ACTIONS = new Set(["start", "stop", "restart", "reconcile", "test", "promote", "rollback"]);
13
18
  const DEFAULT_HEALTH_RECONCILE_INTERVAL_MS = 10000;
@@ -162,7 +167,26 @@ function normalizeCommand(command, field) {
162
167
  const [cmd, ...args] = command;
163
168
  if (typeof cmd !== "string" || !cmd.trim()) throw new Error(`${field}[0] is required`);
164
169
  if (/[;&|<>]/.test(cmd)) throw new Error(`${field}[0] must be an executable path/name, not shell syntax`);
165
- return [cmd, ...args.map(String)];
170
+ const normalizedArgs = args.map(String);
171
+ // shell:true (Windows-only, see execute() in runSurfaceAction) hands each
172
+ // arg to cmd.exe verbatim — a shell metacharacter in an arg is exactly as
173
+ // exploitable as one in cmd[0]. A legitimate CLI arg for the pm2/node
174
+ // invocations this schema targets never needs raw shell syntax.
175
+ for (const [i, arg] of normalizedArgs.entries()) {
176
+ if (/[;&|<>]/.test(arg)) throw new Error(`${field}[${i + 1}] must not contain shell syntax`);
177
+ }
178
+ return [cmd, ...normalizedArgs];
179
+ }
180
+
181
+ // Quotes a value for cmd.exe /c when shell:true is active and the value
182
+ // contains whitespace — spawnSync does NOT auto-quote in that mode, so an
183
+ // absolute path like "C:\Program Files\nodejs\node.exe" (or any arg with a
184
+ // space) breaks at the first space unless the caller quotes it. Idempotent:
185
+ // an already-quoted value is left as-is rather than double-quoted.
186
+ export function shellQuote(value, useShell) {
187
+ if (!useShell || !/\s/.test(value)) return value;
188
+ if (value.startsWith('"') && value.endsWith('"')) return value;
189
+ return `"${value}"`;
166
190
  }
167
191
 
168
192
  function expandPathToken(value) {
@@ -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
- return spawnSync(cmd, args, {
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");
@@ -178,7 +178,30 @@ export function readWatchdogEvents(limit = 100) {
178
178
  }
179
179
  }
180
180
 
181
- export function restartWatchdogService(id) {
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": "1.31.1",
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