@lifeaitools/clauth 2.15.3 → 2.15.5

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.
@@ -3,9 +3,6 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { spawnSync } from "node:child_process";
6
- import { createSerializedExecutor } from "./ops/serialized-executor.js";
7
- import { atomicWriteTextSync } from "./services/file-io.js";
8
- import lockfile from "proper-lockfile";
9
6
 
10
7
  const SCHEMA = "lifeai.plugin.v1";
11
8
  const DEFAULT_TIMEOUT_MS = 3000;
@@ -22,29 +19,6 @@ const DEFAULT_HEALTH_RECONCILE_INTERVAL_MS = 10000;
22
19
  const DEFAULT_HEALTH_TIMEOUT_MS = 2500;
23
20
  const HEALTH_RECONCILE_COOLDOWN_MS = 15000;
24
21
  const DOCUMENTATION_FIELDS = ["architecture", "operator_guide", "install", "runbook", "tool_reference", "release", "agent_context"];
25
- // rdc:review finding (2026-09-02): the charset regex used at manifest-id and
26
- // surface.id validation (validatePluginManifest, two call sites) accepts
27
- // Windows reserved device names (CON, NUL, AUX, PRN, COM1-9, LPT1-9) --
28
- // historically MS-DOS device names special-cased by the Win32 namespace.
29
- //
30
- // CORRECTION (2026-09-02, independent review): this comment previously
31
- // claimed "fs.mkdirSync/writeFileSync/rmSync against a path ending in one of
32
- // these throws or silently targets the device instead of a real directory" --
33
- // live-probed directly on this host (current dev/deploy platform: Windows,
34
- // the Node version this process runs under) and that claim is FALSE.
35
- // `fs.mkdirSync`/`fs.writeFileSync` for directories/files literally named
36
- // con/nul/aux/prn/com1/lpt1 all succeeded with no throw and no device
37
- // redirection. The motivating crash does not reproduce via Node's fs APIs on
38
- // the platform clauth actually runs on. Retained anyway as low-cost defensive
39
- // hygiene against a name class other tools (cmd.exe, older shells, or future
40
- // code that shells out using the raw id) could still special-case -- but
41
- // ONLY at registration (creating a NEW entry). deregisterPlugin's own id
42
- // check deliberately does NOT include this rule: removal must never refuse
43
- // to remove something that already exists (see its Guard 1 comment for why
44
- // blocking removal here was a real, verified regression with no
45
- // compensating benefit). Mirrored client-side in
46
- // standalone/install-clauth-plugin.mjs, registration-only there too.
47
- const RESERVED_DEVICE_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
48
22
 
49
23
  function hasMcpToken(value) {
50
24
  return /(^|[^a-z0-9])mcp([^a-z0-9]|$)/i.test(String(value || ""));
@@ -120,53 +94,6 @@ export function getClauthPm2Home() {
120
94
  return path.join(getSupervisorDir(), "pm2-home");
121
95
  }
122
96
 
123
- // rdc:review finding (2026-09-02): `clauth serve foreground --isolated` never
124
- // set CLAUTH_SUPERVISOR_DIR, so an isolated test instance shared the SAME
125
- // state.json as the live :52437 daemon by default -- the "verify against an
126
- // isolated instance first" workflow documented in
127
- // .claude/rules/clauth-endpoints.md did not actually isolate state. Pure
128
- // function (no env reads) so it's directly testable: given what
129
- // CLAUTH_SUPERVISOR_DIR already is (undefined if unset) and the port an
130
- // isolated instance is starting on, returns the dir that instance should use.
131
- // Port-scoped so two isolated instances on different ports never collide.
132
- // Returns the existing value unchanged when the caller already set one
133
- // explicitly -- an operator who deliberately points CLAUTH_SUPERVISOR_DIR
134
- // somewhere for a specific test still wins.
135
- export function resolveIsolatedSupervisorDir(port, existingEnvValue) {
136
- if (existingEnvValue) return existingEnvValue;
137
- return path.join(os.tmpdir(), "clauth-isolated", `port-${port}`);
138
- }
139
-
140
- // CRITICAL follow-up finding (2026-09-02, independent review of the fix
141
- // above): `opts.isolated` in cli/commands/serve.js is OVERLOADED --
142
- // actionSupervisor() unconditionally sets it true for an unrelated reason
143
- // (skip vault password auth on the internal supervisor child every normal
144
- // `clauth serve start` spawns). Calling resolveIsolatedSupervisorDir()
145
- // whenever `isolated` is true, with no further check, redirected the REAL
146
- // production supervisor's state.json to an empty temp dir on every normal
147
- // boot -- silently disabling the health-reconcile/auto-repair loop with no
148
- // error. This predicate is the actual gate: the one real internal supervisor
149
- // process never redirects, regardless of its own isolated/auth-skip flag;
150
- // any OTHER isolated invocation (serve test, a manual --isolated --port run)
151
- // is a genuine throwaway instance and should.
152
- //
153
- // CORRECTION (2026-09-02, 4th review round): the first version of this gate
154
- // keyed off `port === supervisorPort`, the same signal
155
- // test/serve-http-routes.test.mjs's own sanctioned pattern deliberately
156
- // manufactures (setting CLAUTH_SUPERVISOR_PORT to match its own --port, to
157
- // exercise the write-token-bypass logic) -- structurally identical to the
158
- // real internal daemon from the outside, which is why an attempt to ALSO
159
- // refuse `--isolated` on that port broke 39 passing tests and had to be
160
- // reverted. ensureSupervisorStarted() already sets an unambiguous marker
161
- // (__CLAUTH_SUPERVISOR_DAEMON=1) on the ONE process it spawns for exactly
162
- // this reason -- it was set and never read. Using it here instead of port
163
- // matching means this gate now answers the actual question ("is this the
164
- // automatic internal daemon") rather than a proxy for it that a test
165
- // fixture can innocently collide with.
166
- export function isGenuinelyIsolatedInstance(isolated, isInternalSupervisorDaemon) {
167
- return !!isolated && !isInternalSupervisorDaemon;
168
- }
169
-
170
97
  function file(name) {
171
98
  return path.join(getSupervisorDir(), name);
172
99
  }
@@ -180,35 +107,9 @@ function readJson(filePath, fallback) {
180
107
  }
181
108
  }
182
109
 
183
- // rdc:review finding (2026-09-02), 4th independent round, empirically
184
- // reproduced: a plain fs.writeFileSync is NOT atomic -- an UNLOCKED reader
185
- // (findSurface/listSurfaces/discoverPlugins's own `previous` read, none of
186
- // which are lock-protected even after this same round's mutator-coverage
187
- // fix) can observe a torn, truncated, or empty file mid-write. Measured: one
188
- // process doing ~3500 locked writes of a realistic-sized state.json while
189
- // two unrelated processes did unlocked reads saw EMPTY content on 16-17% of
190
- // reads -- not a rare edge case. discoverPlugins() runs unconditionally on
191
- // every boot and reads via `previous = loadSupervisorState()`; landing in
192
- // that window silently resets every plugin's enabled flag and drops the
193
- // operations audit log, then WRITES THAT BACK -- a transient read glitch
194
- // becomes permanent data loss. Fixed here, at the write layer, rather than
195
- // requiring every reader to coordinate: temp-file + rename is atomic --
196
- // any reader, locked or not, sees either the complete OLD file or the
197
- // complete NEW file, never a partial one. This closes the finding
198
- // unconditionally, independent of which callers do or don't hold the lock.
199
- //
200
- // rdc:review finding (peer session, 2026-09-02): this hand-rolled the exact
201
- // same mkdir+temp+write+rename contract cli/services/file-io.js's
202
- // atomicWriteText() already implements (that file's own header: split out of
203
- // serve.js specifically as a shared fs_* primitive) -- and two MORE
204
- // independent copies already existed (cli/ops/job-store.js,
205
- // standalone/fs-mcp/lib/webdav-config.js). atomicWriteText is async
206
- // (fs/promises); every writeJson caller in this file is synchronous, so a
207
- // direct swap wasn't free -- added atomicWriteTextSync as its sync twin in
208
- // file-io.js instead of shipping a fourth reimplementation of the same
209
- // fragile contract.
210
110
  function writeJson(filePath, value) {
211
- atomicWriteTextSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
111
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
112
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
212
113
  }
213
114
 
214
115
  function appendJsonl(filePath, value) {
@@ -231,17 +132,15 @@ function healthUrlForSurface(surface) {
231
132
  return `http://127.0.0.1:${surface.port}${String(surface.health).startsWith("/") ? surface.health : `/${surface.health}`}`;
232
133
  }
233
134
 
234
- async function updateSurfaceState(surfaceId, patch) {
235
- return withStateLock(() => {
236
- const state = loadSupervisorState();
237
- state.surfaces = (state.surfaces || []).map((surface) => (
238
- `${surface.plugin_id}:${surface.id}` === surfaceId || surface.id === surfaceId
239
- ? { ...surface, ...patch }
240
- : surface
241
- ));
242
- saveSupervisorState(state);
243
- return state.surfaces.find((surface) => `${surface.plugin_id}:${surface.id}` === surfaceId || surface.id === surfaceId) || null;
244
- });
135
+ function updateSurfaceState(surfaceId, patch) {
136
+ const state = loadSupervisorState();
137
+ state.surfaces = (state.surfaces || []).map((surface) => (
138
+ `${surface.plugin_id}:${surface.id}` === surfaceId || surface.id === surfaceId
139
+ ? { ...surface, ...patch }
140
+ : surface
141
+ ));
142
+ saveSupervisorState(state);
143
+ return state.surfaces.find((surface) => `${surface.plugin_id}:${surface.id}` === surfaceId || surface.id === surfaceId) || null;
245
144
  }
246
145
 
247
146
  function appendSupervisorEvent(event) {
@@ -283,19 +182,19 @@ export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, tim
283
182
  const error = health.error;
284
183
 
285
184
  if (healthy) {
286
- await updateSurfaceState(id, { state: "current", last_health_at: observedAt, last_health_ok: true, last_health_error: null });
185
+ updateSurfaceState(id, { state: "current", last_health_at: observedAt, last_health_ok: true, last_health_error: null });
287
186
  inspected.push({ surface_id: id, state: "healthy", observed_at: observedAt });
288
187
  continue;
289
188
  }
290
189
 
291
190
  const lastAttempt = Date.parse(surface.last_reconcile_at || "") || 0;
292
191
  if (Date.now() - lastAttempt < HEALTH_RECONCILE_COOLDOWN_MS) {
293
- await updateSurfaceState(id, { state: "unavailable", last_health_at: observedAt, last_health_ok: false, last_health_error: error });
192
+ updateSurfaceState(id, { state: "unavailable", last_health_at: observedAt, last_health_ok: false, last_health_error: error });
294
193
  inspected.push({ surface_id: id, state: "unavailable", error, cooldown: true, observed_at: observedAt });
295
194
  continue;
296
195
  }
297
196
 
298
- await updateSurfaceState(id, {
197
+ updateSurfaceState(id, {
299
198
  state: "unavailable",
300
199
  last_health_at: observedAt,
301
200
  last_health_ok: false,
@@ -307,7 +206,7 @@ export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, tim
307
206
  const commandCompleted = receipt?.resulting_state?.ok === true;
308
207
  const postHealth = commandCompleted ? await probeSurfaceHealth(url, fetchImpl, timeoutMs) : { healthy: false, error: receipt?.resulting_state?.state || "reconcile_failed" };
309
208
  const repaired = commandCompleted && postHealth.healthy;
310
- await updateSurfaceState(id, {
209
+ updateSurfaceState(id, {
311
210
  state: repaired ? "current" : "unavailable",
312
211
  last_health_at: now(),
313
212
  last_health_ok: repaired,
@@ -414,41 +313,25 @@ export async function probeAllSurfaceHealth({ fetchImpl = globalThis.fetch, time
414
313
  // load-modify-save per call, so N concurrent probes calling it would each
415
314
  // save a copy loaded before its siblings finished — last writer wins and the
416
315
  // rest are silently lost.
417
- //
418
- // rdc:review finding (2026-09-02): this write was outside withStateLock --
419
- // the sibling to the race class that mutex exists to close, left open here.
420
- // Routed through it for consistency with every other state.json mutator in
421
- // this file. Note what this does and doesn't fix: `probed` is computed from
422
- // measurements taken BEFORE this critical section runs (the Promise.all
423
- // above), so a concurrent writer's fresher last_health_* value for the same
424
- // field on the same surface can still be overwritten by this sweep's own
425
- // (now-stale) measurement -- the lock serializes WRITES, it cannot un-stale
426
- // data that was already measured before the lock was acquired. What it DOES
427
- // guarantee: this sweep's own load-modify-save can no longer interleave
428
- // with another lock-using mutator's load-modify-save landing at the same
429
- // instant, and every field this sweep doesn't touch is protected by the
430
- // same fresh-read-under-lock discipline as everywhere else in this file.
431
316
  const byId = new Map(probed.map((entry) => [entry.surface_id, entry]));
432
- await withStateLock(() => {
433
- const state = loadSupervisorState();
434
- state.surfaces = (state.surfaces || []).map((surface) => {
435
- const entry = byId.get(`${surface.plugin_id}:${surface.id}`);
436
- if (!entry) return surface;
437
- // last_health_url is the RESOLVED probe target, not the manifest's health
438
- // field. A remote surface carries a relative "/health" by contract, so the
439
- // manifest value alone cannot be opened in a browser only the resolved
440
- // one can, and it is the fallback when the Open root turns out to be dead.
441
- const next = { ...surface, last_open_url: entry.open_url, last_open_ok: entry.open_ok, last_health_url: entry.url };
442
- if (entry.health === "no_probe") return next;
443
- return {
444
- ...next,
445
- last_health_at: entry.observed_at,
446
- last_health_ok: entry.health === "healthy",
447
- last_health_error: entry.health === "healthy" ? null : entry.error,
448
- };
449
- });
450
- saveSupervisorState(state);
317
+ const state = loadSupervisorState();
318
+ state.surfaces = (state.surfaces || []).map((surface) => {
319
+ const entry = byId.get(`${surface.plugin_id}:${surface.id}`);
320
+ if (!entry) return surface;
321
+ // last_health_url is the RESOLVED probe target, not the manifest's health
322
+ // field. A remote surface carries a relative "/health" by contract, so the
323
+ // manifest value alone cannot be opened in a browser only the resolved
324
+ // one can, and it is the fallback when the Open root turns out to be dead.
325
+ const next = { ...surface, last_open_url: entry.open_url, last_open_ok: entry.open_ok, last_health_url: entry.url };
326
+ if (entry.health === "no_probe") return next;
327
+ return {
328
+ ...next,
329
+ last_health_at: entry.observed_at,
330
+ last_health_ok: entry.health === "healthy",
331
+ last_health_error: entry.health === "healthy" ? null : entry.error,
332
+ };
451
333
  });
334
+ saveSupervisorState(state);
452
335
  return { probed };
453
336
  }
454
337
 
@@ -565,7 +448,7 @@ function localhostHealth(pathOrUrl, port) {
565
448
  function normalizeSurface(surface, plugin) {
566
449
  if (!surface || typeof surface !== "object") throw new Error("surface must be an object");
567
450
  const id = String(surface.id || "").trim();
568
- if (!/^[a-zA-Z0-9_.-]+$/.test(id) || /^\.+$/.test(id) || RESERVED_DEVICE_NAMES.test(id)) throw new Error("surface.id may contain only letters, numbers, dot, underscore, and dash, and may not be all dots, and may not be a Windows reserved device name");
451
+ 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");
569
452
  const destination = normalizeDestination(surface.destination || plugin.destination);
570
453
  // A remote surface names a service running somewhere else (Vultr/Coolify).
571
454
  // It is reached by URL and its port is the deployment registry's fact, not
@@ -618,7 +501,7 @@ export function validatePluginManifest(manifest, sourcePath = "") {
618
501
  if (!manifest || typeof manifest !== "object") throw new Error("manifest must be an object");
619
502
  if (manifest.schema !== SCHEMA) throw new Error(`schema must be ${SCHEMA}`);
620
503
  const id = String(manifest.id || "").trim();
621
- if (!/^[a-zA-Z0-9_.-]+$/.test(id) || /^\.+$/.test(id) || RESERVED_DEVICE_NAMES.test(id)) throw new Error("id may contain only letters, numbers, dot, underscore, and dash, and may not be all dots, and may not be a Windows reserved device name");
504
+ 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");
622
505
  const version = String(manifest.version || "").trim();
623
506
  if (!version) throw new Error("version is required");
624
507
  const plugin = {
@@ -707,143 +590,6 @@ function saveSupervisorState(state) {
707
590
  });
708
591
  }
709
592
 
710
- // state.json's read-modify-write needs a real lock: two separate OS
711
- // processes share it by design in the STANDARD deployment topology (`clauth
712
- // serve start`'s ensureSupervisorStarted() spawns the :52439 supervisor
713
- // child via a genuinely separate `spawn(..., {detached:true})`, deliberately
714
- // sharing CLAUTH_SUPERVISOR_DIR with the main :52437 daemon -- and
715
- // registerSupervisorRoutes() mounts the promote route on both), so an
716
- // in-process-only mutex protects nothing between a promote via :52437 and
717
- // the health-reconcile loop's write via :52439 (gated to exactly that one
718
- // process by `port === getSupervisorPort()`). Confirmed by direct code
719
- // tracing, not assumed.
720
- //
721
- // withStateLock does two things: an in-process queue (createSerializedExecutor,
722
- // cli/ops/serialized-executor.js -- reused, not duplicated) avoids
723
- // unnecessary lock-file contention among same-process callers, and
724
- // proper-lockfile below is a real cross-process lock serializing across BOTH
725
- // processes. Every mutator in this file (discoverPlugins, registerPlugin,
726
- // deregisterPlugin, runPluginAction, operation, runSurfacePromotion,
727
- // updateSurfaceState, probeAllSurfaceHealth) is routed through it.
728
- //
729
- // Deliberately NOT held across slow work: every withStateLock call site
730
- // wraps only a load+patch+save (fast, no network/spawnSync inside the locked
731
- // section) -- runSurfaceAction's actual command execution (which can run up
732
- // to `surface.timeoutMs || 30000`ms) and health probes both run OUTSIDE any
733
- // withStateLock call. This is what keeps normal contention brief: no
734
- // legitimate holder should ever approach the stale threshold below.
735
- //
736
- // HISTORY: three hand-rolled versions of this lock were built, reviewed, and
737
- // each found to have a distinct real concurrency bug -- all via direct
738
- // empirical reproduction (real spawned processes racing the real code), not
739
- // by inspection alone:
740
- // 1. openSync(path,'wx') + a SEPARATE writeSync for content -- the file
741
- // observably existed empty for a brief window between the two; a
742
- // concurrent reader landing there saw unparseable content and
743
- // immediately treated it as abandoned, racing the legitimate holder's
744
- // in-flight write. Reproduced: 23 increments landed instead of 24
745
- // across two real processes each doing 12 locked increments.
746
- // 2. Fixed (1) via a directory (one atomic mkdirSync, no separate
747
- // content-write step) but reclaimed a stale lock via unlinkSync +
748
- // retry -- TWO waiters could both read the same stale record, both
749
- // decide to reclaim, and both succeed independently (unlink doesn't
750
- // prove you were first). Reproduced: 2-3 simultaneous "holders" in 5 of
751
- // 12 runs against an instrumented mirror of the algorithm.
752
- // 3. Fixed (2) via an atomic renameSync-based reclaim claim, but had no
753
- // FENCING: release() removed "whatever is at the lock path" rather
754
- // than "the specific instance this process acquired," so a holder that
755
- // stalled past the stale threshold while still alive could have its
756
- // lock reclaimed by a waiter, and the original holder's eventual
757
- // release() would then delete the NEW holder's active lock. Separately,
758
- // a leftover lock from version (1) or (2)'s on-disk shape permanently
759
- // deadlocked version (3) on upgrade -- reproduced directly.
760
- //
761
- // Three distinct real bugs in three consecutive attempts at hand-rolling the
762
- // same primitive is a pattern, not bad luck -- proper file locking with
763
- // correct fencing, staleness detection, and crash recovery is a well-studied
764
- // problem with mature solutions, and continuing to re-derive one under time
765
- // pressure was the wrong instinct from the start (this was explicitly
766
- // offered as "option 2" in the very first interview on this topic and set
767
- // aside for "no new dependency," a preference that cost far more in
768
- // engineering time and risk than the dependency would have). Replaced with
769
- // proper-lockfile (moxystudio/node-proper-lockfile, MIT, minimal dependency
770
- // footprint -- graceful-fs, retry, signal-exit, all already-common
771
- // transitive deps): mkdir-based atomic creation (same primitive as version
772
- // 2/3 above), staleness via continuously-updated mtime rather than a
773
- // point-in-time pid/age snapshot (detects an ACTUALLY-stuck holder, not just
774
- // an old one), and release() is tied to the specific acquisition via its own
775
- // compromise detection (onCompromised fires if the lock is found to have
776
- // been reclaimed out from under an active holder) -- closing the fencing gap
777
- // hand-rolling never got right. Its lockfile path is `<file>.lock`, distinct
778
- // from every prior on-disk shape used here (`state.lock` as both a file and
779
- // a directory across the three versions above), so no upgrade-collision
780
- // migration is needed -- a leftover artifact from any prior version simply
781
- // sits unused at a path this version never touches.
782
- const inProcessStateQueue = createSerializedExecutor();
783
- // Matches the generous, crash-recovery-oriented threshold from the
784
- // hand-rolled versions: no legitimate critical section here holds the lock
785
- // anywhere near this long (see "deliberately NOT held across slow work"
786
- // above), so this is a backstop, not a normal-operation timeout.
787
- const STATE_LOCK_STALE_MS = 30000;
788
- // ~5s total wait budget under contention, matching the prior versions'
789
- // timeout, expressed as proper-lockfile's retry-package options.
790
- const STATE_LOCK_RETRIES = { retries: 50, minTimeout: 100, maxTimeout: 100, randomize: false };
791
-
792
- function stateLockTargetPath() {
793
- // realpath:false below means this path is never required to exist (pure
794
- // string resolution, no fs check) -- safe to point at state.json even
795
- // before it's ever been written, e.g. a brand-new install's first boot.
796
- return file("state.json");
797
- }
798
-
799
- // rdc:review finding (2026-09-02), 4th round: cli/commands/serve.js had its
800
- // own, older copy of this exact function that did NOT special-case EPERM as
801
- // "alive" -- ensureSupervisorStarted()/stopSupervisorSibling() use it to
802
- // decide whether the real :52439 supervisor child is already running, and
803
- // under EPERM (a live process signalled across a security/account boundary)
804
- // the stale definition wrongly reported it dead, risking a duplicate
805
- // supervisor child spawn -- two real processes racing the port bind and the
806
- // health-reconcile loop. Exported here and imported by serve.js instead of
807
- // duplicated, so there is exactly one definition to keep correct. (No longer
808
- // used by the lock itself -- proper-lockfile's own mtime-based staleness
809
- // check replaced the hand-rolled pid-liveness check -- but still the shared
810
- // definition serve.js's supervisor-child bookkeeping depends on.)
811
- export function isProcessAlive(pid) {
812
- try {
813
- process.kill(pid, 0);
814
- return true;
815
- } catch (err) {
816
- // EPERM: the process exists but we lack permission to signal it -- still
817
- // alive. Any other error (ESRCH, etc.) means it's gone.
818
- return err.code === "EPERM";
819
- }
820
- }
821
-
822
- export async function withStateLock(criticalSection) {
823
- return inProcessStateQueue(async () => {
824
- const release = await lockfile.lock(stateLockTargetPath(), {
825
- realpath: false,
826
- stale: STATE_LOCK_STALE_MS,
827
- retries: STATE_LOCK_RETRIES,
828
- onCompromised: (err) => {
829
- // Should be effectively unreachable given how short every critical
830
- // section here is relative to `stale` -- logged, not thrown, so a
831
- // detection here can't itself crash the process the way the
832
- // library's default onCompromised (rethrow) would.
833
- try {
834
- const logFile = process.env.CLAUTH_SERVE_LOG || path.join(os.tmpdir(), "clauth-serve.log");
835
- fs.appendFileSync(logFile, `[${now()}] state.json lock compromised: ${err?.message || err}\n`, "utf8");
836
- } catch { /* logging must never itself throw */ }
837
- },
838
- });
839
- try {
840
- return await criticalSection();
841
- } finally {
842
- await release();
843
- }
844
- });
845
- }
846
-
847
593
  function existingById(state) {
848
594
  return new Map((state.plugins || []).map((plugin) => [plugin.id, plugin]));
849
595
  }
@@ -990,33 +736,11 @@ export function registerPlugin(manifestPath, actor = "localhost") {
990
736
  }
991
737
  const discovery = discoverPlugins();
992
738
  const registered = discovery.plugins.find((plugin) => plugin.id === manifest.id);
993
-
994
- // A changed manifest means changed code (a version bump, a fresh
995
- // `npm install -g`) — registering it used to only re-read metadata and
996
- // never touch the actual running process, so the live PM2 surface kept
997
- // serving the OLD code indefinitely until something else happened to
998
- // restart it (Dave: "plugin install pings clauth to reread -- it should
999
- // restart the pm2 -- fix the bug"). Restart every surface clauth actually
1000
- // owns (lifecycle_owner:"clauth", i.e. PM2-managed, not an "external"-owned
1001
- // surface like codeflow's) that declares a real restart command. Skipped
1002
- // entirely when unchanged — an unrelated dependency bump must not restart
1003
- // a live service for nothing.
1004
- const restarted = [];
1005
- if (!unchanged && registered) {
1006
- for (const surface of registered.surfaces || []) {
1007
- if (surface.lifecycle_owner !== "clauth") continue;
1008
- if (!Array.isArray(surface.restart) || surface.restart.length === 0) continue;
1009
- const result = runSurfaceAction(`${manifest.id}:${surface.id}`, "restart", actor);
1010
- restarted.push({ surface_id: surface.id, ok: result?.resulting_state?.ok === true, state: result?.resulting_state?.state || result?.error || "unknown" });
1011
- }
1012
- }
1013
-
1014
739
  return operation("plugin.register", { manifest_path: manifestPath, plugin_id: manifest.id }, null, {
1015
740
  ok: Boolean(registered) && registered.state !== "manifest_invalid",
1016
741
  state: unchanged ? "unchanged" : "registered",
1017
742
  plugin_state: registered?.state || "not_found",
1018
743
  surfaces: registered?.surfaces?.map((surface) => surface.id) || [],
1019
- restarted,
1020
744
  }, actor);
1021
745
  }
1022
746
 
@@ -1037,26 +761,12 @@ export function registerPlugin(manifestPath, actor = "localhost") {
1037
761
  // exist, or to cache what was found, that rebuilds the thing that was deleted —
1038
762
  // stop instead.
1039
763
  // ─────────────────────────────────────────────────────────────────────────────
1040
- // rdc-skills is deliberately NOT in this list. It is npm-published, so unlike
1041
- // every entry below it already has its own real install lifecycle — its own
1042
- // postinstall (scripts/postinstall.js) calls `clauth plugin register` against
1043
- // the INSTALLED package's own manifest (__dirname-anchored, never cwd). A
1044
- // checkout-path sweep entry here would read a DIFFERENT, unrelated copy
1045
- // (a dev git checkout that may not exist on every machine, or may hold
1046
- // unreleased content) and could overwrite the correct, install-based
1047
- // registration with it. This already happened once: the earlier version of
1048
- // this entry shipped a manifest carrying a literal cwd "C:/Dev/rdc-skills",
1049
- // so `npm i -g` on any OTHER machine auto-enabled a core plugin pointing at
1050
- // a directory that did not exist there (Dave: "rdc skills is an mcp
1051
- // application is it installed - no checkout is allowed"). The ${PACKAGE_ROOT}
1052
- // token fix in registerPlugin() solved the symptom; removing this entry
1053
- // removes the actual competing registration path.
1054
764
  const PRODUCT_REPO_MANIFESTS = [
1055
765
  { repo: "regen-root", manifest: "packages/codeflow/clauth-plugin.json" },
1056
766
  { repo: "regen-root", manifest: "apps/dev-center/clauth-plugin.json" },
1057
- { repo: "regen-root", manifest: "apps/codeflow-explorer/clauth-plugin.json" },
1058
767
  { repo: "regen-root", manifest: "mcp-servers/regen-media/clauth-plugin.json" },
1059
768
  { repo: "regen-root", manifest: "mcp-servers/web-research/clauth-plugin.json" },
769
+ { repo: "rdc-skills", manifest: "clauth-plugin.json" },
1060
770
  ];
1061
771
 
1062
772
  // Sweep outcomes that mean "nothing was there to sync", as distinct from
@@ -1189,16 +899,6 @@ export function deregisterPlugin(id, actor = "localhost", { dryRun = false, expe
1189
899
  // all resolve back INSIDE the root and sail through containment. Only the
1190
900
  // charset rule stops them. Relaxing it to admit a separator, a colon or a
1191
901
  // drive letter reopens a real hole — the tests pin all three forms.
1192
- //
1193
- // rdc:review finding (2026-09-02): unlike validatePluginManifest (which
1194
- // guards CREATING new entries), this guard deliberately does NOT reject
1195
- // RESERVED_DEVICE_NAMES. Removal must never refuse to remove something that
1196
- // already exists on disk, and blocking it here had no compensating safety
1197
- // benefit -- it only trapped any plugin whose id happened to match a
1198
- // reserved name (registered before this check existed, or by any other
1199
- // path) in a permanent, un-removable quarantined state with no
1200
- // remediation, since deregisterPlugin is the ONLY removal API, including
1201
- // under force:true.
1202
902
  if (!/^[a-zA-Z0-9_.-]+$/.test(pluginId) || /^\.+$/.test(pluginId)) {
1203
903
  return operation("plugin.deregister", { plugin_id: pluginId }, null, {
1204
904
  ok: false, state: "invalid_plugin_id", error: "plugin id may contain only letters, numbers, dot, underscore, and dash, and may not be all dots",
@@ -1379,28 +1079,6 @@ export function readSupervisorEvents(limit = 100) {
1379
1079
  });
1380
1080
  }
1381
1081
 
1382
- // rdc:review finding (2026-09-02), 4th round: this function's own
1383
- // read-modify-write of state.operations is NOT withStateLock-protected, and
1384
- // -- corrected from an earlier, inaccurate version of this note -- its
1385
- // residual risk is NOT limited to a lost operations audit-log entry.
1386
- // loadSupervisorState()/saveSupervisorState() here operate on the WHOLE
1387
- // state object, not a scoped patch: if a withStateLock-protected mutator's
1388
- // write (e.g. a plugin-enable flip) lands between this function's own
1389
- // unlocked load and save, this function's save reverts that change too, not
1390
- // just its own append. operation() is called by EVERY mutator in this file
1391
- // (runSurfaceAction, runSurfacePromotion, registerPlugin, deregisterPlugin,
1392
- // runPluginAction, addTunnelRoute, removeTunnelRoute), all currently
1393
- // synchronous, so converting operation() to route through withStateLock
1394
- // requires converting all of them (and their own callers throughout
1395
- // serve.js/index.js/the HTTP components) to async -- a large, real ripple,
1396
- // not a quick patch, and the wrong thing to attempt under time pressure atop
1397
- // a lock implementation that has already had two genuine bugs found and
1398
- // fixed in it this same session. Left honestly unlocked and documented
1399
- // rather than converted hastily. state.json's write being atomic (see
1400
- // writeJson) means this can no longer corrupt the FILE (no torn reads), only
1401
- // silently lose a concurrent WRITE under real contention -- narrower than
1402
- // before this session's atomic-write fix, but still real. Tracked for the
1403
- // full-mutator-coverage follow-up rather than fixed here.
1404
1082
  export function operation(action, target, prior, result, actor = "localhost") {
1405
1083
  const receipt = {
1406
1084
  operationId: crypto.randomUUID(),
@@ -1443,6 +1121,18 @@ export function operation(action, target, prior, result, actor = "localhost") {
1443
1121
  return receipt;
1444
1122
  }
1445
1123
 
1124
+ export function setPluginEnabled(id, enabled, actor = "localhost") {
1125
+ const state = loadSupervisorState();
1126
+ const prior = (state.plugins || []).find((plugin) => plugin.id === id);
1127
+ if (!prior) return { error: "plugin_not_found" };
1128
+ if (prior.state === "manifest_invalid") return { error: "manifest_invalid" };
1129
+ const nextPlugin = { ...prior, enabled: Boolean(enabled), state: enabled ? "current" : "awaiting_enable" };
1130
+ state.plugins = state.plugins.map((plugin) => plugin.id === id ? nextPlugin : plugin);
1131
+ state.surfaces = (state.surfaces || []).map((surface) => surface.plugin_id === id ? { ...surface, enabled: Boolean(enabled), state: nextPlugin.state } : surface);
1132
+ saveSupervisorState(state);
1133
+ return operation(enabled ? "enable" : "disable", { plugin_id: id }, prior, nextPlugin, actor);
1134
+ }
1135
+
1446
1136
  export function runPluginAction(id, action, actor = "localhost") {
1447
1137
  if (!["test", "promote"].includes(action)) return { error: "invalid_action" };
1448
1138
  const state = loadSupervisorState();
@@ -1522,8 +1212,16 @@ export function runSurfaceAction(id, action, actor = "localhost") {
1522
1212
  // a space (e.g. "C:\Program Files\nodejs\node.exe") breaks at the first
1523
1213
  // space unless quoted. Bare shim names (pm2, npm) never contain spaces,
1524
1214
  // so this only ever affects absolute-path values, and only on Windows.
1525
- const resolvedCmd = shellQuote(cmd, useShell);
1526
- const resolvedArgs = args.map((arg) => shellQuote(arg, useShell));
1215
+ // Expand $LIFEAI_ENV / $REGEN_ROOT tokens in the command AND its args, the same
1216
+ // way surface.cwd is expanded at normalize time. A surface start such as
1217
+ // `pwsh -File $LIFEAI_ENV/services/restart-dev-center.ps1` (dev-center) or
1218
+ // `pm2 start $REGEN_ROOT/apps/codeflow-explorer/... ` otherwise reached the shell
1219
+ // with the literal token and failed ("not recognized as the name of a script
1220
+ // file", exit 64) — a start/restart that fails while the service stays down.
1221
+ // expandPathToken is a no-op on token-free values (pm2, --name, --cwd), so it is
1222
+ // safe on every arg.
1223
+ const resolvedCmd = shellQuote(expandPathToken(cmd) || cmd, useShell);
1224
+ const resolvedArgs = args.map((arg) => shellQuote(expandPathToken(arg) || arg, useShell));
1527
1225
  return spawnSync(resolvedCmd, resolvedArgs, {
1528
1226
  cwd: surface.cwd || undefined,
1529
1227
  env: { ...process.env, CLAUTH_PM2_HOME: getClauthPm2Home(), PM2_HOME: getClauthPm2Home() },
@@ -1564,30 +1262,6 @@ export function runSurfaceAction(id, action, actor = "localhost") {
1564
1262
  export async function runSurfacePromotion(id, actor = "localhost", { fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_HEALTH_TIMEOUT_MS } = {}) {
1565
1263
  const surface = findSurface(id);
1566
1264
  if (!surface) return { error: "surface_not_found" };
1567
- // rdc:review finding (2026-09-02), third pass: the caller-supplied `id` can
1568
- // be a short, non-qualified form (findSurface() resolves via `surface.id
1569
- // === id` too, not qualified-only). updateSurfaceState()'s matcher is
1570
- // OR-based and applied via `.map()` -- it patches EVERY surface whose id OR
1571
- // qualified id matches, not just the one resolved above. Two different
1572
- // plugins registering a surface with the same short id (nothing enforces
1573
- // cross-plugin uniqueness) would have every updateSurfaceState(id, ...)
1574
- // call below silently patch BOTH surfaces. The old (pre-this-fix) code
1575
- // never had this hole because its array .map() matched on the RESOLVED
1576
- // surface's own qualified identity, not the raw caller-supplied id.
1577
- //
1578
- // rdc:review finding (2026-09-02), 4th round: runSurfaceAction(id, ...)
1579
- // below was left on the raw `id`, reasoning that its own findSurface()
1580
- // resolves via first-match .find() rather than multi-patch .map() so a
1581
- // short-id collision couldn't touch the WRONG surface's data the way
1582
- // updateSurfaceState's .map() could -- but .find() can still resolve a
1583
- // DIFFERENT surface than the one this function already committed to if a
1584
- // concurrent unlocked discoverPlugins() reorders state.surfaces between
1585
- // this function's own findSurface(id) above and runSurfaceAction's later,
1586
- // independent one. A colon can never appear in a valid id (the charset
1587
- // regex forbids it), so a qualified string can never accidentally match a
1588
- // different surface's raw short id -- using it everywhere below, including
1589
- // runSurfaceAction, is strictly safer with no downside.
1590
- const qualifiedId = `${surface.plugin_id}:${surface.id}`;
1591
1265
  if (surface.lifecycle_owner === "external" || surface.lifecycle_owner === "plugin") {
1592
1266
  return operation("promote", { surface_id: id }, surface, { ok: false, state: "observed_only", reason: "lifecycle_not_owned_by_clauth" }, actor);
1593
1267
  }
@@ -1608,88 +1282,27 @@ export async function runSurfacePromotion(id, actor = "localhost", { fetchImpl =
1608
1282
  // Reuse the guarded command runner internally without exposing a promote
1609
1283
  // bypass to API callers. Its action is labelled restart only for execution;
1610
1284
  // the durable receipt below remains the authoritative promotion record.
1611
- //
1612
- // rdc:review finding (2026-09-02), second pass on this same function: the
1613
- // first version of this fix snapshotted the whole `surfaces` array ONCE at
1614
- // function entry and wrote it back WHOLESALE on both the pre-action swap
1615
- // and the post-action revert -- a stale-snapshot overwrite, not a merge, so
1616
- // any concurrent write to a DIFFERENT surface (reconcileSurfaceHealth's
1617
- // health update, another promotion, discoverPlugins) landing during this
1618
- // function's real await gaps (the health probe below, or a slow
1619
- // spawnSync-backed command) was silently discarded the moment this
1620
- // function's own revert-write landed -- reopening, in a different shape,
1621
- // the exact class of race withStateLock exists to close. Fixed by routing
1622
- // every surface mutation here through updateSurfaceState(), the same
1623
- // single-entity merge-patch helper reconcileSurfaceHealth already uses --
1624
- // it re-reads fresh state under the same lock and patches ONLY this
1625
- // surface's fields, so a concurrent writer's change to any other surface
1626
- // (or any other field on this one) survives regardless of how long this
1627
- // function's own await gaps run.
1628
- // rdc:review finding (2026-09-02), third pass: the plugins-flip block below
1629
- // added `matchFound` so a concurrent-removal no-op is reported honestly
1630
- // instead of a false success claim. The four updateSurfaceState() calls in
1631
- // this function had the identical exposure (a concurrent deregisterPlugin
1632
- // removing this surface mid-promotion makes each call's own internal
1633
- // .map() a silent no-op) but discarded their return values, so the same
1634
- // vanished-mid-flight case here produced a misleading
1635
- // "promotion_rollback_failed" receipt instead of naming what happened.
1636
- // Checked only at the FIRST call: if the surface is already gone before
1637
- // the promote-command swap even lands, there is nothing left to execute --
1638
- // short-circuit with an honest receipt instead of running commands against
1639
- // and probing the health of a surface findSurface() itself would already
1640
- // report has vanished, then mislabeling the result.
1641
- const swapped = await updateSurfaceState(qualifiedId, { restart: surface.promote });
1642
- if (!swapped) {
1643
- return operation("promote", { surface_id: id }, surface, {
1644
- ok: false, state: "surface_vanished_during_promotion",
1645
- reason: "surface was removed (e.g. concurrent deregister) before the promote command could run",
1646
- }, actor);
1647
- }
1648
- const promotion = runSurfaceAction(qualifiedId, "restart", actor);
1649
- await updateSurfaceState(qualifiedId, { restart: surface.restart });
1285
+ const promoteSurface = { ...surface, restart: surface.promote };
1286
+ const state = loadSupervisorState();
1287
+ const surfaces = state.surfaces || [];
1288
+ saveSupervisorState({ ...state, surfaces: surfaces.map((item) => `${item.plugin_id}:${item.id}` === `${surface.plugin_id}:${surface.id}` ? promoteSurface : item) });
1289
+ const promotion = runSurfaceAction(id, "restart", actor);
1290
+ saveSupervisorState({ ...loadSupervisorState(), surfaces });
1650
1291
 
1651
1292
  const commandOk = promotion?.resulting_state?.ok === true;
1652
1293
  const health = commandOk ? await probeSurfaceHealth(healthUrl, fetchImpl, timeoutMs) : { healthy: false, error: promotion?.resulting_state?.state || "promotion_command_failed" };
1653
1294
  if (commandOk && health.healthy) {
1654
- // A healthy promotion is the real, verified "this is live" event that
1655
- // setPluginEnabled() used to fake with a bare metadata flip -- so this is
1656
- // where `enabled` belongs now. Without this, a core:false plugin (every
1657
- // standalone MCP: fs-mcp/gws-mcp/test-mcp all ship core:false) can never
1658
- // reach enabled:true by any path, which silently disables
1659
- // reconcileSurfaceHealth()'s auto-repair for it forever.
1660
- // `state` is not touched here -- discoverPlugins() recomputes it from
1661
- // `prior.enabled` on the next pass, the same way it always has.
1662
- //
1663
- // rdc:review finding (2026-09-02): the evidence line below used to claim
1664
- // "plugin_enabled=true" unconditionally, even when no plugin matched
1665
- // surface.plugin_id (e.g. deregistered concurrently, between this
1666
- // function's own reads) -- .map() with no match is a silent no-op, so the
1667
- // receipt was reporting a mutation that never happened. matchFound makes
1668
- // the evidence honest about which case actually occurred.
1669
- const matchFound = await withStateLock(() => {
1670
- const enabledState = loadSupervisorState();
1671
- let found = false;
1672
- const nextPlugins = (enabledState.plugins || []).map((plugin) => {
1673
- if (plugin.id !== surface.plugin_id) return plugin;
1674
- found = true;
1675
- return { ...plugin, enabled: true };
1676
- });
1677
- saveSupervisorState({ ...enabledState, plugins: nextPlugins });
1678
- return found;
1679
- });
1680
1295
  return operation("promote", { surface_id: id }, surface, {
1681
1296
  ok: true, state: "promotion_healthy", health_url: healthUrl,
1682
- evidence: [
1683
- "rollback_command_declared=true",
1684
- "post_promotion_health=healthy",
1685
- matchFound ? "plugin_enabled=true" : "plugin_enabled=false plugin_not_found_in_state",
1686
- ],
1297
+ evidence: ["rollback_command_declared=true", "post_promotion_health=healthy"],
1687
1298
  }, actor);
1688
1299
  }
1689
1300
 
1690
- await updateSurfaceState(qualifiedId, { restart: surface.rollback });
1691
- const rollback = runSurfaceAction(qualifiedId, "restart", actor);
1692
- await updateSurfaceState(qualifiedId, { restart: surface.restart });
1301
+ const rollbackSurface = { ...surface, restart: surface.rollback };
1302
+ const rollbackState = loadSupervisorState();
1303
+ saveSupervisorState({ ...rollbackState, surfaces: (rollbackState.surfaces || []).map((item) => `${item.plugin_id}:${item.id}` === `${surface.plugin_id}:${surface.id}` ? rollbackSurface : item) });
1304
+ const rollback = runSurfaceAction(id, "restart", actor);
1305
+ saveSupervisorState({ ...loadSupervisorState(), surfaces });
1693
1306
  const rollbackOk = rollback?.resulting_state?.ok === true;
1694
1307
  return operation("promote", { surface_id: id }, surface, {
1695
1308
  ok: false,