@lifeaitools/clauth 2.15.6 → 2.15.8

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.
@@ -354,15 +354,26 @@ class ClauthSurfacesPanel extends window.ClauthPanelElement {
354
354
  const needsHelpLine = needsHelp
355
355
  ? "Stopped auto-restarting after " + (surface.consecutive_reconcile_failures || "several") + " failed attempts — check the log, fix the cause, then Start/Restart to resume auto-repair."
356
356
  : null;
357
+ // Command-set drift (2026-09-06): the surface's OWN code (read live via
358
+ // its health response) disagrees with what its manifest declares --
359
+ // exactly the "note says X, code doesn't enforce X" defect class. Only
360
+ // rendered when TRUE -- a surface that hasn't been retrofitted with the
361
+ // live commands field yet (the common case today) reports no drift, not
362
+ // a false one.
363
+ const driftLine = surface.command_drift
364
+ ? "Command-set drift: manifest declares " + (surface.declared_commands || []).join(", ") + "; surface's own code reports " + (surface.last_health_commands || []).join(", ") + "."
365
+ : null;
357
366
  return '<div class="' + rowClass + (needsHelp ? " needs-help" : "") + '" data-surface-id="' + htmlEscape(compositeId) + '"' + rowAction + '>' +
358
367
  '<div class="supervisor-row-top"><span class="supervisor-name">' + htmlEscape(surface.name || compositeId) + '</span>' +
359
368
  healthPill +
360
369
  supervisorBadge(surface.lifecycle_owner || "unknown", ownerKind) + supervisorBadge(stateLabel, stateKind) +
370
+ (surface.command_drift ? supervisorBadge("COMMAND DRIFT", "warn") : "") +
361
371
  openBtn +
362
372
  '</div>' +
363
373
  '<div class="supervisor-meta">' + htmlEscape(surface.destination || "—") + ' · port ' + htmlEscape(surface.port || "—") + '</div>' +
364
374
  '<div class="supervisor-meta">' + htmlEscape(healthLine) + '</div>' +
365
375
  (needsHelpLine ? '<div class="supervisor-meta critical">' + htmlEscape(needsHelpLine) + '</div>' : '') +
376
+ (driftLine ? '<div class="supervisor-meta critical">' + htmlEscape(driftLine) + '</div>' : '') +
366
377
  '</div>';
367
378
  }
368
379
 
@@ -15,7 +15,33 @@ import path from "path";
15
15
  // Cache path — avoids re-querying WMI/CIM on every daemon start.
16
16
  // This eliminates the spawnSync cmd.exe ETIMEDOUT crash that occurs
17
17
  // when PowerShell/WMI is slow on first call after boot.
18
- const CACHE_FILE = path.join(os.tmpdir(), "clauth-machine.cache");
18
+ //
19
+ // Durable as of 2026-09-06 (was os.tmpdir(), see LEGACY_CACHE_FILE below).
20
+ // getMachineId() derives (primary, secondary) from TWO independent execSync
21
+ // calls, each wrapped in its own try/catch that falls through silently on
22
+ // failure (see the comments at those calls). If either call transiently
23
+ // timed out, the resulting pairing was composed differently than a
24
+ // successful run, producing a DIFFERENT sha256 machine hash for the exact
25
+ // same physical machine — surfacing server-side as a false
26
+ // machine_not_found lockout, not a security event. A cache in system temp
27
+ // does not survive Windows Disk Cleanup or some machines' reboot behavior,
28
+ // which re-exposed the flaky first-computation path on any later restart
29
+ // where WMI happened to be slow. Matches the durable pattern boot.key
30
+ // already uses (getBootKeyPath() in cli/commands/serve.js) — same
31
+ // AppData/Roaming/clauth (win32) / ~/.config/clauth (else) directory.
32
+ function getCacheDir() {
33
+ if (os.platform() === "win32") {
34
+ return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "clauth");
35
+ }
36
+ return path.join(os.homedir(), ".config", "clauth");
37
+ }
38
+ const CACHE_FILE = path.join(getCacheDir(), "machine.cache");
39
+ // Pre-2026-09-06 location. An already-registered machine may still have a
40
+ // valid cache here; migrate it into the durable location on first read
41
+ // instead of recomputing via WMI — recomputing is exactly the flaky path
42
+ // this fix exists to avoid, and the durable write means this only ever
43
+ // happens once per machine.
44
+ const LEGACY_CACHE_FILE = path.join(os.tmpdir(), "clauth-machine.cache");
19
45
 
20
46
  function readCache() {
21
47
  try {
@@ -23,12 +49,24 @@ function readCache() {
23
49
  // Validate: must be two non-empty lines (primary:secondary)
24
50
  const [primary, secondary] = raw.split("\n");
25
51
  if (primary && secondary) return { primary: primary.trim(), secondary: secondary.trim() };
26
- } catch { /* cache miss */ }
52
+ } catch { /* durable cache miss */ }
53
+ try {
54
+ const raw = fs.readFileSync(LEGACY_CACHE_FILE, "utf8").trim();
55
+ const [primary, secondary] = raw.split("\n");
56
+ if (primary && secondary) {
57
+ const migrated = { primary: primary.trim(), secondary: secondary.trim() };
58
+ writeCache(migrated.primary, migrated.secondary);
59
+ return migrated;
60
+ }
61
+ } catch { /* no legacy cache either */ }
27
62
  return null;
28
63
  }
29
64
 
30
65
  function writeCache(primary, secondary) {
31
- try { fs.writeFileSync(CACHE_FILE, `${primary}\n${secondary}`, "utf8"); } catch { /* best effort */ }
66
+ try {
67
+ fs.mkdirSync(getCacheDir(), { recursive: true });
68
+ fs.writeFileSync(CACHE_FILE, `${primary}\n${secondary}`, "utf8");
69
+ } catch { /* best effort */ }
32
70
  }
33
71
 
34
72
  function getMachineId() {
@@ -140,4 +178,8 @@ export function deriveSeedHash(machineHash, password) {
140
178
  .digest("hex");
141
179
  }
142
180
 
181
+ // Exported for test path introspection only — not part of the public
182
+ // CLI/API surface.
183
+ export { CACHE_FILE, LEGACY_CACHE_FILE, getCacheDir };
184
+
143
185
  export default { getMachineHash, deriveToken, deriveSeedHash };
@@ -4,7 +4,12 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
 
7
- import { getMachineHash, deriveToken, deriveSeedHash } from "./fingerprint.js";
7
+ import { getMachineHash, deriveToken, deriveSeedHash, CACHE_FILE, LEGACY_CACHE_FILE } from "./fingerprint.js";
8
+
9
+ // CACHE_FILE/LEGACY_CACHE_FILE are the REAL production paths (durable
10
+ // AppData/Roaming/clauth on win32, formerly os.tmpdir()) — read-only in this
11
+ // suite. Every test below only ever reads mtimeMs, never asserts on or
12
+ // writes machine-identifying content into either file.
8
13
 
9
14
  // getMachineId() normally shells out to WMI/registry/ioreg, which this harness
10
15
  // must never do (slow, platform-dependent, pollutes nothing but still real
@@ -84,12 +89,31 @@ test("deriveSeedHash changes with the password", () => {
84
89
  assert.notEqual(deriveSeedHash(hash, "one"), deriveSeedHash(hash, "two"));
85
90
  });
86
91
 
87
- test("machine id cache: a fresh CLAUTH_MACHINE_ID call never touches the WMI cache file", () => {
92
+ test("machine id cache: a fresh CLAUTH_MACHINE_ID call never touches the WMI cache file (durable or legacy)", () => {
88
93
  // Positive control that the cache mechanism exists and this test can see it,
89
94
  // so "the cache file was untouched" below actually means something.
90
- const cacheFile = path.join(os.tmpdir(), "clauth-machine.cache");
91
- const before = fs.existsSync(cacheFile) ? fs.statSync(cacheFile).mtimeMs : null;
95
+ const before = {
96
+ durable: fs.existsSync(CACHE_FILE) ? fs.statSync(CACHE_FILE).mtimeMs : null,
97
+ legacy: fs.existsSync(LEGACY_CACHE_FILE) ? fs.statSync(LEGACY_CACHE_FILE).mtimeMs : null,
98
+ };
92
99
  withMachineId("container-fast-path", () => getMachineHash());
93
- const after = fs.existsSync(cacheFile) ? fs.statSync(cacheFile).mtimeMs : null;
94
- assert.equal(before, after, "the container-id fast path must not read or write the WMI cache file");
100
+ const after = {
101
+ durable: fs.existsSync(CACHE_FILE) ? fs.statSync(CACHE_FILE).mtimeMs : null,
102
+ legacy: fs.existsSync(LEGACY_CACHE_FILE) ? fs.statSync(LEGACY_CACHE_FILE).mtimeMs : null,
103
+ };
104
+ assert.deepEqual(before, after, "the container-id fast path must not read or write either cache file");
105
+ });
106
+
107
+ test("cache paths are durable (AppData/Roaming/clauth or ~/.config/clauth), never os.tmpdir()", () => {
108
+ // The whole point of this fix: CACHE_FILE must NOT live in system temp,
109
+ // which Windows Disk Cleanup (or equivalent) can wipe, re-exposing the
110
+ // flaky first-computation WMI path on the next restart.
111
+ assert.ok(!CACHE_FILE.startsWith(os.tmpdir()), `CACHE_FILE must be durable, got: ${CACHE_FILE}`);
112
+ assert.equal(path.basename(CACHE_FILE), "machine.cache");
113
+ const dir = path.dirname(CACHE_FILE);
114
+ assert.ok(dir.endsWith(path.join("clauth")), `cache dir must be a clauth-owned config directory, got: ${dir}`);
115
+ // LEGACY_CACHE_FILE is the pre-fix location, kept only so readCache() can
116
+ // migrate an already-registered machine's existing cache instead of
117
+ // recomputing via WMI (see fingerprint.js).
118
+ assert.equal(LEGACY_CACHE_FILE, path.join(os.tmpdir(), "clauth-machine.cache"));
95
119
  });
@@ -163,20 +163,56 @@ function appendSupervisorEvent(event) {
163
163
  appendJsonl(file("events.jsonl"), { ts: now(), ...event });
164
164
  }
165
165
 
166
+ // The command set a surface's OWN manifest actually declares — derived from
167
+ // which of the real action fields carry a non-empty command array. "reconcile"
168
+ // and "test" are structural (reconcile is start-or-restart chosen by clauth;
169
+ // test is the candidate-testing path), not something a manifest declares, so
170
+ // they are excluded here on purpose.
171
+ const DECLARABLE_ACTIONS = ["start", "stop", "restart", "promote", "rollback"];
172
+ function declaredCommands(surface) {
173
+ return DECLARABLE_ACTIONS.filter((action) => Array.isArray(surface?.[action]) && surface[action].length > 0);
174
+ }
175
+
176
+ function sameCommandSet(a, b) {
177
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
178
+ if (a.length !== b.length) return false;
179
+ const setA = new Set(a);
180
+ return b.every((item) => setA.has(item));
181
+ }
182
+
166
183
  async function probeSurfaceHealth(url, fetchImpl, timeoutMs) {
167
184
  try {
168
185
  const controller = new AbortController();
169
186
  const timer = setTimeout(() => controller.abort(), timeoutMs);
170
187
  try {
171
188
  const response = await fetchImpl(url, { signal: controller.signal });
172
- return response?.ok
173
- ? { healthy: true, error: null }
174
- : { healthy: false, error: `HTTP ${response?.status ?? "unknown"}` };
189
+ if (!response?.ok) return { healthy: false, error: `HTTP ${response?.status ?? "unknown"}`, liveCommands: null };
190
+ // Command-set contract (2026-09-06): a surface MAY include a
191
+ // `commands` array in its health response the set of actions ITS
192
+ // OWN code actually supports, read live rather than trusted from the
193
+ // manifest's static declaration ("from code not text so we know there
194
+ // is no drift" — Dave, 2026-09-06). Piggybacked on the health probe
195
+ // clauth already runs every reconcile tick, so retrofitting a surface
196
+ // costs it nothing extra: no new endpoint, no new round trip. A
197
+ // surface that doesn't (yet) include this field is unaffected —
198
+ // liveCommands stays null and callers fall back to the manifest's
199
+ // declared set, exactly as before this contract existed.
200
+ let liveCommands = null;
201
+ try {
202
+ // No .clone() needed — this is the only read of this response, real
203
+ // or mocked. Test fixtures across this suite mock fetchImpl with
204
+ // plain { ok, status } objects with no .json() at all; the try/catch
205
+ // already treats that identically to "not JSON" — both normal, not
206
+ // an error — so existing tests are unaffected without special-casing.
207
+ const body = typeof response.json === "function" ? await response.json() : null;
208
+ if (Array.isArray(body?.commands)) liveCommands = body.commands.filter((c) => typeof c === "string");
209
+ } catch { /* not JSON, or no commands field — both normal, not an error */ }
210
+ return { healthy: true, error: null, liveCommands };
175
211
  } finally {
176
212
  clearTimeout(timer);
177
213
  }
178
214
  } catch (err) {
179
- return { healthy: false, error: err?.name === "AbortError" ? "health timeout" : String(err?.message || err) };
215
+ return { healthy: false, error: err?.name === "AbortError" ? "health timeout" : String(err?.message || err), liveCommands: null };
180
216
  }
181
217
  }
182
218
 
@@ -198,8 +234,21 @@ export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, tim
198
234
  const error = health.error;
199
235
 
200
236
  if (healthy) {
201
- updateSurfaceState(id, { state: "current", last_health_at: observedAt, last_health_ok: true, last_health_error: null, consecutive_reconcile_failures: 0 });
202
- inspected.push({ surface_id: id, state: "healthy", observed_at: observedAt });
237
+ const declared = declaredCommands(surface);
238
+ const commandDrift = Array.isArray(health.liveCommands) && !sameCommandSet(health.liveCommands, declared);
239
+ updateSurfaceState(id, {
240
+ state: "current",
241
+ last_health_at: observedAt,
242
+ last_health_ok: true,
243
+ last_health_error: null,
244
+ consecutive_reconcile_failures: 0,
245
+ last_health_commands: health.liveCommands,
246
+ command_drift: commandDrift,
247
+ });
248
+ if (commandDrift) {
249
+ appendSupervisorEvent({ kind: "surface_command_drift", surface_id: id, declared, live: health.liveCommands });
250
+ }
251
+ inspected.push({ surface_id: id, state: "healthy", observed_at: observedAt, command_drift: commandDrift });
203
252
  continue;
204
253
  }
205
254
 
@@ -365,6 +414,15 @@ export async function probeAllSurfaceHealth({ fetchImpl = globalThis.fetch, time
365
414
  url ? probeSurfaceHealth(url, fetchImpl, timeoutMs) : null,
366
415
  openUrl ? probeSurfaceHealth(openUrl, fetchImpl, timeoutMs) : null,
367
416
  ]);
417
+ // Command-set drift (2026-09-06): computed here too, not just in
418
+ // reconcileSurfaceHealth, because THIS sweep is the only one that ever
419
+ // probes external/remote surfaces — reconcile only looks at enabled
420
+ // clauth-owned local pm2 surfaces. null when the surface hasn't declared
421
+ // liveCommands (not retrofitted yet, or down) — that is a normal,
422
+ // unretrofitted surface, not a drift finding.
423
+ const declared = declaredCommands(surface);
424
+ const liveCommands = health?.healthy ? health.liveCommands : null;
425
+ const commandDrift = Array.isArray(liveCommands) ? !sameCommandSet(liveCommands, declared) : null;
368
426
  return {
369
427
  surface_id,
370
428
  health: !url ? "no_probe" : (health.healthy ? "healthy" : "down"),
@@ -372,6 +430,8 @@ export async function probeAllSurfaceHealth({ fetchImpl = globalThis.fetch, time
372
430
  error: url && !health.healthy ? (health.error || null) : null,
373
431
  open_url: openUrl || null,
374
432
  open_ok: open ? open.healthy : null,
433
+ live_commands: liveCommands,
434
+ command_drift: commandDrift,
375
435
  observed_at: now(),
376
436
  };
377
437
  }));
@@ -396,6 +456,8 @@ export async function probeAllSurfaceHealth({ fetchImpl = globalThis.fetch, time
396
456
  last_health_at: entry.observed_at,
397
457
  last_health_ok: entry.health === "healthy",
398
458
  last_health_error: entry.health === "healthy" ? null : entry.error,
459
+ last_health_commands: entry.live_commands,
460
+ command_drift: entry.command_drift,
399
461
  };
400
462
  });
401
463
  saveSupervisorState(state);
@@ -834,6 +896,7 @@ const PRODUCT_REPO_MANIFESTS = [
834
896
  { repo: "regen-root", manifest: "mcp-servers/regen-media/clauth-plugin.json" },
835
897
  { repo: "regen-root", manifest: "mcp-servers/web-research/clauth-plugin.json" },
836
898
  { repo: "rdc-skills", manifest: "clauth-plugin.json" },
899
+ { repo: "rdc-harness", manifest: "fsm-daemon/clauth-plugin.json" },
837
900
  ];
838
901
 
839
902
  // Sweep outcomes that mean "nothing was there to sync", as distinct from
@@ -853,6 +916,12 @@ export const SYNC_REPO_NAMES = Object.freeze([...new Set(PRODUCT_REPO_MANIFESTS.
853
916
  function defaultRepoRoot(repo) {
854
917
  if (repo === "regen-root") return process.env.REGEN_ROOT || process.env.LIFEAI_REPO_ROOT || "C:/Dev/regen-root";
855
918
  if (repo === "rdc-skills") return process.env.RDC_SKILLS_ROOT || "C:/Dev/rdc-skills";
919
+ // fsm-daemon's clauth-plugin.json lives inside rdc-harness, not published
920
+ // separately -- it was previously reachable only via a one-off manual
921
+ // `clauth plugin register` pinned to whichever machine ran it, invisible to
922
+ // this sweep and to any box that registers fsm-daemon fresh. Same env-var
923
+ // + hardcoded-default shape as the two entries above, not a new pattern.
924
+ if (repo === "rdc-harness") return process.env.RDC_HARNESS_ROOT || "C:/Dev/rdc-harness";
856
925
  return null;
857
926
  }
858
927
 
@@ -1193,6 +1262,17 @@ export function setPluginEnabled(id, enabled, actor = "localhost") {
1193
1262
  const prior = (state.plugins || []).find((plugin) => plugin.id === id);
1194
1263
  if (!prior) return { error: "plugin_not_found" };
1195
1264
  if (prior.state === "manifest_invalid") return { error: "manifest_invalid" };
1265
+ // A core:true plugin auto-enables at discovery (discoverPlugins()) and,
1266
+ // per the same rule, is never allowed BACK into awaiting_enable — "core
1267
+ // surfaces just run" is a guarantee, not a default a dashboard toggle can
1268
+ // quietly undo. Approved: direct operator instruction, 2026-09-06 —
1269
+ // "core: true surfaces only" get the no-gate treatment, applied uniformly
1270
+ // to every destination (local AND remote, same rule). Disabling a core
1271
+ // plugin is rejected outright rather than silently ignored, so a caller
1272
+ // finds out immediately rather than wondering why the toggle didn't stick.
1273
+ if (!enabled && prior.core === true) {
1274
+ return { error: "core_plugin_cannot_be_disabled", plugin_id: id };
1275
+ }
1196
1276
  const nextPlugin = { ...prior, enabled: Boolean(enabled), state: enabled ? "current" : "awaiting_enable" };
1197
1277
  state.plugins = state.plugins.map((plugin) => plugin.id === id ? nextPlugin : plugin);
1198
1278
  state.surfaces = (state.surfaces || []).map((surface) => surface.plugin_id === id ? { ...surface, enabled: Boolean(enabled), state: nextPlugin.state } : surface);
@@ -314,6 +314,29 @@ test("trusted managed core plugins auto-enable while user plugins remain opt-in"
314
314
  assert.equal(userPlugin.state, "awaiting_enable");
315
315
  }));
316
316
 
317
+ test("setPluginEnabled refuses to disable a core plugin — 'core surfaces just run' is a guarantee, not a default", () => withTempSupervisor((root) => {
318
+ const managed = path.join(root, "managed");
319
+ const user = path.join(root, "user");
320
+ process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
321
+ process.env.CLAUTH_USER_PLUGIN_ROOTS = user;
322
+ writePlugin(root, "managed", "core-mcp", baseManifest("core-mcp", { core: true, enable_default: true }));
323
+ writePlugin(root, "managed", "noncore-mcp", baseManifest("noncore-mcp", { core: false }));
324
+ discoverPlugins();
325
+
326
+ const rejected = setPluginEnabled("core-mcp", false);
327
+ assert.equal(rejected.error, "core_plugin_cannot_be_disabled");
328
+ // Positive control: the plugin's actual state is UNCHANGED by the
329
+ // rejected call, not silently flipped anyway.
330
+ assert.equal(listPlugins().find((p) => p.id === "core-mcp").enabled, true);
331
+ assert.equal(listPlugins().find((p) => p.id === "core-mcp").state, "current");
332
+
333
+ // Regression guard: a NON-core plugin can still be disabled exactly as
334
+ // before -- this fix narrows the gate, it does not remove it.
335
+ const disabled = setPluginEnabled("noncore-mcp", false);
336
+ assert.equal(disabled.resulting_state.enabled, false);
337
+ assert.equal(listPlugins().find((p) => p.id === "noncore-mcp").state, "awaiting_enable");
338
+ }));
339
+
317
340
  test("plugin test marks a private candidate and never creates a public route", () => withTempSupervisor((root) => {
318
341
  const managed = path.join(root, "managed");
319
342
  process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
@@ -452,6 +475,65 @@ test("health reconciliation marks a failed clauth surface and repairs it through
452
475
  }
453
476
  });
454
477
 
478
+ test("a healthy surface's live-reported commands are recorded, and drift against the manifest is detected", async () => {
479
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-commands-"));
480
+ const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
481
+ const oldManaged = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
482
+ const oldUser = process.env.CLAUTH_USER_PLUGIN_ROOTS;
483
+ process.env.CLAUTH_SUPERVISOR_DIR = root;
484
+ process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
485
+ process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
486
+ try {
487
+ // Manifest declares start+stop only (no restart command).
488
+ writePlugin(root, "managed", "commands-demo", baseManifest("commands-demo", {
489
+ core: true,
490
+ enable_default: true,
491
+ surfaces: [{
492
+ id: "primary",
493
+ destination: "local/clauth/pm2",
494
+ lifecycle_owner: "clauth",
495
+ port: 39125,
496
+ health: "/health",
497
+ start: [process.execPath, "--version"],
498
+ stop: [process.execPath, "--version"],
499
+ }],
500
+ }));
501
+ discoverPlugins();
502
+
503
+ // Surface's OWN code reports a DIFFERENT set live (it also implements
504
+ // restart, which the manifest never declared) -- this is the drift case.
505
+ const drifted = await reconcileSurfaceHealth({
506
+ fetchImpl: async () => ({ ok: true, json: async () => ({ status: "ok", commands: ["start", "stop", "restart"] }) }),
507
+ });
508
+ assert.equal(drifted.inspected[0].command_drift, true);
509
+ const afterDrift = listSurfaces().find((s) => s.plugin_id === "commands-demo");
510
+ assert.deepEqual(afterDrift.last_health_commands, ["start", "stop", "restart"]);
511
+ assert.equal(afterDrift.command_drift, true);
512
+
513
+ // Surface's code now matches the manifest exactly -- no drift.
514
+ const matched = await reconcileSurfaceHealth({
515
+ fetchImpl: async () => ({ ok: true, json: async () => ({ status: "ok", commands: ["stop", "start"] }) }),
516
+ });
517
+ assert.equal(matched.inspected[0].command_drift, false);
518
+ assert.equal(listSurfaces().find((s) => s.plugin_id === "commands-demo").command_drift, false);
519
+
520
+ // A surface that hasn't been retrofitted with the commands field at all
521
+ // must NOT be flagged as drifted -- no live evidence is never treated as
522
+ // disagreeing evidence.
523
+ const unretrofitted = await reconcileSurfaceHealth({ fetchImpl: async () => ({ ok: true }) });
524
+ assert.equal(unretrofitted.inspected[0].command_drift, false, "no liveCommands reported must never read as drift");
525
+ assert.equal(listSurfaces().find((s) => s.plugin_id === "commands-demo").last_health_commands, null);
526
+ } finally {
527
+ if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
528
+ else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
529
+ if (oldManaged === undefined) delete process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
530
+ else process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = oldManaged;
531
+ if (oldUser === undefined) delete process.env.CLAUTH_USER_PLUGIN_ROOTS;
532
+ else process.env.CLAUTH_USER_PLUGIN_ROOTS = oldUser;
533
+ fs.rmSync(root, { recursive: true, force: true });
534
+ }
535
+ });
536
+
455
537
  test("health reconciliation does not claim current when a successful command leaves health down", async () => {
456
538
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-post-health-"));
457
539
  const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
@@ -894,6 +976,7 @@ function withTempProductRepos(fn) {
894
976
  const base = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-sync-repos-"));
895
977
  const regenRoot = path.join(base, "regen-root");
896
978
  const rdcSkills = path.join(base, "rdc-skills");
979
+ const rdcHarness = path.join(base, "rdc-harness");
897
980
  const writeManifest = (repoRoot, relPath, manifest) => {
898
981
  const full = path.join(repoRoot, relPath);
899
982
  fs.mkdirSync(path.dirname(full), { recursive: true });
@@ -905,8 +988,9 @@ function withTempProductRepos(fn) {
905
988
  writeManifest(regenRoot, "mcp-servers/regen-media/clauth-plugin.json", baseManifest("regen-media"));
906
989
  writeManifest(regenRoot, "mcp-servers/web-research/clauth-plugin.json", baseManifest("web-research"));
907
990
  writeManifest(rdcSkills, "clauth-plugin.json", baseManifest("rdc-skills"));
991
+ writeManifest(rdcHarness, "fsm-daemon/clauth-plugin.json", baseManifest("fsm-daemon"));
908
992
  try {
909
- return fn({ base, regenRoot, rdcSkills, writeManifest, roots: { "regen-root": regenRoot, "rdc-skills": rdcSkills } });
993
+ return fn({ base, regenRoot, rdcSkills, rdcHarness, writeManifest, roots: { "regen-root": regenRoot, "rdc-skills": rdcSkills, "rdc-harness": rdcHarness } });
910
994
  } finally {
911
995
  fs.rmSync(base, { recursive: true, force: true });
912
996
  }
@@ -914,7 +998,7 @@ function withTempProductRepos(fn) {
914
998
 
915
999
  test("plugin sync inherits registerPlugin idempotence — a second sweep reports every manifest unchanged", () => withTempSupervisor(() => withTempProductRepos(({ roots }) => {
916
1000
  const first = syncPluginsFromRepos(roots, "test");
917
- assert.equal(first.length, 5, "one receipt per attempted manifest");
1001
+ assert.equal(first.length, 6, "one receipt per attempted manifest");
918
1002
  assert.deepEqual(
919
1003
  first.filter((entry) => !entry.ok).map((entry) => `${entry.repo}:${entry.state}`),
920
1004
  [],
@@ -925,7 +1009,7 @@ test("plugin sync inherits registerPlugin idempotence — a second sweep reports
925
1009
  // registerPlugin sha256-compares before writing; sync must not defeat that by
926
1010
  // re-writing or re-hashing on its own.
927
1011
  const second = syncPluginsFromRepos(roots, "test");
928
- assert.equal(second.length, 5);
1012
+ assert.equal(second.length, 6);
929
1013
  assert.equal(second.every((entry) => entry.ok && entry.state === "unchanged"), true, "re-sweeping identical content must be a no-op");
930
1014
  })));
931
1015
 
@@ -937,7 +1021,7 @@ test("plugin sync warns and continues over a missing repo root instead of throwi
937
1021
  assert.doesNotThrow(() => {
938
1022
  receipts = syncPluginsFromRepos({ "regen-root": absent, "rdc-skills": rdcSkills }, "test");
939
1023
  });
940
- assert.equal(receipts.length, 5, "a skipped repo still yields a receipt per attempted manifest");
1024
+ assert.equal(receipts.length, 6, "a skipped repo still yields a receipt per attempted manifest");
941
1025
  const missing = receipts.filter((entry) => entry.state === "repo_root_missing");
942
1026
  assert.equal(missing.length, 4, "all four regen-root manifests report the missing root");
943
1027
  assert.equal(missing.every((entry) => entry.ok === false), true);
@@ -951,15 +1035,15 @@ test("plugin sync registers the remaining manifests when one is malformed", () =
951
1035
  writeManifest(regenRoot, "apps/dev-center/clauth-plugin.json", "{ this is not valid json");
952
1036
 
953
1037
  const receipts = syncPluginsFromRepos(roots, "test");
954
- assert.equal(receipts.length, 5);
1038
+ assert.equal(receipts.length, 6);
955
1039
  const bad = receipts.find((entry) => entry.path.includes("dev-center"));
956
1040
  assert.equal(bad.ok, false);
957
1041
  assert.equal(bad.state, "manifest_invalid");
958
1042
  const good = receipts.filter((entry) => entry !== bad);
959
- assert.equal(good.length, 4);
1043
+ assert.equal(good.length, 5);
960
1044
  assert.equal(good.every((entry) => entry.ok && entry.state === "registered"), true, "one bad manifest must not abort the sweep");
961
1045
  const ids = new Set(listPlugins().map((plugin) => plugin.id));
962
- for (const id of ["codeflow-mcp", "regen-media", "web-research", "rdc-skills"]) {
1046
+ for (const id of ["codeflow-mcp", "regen-media", "web-research", "rdc-skills", "fsm-daemon"]) {
963
1047
  assert.ok(ids.has(id), `${id} must still be registered`);
964
1048
  }
965
1049
  })));
@@ -1201,7 +1285,7 @@ test("plugin sync reports an unknown repo-root override instead of silently swee
1201
1285
  assert.equal(rejected[0].ok, false);
1202
1286
  assert.match(rejected[0].error, /unknown repo name/);
1203
1287
  assert.equal(SYNC_SKIP_STATES.includes("unknown_repo_name"), false, "a typo'd repo name must fail the sweep, not be skipped");
1204
- assert.deepEqual([...SYNC_REPO_NAMES].sort(), ["rdc-skills", "regen-root"]);
1288
+ assert.deepEqual([...SYNC_REPO_NAMES].sort(), ["rdc-harness", "rdc-skills", "regen-root"]);
1205
1289
  })));
1206
1290
 
1207
1291
  test("plugin sync never throws on a malformed repo root — the contract absence must not break", () => withTempSupervisor(() => withTempProductRepos(({ rdcSkills }) => {
@@ -1212,7 +1296,7 @@ test("plugin sync never throws on a malformed repo root — the contract absence
1212
1296
  assert.doesNotThrow(() => {
1213
1297
  receipts = syncPluginsFromRepos({ "regen-root": badRoot, "rdc-skills": rdcSkills }, "test");
1214
1298
  }, `root ${JSON.stringify(badRoot)} must not throw`);
1215
- assert.equal(receipts.length, 5, "every attempted manifest still yields a receipt");
1299
+ assert.equal(receipts.length, 6, "every attempted manifest still yields a receipt");
1216
1300
  assert.equal(
1217
1301
  receipts.filter((entry) => entry.repo === "regen-root" && entry.state === "repo_root_unknown").length,
1218
1302
  4,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "2.15.6",
3
+ "version": "2.15.8",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,12 @@
1
- // clauth — auth-vault Edge Function v2
2
- // Added: IP whitelist, rate limiting, machine lockout (fail_count + locked)
1
+ // clauth — auth-vault Edge Function v3
2
+ // IP whitelist + machine lockout (fail_count + locked) remain. Rate limiting
3
+ // and audit logging (clauth_audit) removed 2026-09-03 -- see the comments at
4
+ // validateHMAC and where auditLog used to be defined for why: the rate-limit
5
+ // check was an unindexed COUNT against clauth_audit run on EVERY request,
6
+ // pre-auth, and every rejection it produced also wrote an audit row -- a
7
+ // feedback loop that took the whole project down under sustained load. The
8
+ // security value it provided was already covered, tighter, by the
9
+ // per-machine 5-failed-attempts lockout below.
3
10
 
4
11
  import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
5
12
 
@@ -11,8 +18,6 @@ const ADMIN_BOOTSTRAP_TOKEN = Deno.env.get("CLAUTH_ADMIN_BOOTSTRAP_TOKEN")!;
11
18
  const ALLOWED_IPS: string[] = (Deno.env.get("CLAUTH_ALLOWED_IPS") || "")
12
19
  .split(",").map(s => s.trim()).filter(Boolean);
13
20
 
14
- const RATE_LIMIT_MAX = 30;
15
- const RATE_LIMIT_WINDOW = 60;
16
21
  const REPLAY_WINDOW_MS = 5 * 60 * 1000;
17
22
  const MAX_FAIL_COUNT = 5;
18
23
  const DEFAULT_INSTALL_ID = "default";
@@ -55,20 +60,36 @@ function checkIP(ip: string): { allowed: boolean; reason?: string } {
55
60
  return { allowed: false, reason: `IP not whitelisted: ${ip}` };
56
61
  }
57
62
 
58
- async function checkRateLimit(sb: any, machine_hash: string): Promise<{ allowed: boolean; reason?: string }> {
59
- const windowStart = new Date(Date.now() - RATE_LIMIT_WINDOW * 1000).toISOString();
60
- const { count } = await sb.from("clauth_audit")
61
- .select("id", { count: "exact", head: true })
62
- .eq("machine_hash", machine_hash)
63
- .gte("created_at", windowStart);
64
- if ((count || 0) >= RATE_LIMIT_MAX) {
65
- return { allowed: false, reason: `Rate limit: ${count}/${RATE_LIMIT_MAX} per ${RATE_LIMIT_WINDOW}s` };
66
- }
67
- return { allowed: true };
68
- }
69
-
63
+ // rate-limiting removed 2026-09-03 -- see supervisor-registry.js's own
64
+ // state.json lock history for the shape of this bug: checkRateLimit() ran
65
+ // this exact query -- a COUNT against clauth_audit filtered by machine_hash
66
+ // and created_at -- on EVERY request, BEFORE authentication (validateHMAC
67
+ // below), against a table with no index on either column and no retention.
68
+ // As the table grew (174,385 rows, unbounded), the scan cost grew with it;
69
+ // under sustained traffic the queries started stacking, Postgres killed them
70
+ // at statement_timeout, and Cloudflare killed the stacked connections at its
71
+ // own 90s ceiling (522s) -- and every one of those rejections ALSO wrote an
72
+ // audit row (see auditLog's removal below), so the failure fed itself. The
73
+ // actual security protection this duplicated is already provided, tighter,
74
+ // by the per-machine lockout in validateHMAC below (5 failed attempts locks
75
+ // the machine -- well under the 30-per-60s this used to allow). Rate
76
+ // limiting for a genuine runaway client belongs at Cloudflare, in front of
77
+ // this function, not as a synchronous Postgres query on the hot path of
78
+ // every request.
70
79
  async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reason?: string }> {
71
80
  const now = Date.now();
81
+
82
+ // A caller that omits machine_hash entirely used to fall straight through
83
+ // to the clauth_machines lookup below with body.machine_hash === undefined.
84
+ // PostgREST's .single() then answers "0 rows" for a query that can never
85
+ // match, which comes back as an HTTP 406 -- 1,438 of these in 24h
86
+ // (2026-09-06), every one a wasted round trip for a request already known
87
+ // to be malformed. Reject before the DB call: same auth_failed response
88
+ // the caller already gets, no query, no spurious 406 in the logs.
89
+ if (typeof body.machine_hash !== "string" || !body.machine_hash) {
90
+ return { valid: false, reason: "machine_hash_missing" };
91
+ }
92
+
72
93
  if (Math.abs(now - body.timestamp) > REPLAY_WINDOW_MS) return { valid: false, reason: "timestamp_expired" };
73
94
 
74
95
  const { data: machine, error } = await sb.from("clauth_machines")
@@ -99,20 +120,23 @@ async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reaso
99
120
  return { valid: true };
100
121
  }
101
122
 
102
- async function auditLog(sb: any, machine_hash: string, service_name: string, action: string, result: string, detail?: string) {
103
- await sb.from("clauth_audit").insert({ machine_hash, service_name, action, result, detail });
104
- }
105
-
123
+ // auditLog() removed 2026-09-03 alongside checkRateLimit() -- it wrote a row
124
+ // to clauth_audit on every path through this function, including rejections
125
+ // (rate-limited, auth-denied, IP-blocked), which is what turned "the table
126
+ // got slow" into a feedback loop (more rejections -> more rows -> slower
127
+ // scans -> more rejections). No retention policy ever existed for this
128
+ // table. If durable audit logging is wanted back, it needs its own design --
129
+ // write-only, indexed for its actual read pattern (if any), with a real
130
+ // retention/partition policy -- not a bare insert-on-every-call with no cap.
106
131
  async function handleRetrieve(sb: any, body: any, mh: string) {
107
132
  const { service } = body;
108
133
  if (!service) return { error: "service required" };
109
134
  const { data: svc } = await sb.from("clauth_services").select("*").eq("name", service).single();
110
- if (!svc) { await auditLog(sb, mh, service, "retrieve", "fail", "service_not_found"); return { error: "service_not_found" }; }
111
- if (!svc.enabled) { await auditLog(sb, mh, service, "retrieve", "denied", "service_disabled"); return { error: "service_disabled" }; }
112
- if (!svc.vault_key) { await auditLog(sb, mh, service, "retrieve", "fail", "no_key_stored"); return { error: "no_key_stored" }; }
135
+ if (!svc) return { error: "service_not_found" };
136
+ if (!svc.enabled) return { error: "service_disabled" };
137
+ if (!svc.vault_key) return { error: "no_key_stored" };
113
138
  const { data: secret } = await sb.rpc("vault_decrypt_secret", { secret_name: svc.vault_key });
114
139
  await sb.from("clauth_services").update({ last_retrieved: new Date().toISOString() }).eq("name", service);
115
- await auditLog(sb, mh, service, "retrieve", "success");
116
140
  return { service, key_type: svc.key_type, value: secret };
117
141
  }
118
142
 
@@ -121,9 +145,8 @@ async function handleWrite(sb: any, body: any, mh: string) {
121
145
  if (!service || !value) return { error: "service and value required" };
122
146
  const vaultKey = `clauth.${service}`;
123
147
  const { error } = await sb.rpc("vault_upsert_secret", { secret_name: vaultKey, secret_value: typeof value === "string" ? value : JSON.stringify(value) });
124
- if (error) { await auditLog(sb, mh, service, "write", "fail", error.message); return { error: error.message }; }
148
+ if (error) return { error: error.message };
125
149
  await sb.from("clauth_services").update({ vault_key: vaultKey, last_rotated: new Date().toISOString() }).eq("name", service);
126
- await auditLog(sb, mh, service, "write", "success");
127
150
  return { success: true, service, vault_key: vaultKey };
128
151
  }
129
152
 
@@ -133,7 +156,6 @@ async function handleEnable(sb: any, body: any, mh: string) {
133
156
  q = service !== "all" ? q.eq("name", service) : q.not("vault_key", "is", null);
134
157
  const { error } = await q;
135
158
  if (error) return { error: error.message };
136
- await auditLog(sb, mh, service, enabled ? "enable" : "disable", "success");
137
159
  return { success: true, service, enabled };
138
160
  }
139
161
 
@@ -144,7 +166,6 @@ async function handleAdd(sb: any, body: any, mh: string) {
144
166
  if (project) row.project = project;
145
167
  const { error } = await sb.from("clauth_services").insert(row);
146
168
  if (error) return { error: error.message };
147
- await auditLog(sb, mh, name, "add", "success");
148
169
  return { success: true, name, label, key_type, project: project || null };
149
170
  }
150
171
 
@@ -157,7 +178,6 @@ async function handleUpdate(sb: any, body: any, mh: string) {
157
178
  if (description !== undefined) updates.description = description || null;
158
179
  const { error } = await sb.from("clauth_services").update(updates).eq("name", service);
159
180
  if (error) return { error: error.message };
160
- await auditLog(sb, mh, service, "update", "success", `fields: ${Object.keys(updates).join(", ")}`);
161
181
  return { success: true, service, ...updates };
162
182
  }
163
183
 
@@ -166,7 +186,6 @@ async function handleRemove(sb: any, body: any, mh: string) {
166
186
  if (confirm !== `CONFIRM REMOVE ${service.toUpperCase()}`) return { error: "confirm phrase mismatch" };
167
187
  await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
168
188
  await sb.from("clauth_services").delete().eq("name", service);
169
- await auditLog(sb, mh, service, "remove", "success");
170
189
  return { success: true, service };
171
190
  }
172
191
 
@@ -182,7 +201,6 @@ async function handleRevoke(sb: any, body: any, mh: string) {
182
201
  await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
183
202
  await sb.from("clauth_services").update({ vault_key: null, enabled: false }).eq("name", service);
184
203
  }
185
- await auditLog(sb, mh, service, "revoke", "success");
186
204
  return { success: true, service };
187
205
  }
188
206
 
@@ -193,7 +211,6 @@ async function handleStatus(sb: any, body: any, mh: string) {
193
211
  .order("name");
194
212
  if (body.project) q = q.eq("project", body.project);
195
213
  const { data: services } = await q;
196
- await auditLog(sb, mh, "all", "status", "success");
197
214
  return { services: services || [] };
198
215
  }
199
216
 
@@ -204,7 +221,6 @@ async function handleChangePassword(sb: any, body: any, mh: string) {
204
221
  .update({ hmac_seed_hash: new_hmac_seed_hash, fail_count: 0, locked: false })
205
222
  .eq("machine_hash", mh);
206
223
  if (error) return { error: error.message };
207
- await auditLog(sb, mh, "system", "change-password", "success");
208
224
  return { success: true };
209
225
  }
210
226
 
@@ -231,12 +247,8 @@ async function handleCreateEnrollment(sb: any, body: any, mh: string) {
231
247
  created_by_machine_hash: mh,
232
248
  expires_at,
233
249
  });
234
- if (error) {
235
- await auditLog(sb, mh, "system", "create-enrollment", "fail", error.message);
236
- return { error: error.message };
237
- }
250
+ if (error) return { error: error.message };
238
251
 
239
- await auditLog(sb, mh, "system", "create-enrollment", "success", `install_id=${install_id}`);
240
252
  return { success: true, enrollment_code: code, install_id, expires_at, label };
241
253
  }
242
254
 
@@ -274,7 +286,6 @@ async function handleRedeemEnrollment(sb: any, body: any) {
274
286
  if (consumeError) return { error: consumeError.message };
275
287
  if (!consumedRows || consumedRows.length !== 1) return { error: "enrollment_already_used" };
276
288
 
277
- await auditLog(sb, machine_hash, "system", "redeem-enrollment", "success", `install_id=${install_id}`);
278
289
  return { success: true, machine_hash, install_id };
279
290
  }
280
291
 
@@ -314,21 +325,11 @@ Deno.serve(async (req: Request) => {
314
325
 
315
326
  const ipCheck = checkIP(ip);
316
327
  if (!ipCheck.allowed) {
317
- await auditLog(sb, body.machine_hash || "unknown", "system", route, "blocked", ipCheck.reason);
318
328
  return Response.json({ error: "ip_blocked", reason: ipCheck.reason }, { status: 403 });
319
329
  }
320
330
 
321
- if (body.machine_hash) {
322
- const rateCheck = await checkRateLimit(sb, body.machine_hash);
323
- if (!rateCheck.allowed) {
324
- await auditLog(sb, body.machine_hash, "system", route, "rate_limited", rateCheck.reason);
325
- return Response.json({ error: "rate_limited", reason: rateCheck.reason }, { status: 429 });
326
- }
327
- }
328
-
329
331
  const authResult = await validateHMAC(sb, { machine_hash: body.machine_hash, token: body.token, timestamp: body.timestamp, password: body.password });
330
332
  if (!authResult.valid) {
331
- await auditLog(sb, body.machine_hash || "unknown", body.service || "unknown", route, "denied", authResult.reason);
332
333
  return Response.json({ error: "auth_failed", reason: authResult.reason }, { status: 401 });
333
334
  }
334
335