@lifeaitools/clauth 2.10.2 → 2.15.3

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,6 +3,9 @@ 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";
6
9
 
7
10
  const SCHEMA = "lifeai.plugin.v1";
8
11
  const DEFAULT_TIMEOUT_MS = 3000;
@@ -19,6 +22,29 @@ const DEFAULT_HEALTH_RECONCILE_INTERVAL_MS = 10000;
19
22
  const DEFAULT_HEALTH_TIMEOUT_MS = 2500;
20
23
  const HEALTH_RECONCILE_COOLDOWN_MS = 15000;
21
24
  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;
22
48
 
23
49
  function hasMcpToken(value) {
24
50
  return /(^|[^a-z0-9])mcp([^a-z0-9]|$)/i.test(String(value || ""));
@@ -94,6 +120,53 @@ export function getClauthPm2Home() {
94
120
  return path.join(getSupervisorDir(), "pm2-home");
95
121
  }
96
122
 
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
+
97
170
  function file(name) {
98
171
  return path.join(getSupervisorDir(), name);
99
172
  }
@@ -107,9 +180,35 @@ function readJson(filePath, fallback) {
107
180
  }
108
181
  }
109
182
 
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.
110
210
  function writeJson(filePath, value) {
111
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
112
- fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
211
+ atomicWriteTextSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
113
212
  }
114
213
 
115
214
  function appendJsonl(filePath, value) {
@@ -132,15 +231,17 @@ function healthUrlForSurface(surface) {
132
231
  return `http://127.0.0.1:${surface.port}${String(surface.health).startsWith("/") ? surface.health : `/${surface.health}`}`;
133
232
  }
134
233
 
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;
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
+ });
144
245
  }
145
246
 
146
247
  function appendSupervisorEvent(event) {
@@ -182,19 +283,19 @@ export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, tim
182
283
  const error = health.error;
183
284
 
184
285
  if (healthy) {
185
- updateSurfaceState(id, { state: "current", last_health_at: observedAt, last_health_ok: true, last_health_error: null });
286
+ await updateSurfaceState(id, { state: "current", last_health_at: observedAt, last_health_ok: true, last_health_error: null });
186
287
  inspected.push({ surface_id: id, state: "healthy", observed_at: observedAt });
187
288
  continue;
188
289
  }
189
290
 
190
291
  const lastAttempt = Date.parse(surface.last_reconcile_at || "") || 0;
191
292
  if (Date.now() - lastAttempt < HEALTH_RECONCILE_COOLDOWN_MS) {
192
- updateSurfaceState(id, { state: "unavailable", last_health_at: observedAt, last_health_ok: false, last_health_error: error });
293
+ await updateSurfaceState(id, { state: "unavailable", last_health_at: observedAt, last_health_ok: false, last_health_error: error });
193
294
  inspected.push({ surface_id: id, state: "unavailable", error, cooldown: true, observed_at: observedAt });
194
295
  continue;
195
296
  }
196
297
 
197
- updateSurfaceState(id, {
298
+ await updateSurfaceState(id, {
198
299
  state: "unavailable",
199
300
  last_health_at: observedAt,
200
301
  last_health_ok: false,
@@ -206,7 +307,7 @@ export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, tim
206
307
  const commandCompleted = receipt?.resulting_state?.ok === true;
207
308
  const postHealth = commandCompleted ? await probeSurfaceHealth(url, fetchImpl, timeoutMs) : { healthy: false, error: receipt?.resulting_state?.state || "reconcile_failed" };
208
309
  const repaired = commandCompleted && postHealth.healthy;
209
- updateSurfaceState(id, {
310
+ await updateSurfaceState(id, {
210
311
  state: repaired ? "current" : "unavailable",
211
312
  last_health_at: now(),
212
313
  last_health_ok: repaired,
@@ -313,25 +414,41 @@ export async function probeAllSurfaceHealth({ fetchImpl = globalThis.fetch, time
313
414
  // load-modify-save per call, so N concurrent probes calling it would each
314
415
  // save a copy loaded before its siblings finished — last writer wins and the
315
416
  // 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.
316
431
  const byId = new Map(probed.map((entry) => [entry.surface_id, entry]));
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
- };
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);
333
451
  });
334
- saveSupervisorState(state);
335
452
  return { probed };
336
453
  }
337
454
 
@@ -448,7 +565,7 @@ function localhostHealth(pathOrUrl, port) {
448
565
  function normalizeSurface(surface, plugin) {
449
566
  if (!surface || typeof surface !== "object") throw new Error("surface must be an object");
450
567
  const id = String(surface.id || "").trim();
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");
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");
452
569
  const destination = normalizeDestination(surface.destination || plugin.destination);
453
570
  // A remote surface names a service running somewhere else (Vultr/Coolify).
454
571
  // It is reached by URL and its port is the deployment registry's fact, not
@@ -491,6 +608,8 @@ function normalizeSurface(surface, plugin) {
491
608
  start: normalizeCommand(surface.start || plugin.start, "surface.start"),
492
609
  stop: normalizeCommand(surface.stop || plugin.stop, "surface.stop"),
493
610
  restart: normalizeCommand(surface.restart || plugin.restart, "surface.restart"),
611
+ promote: normalizeCommand(surface.promote || plugin.promote, "surface.promote"),
612
+ rollback: normalizeCommand(surface.rollback || plugin.rollback, "surface.rollback"),
494
613
  routes: Array.isArray(surface.routes) ? surface.routes : [],
495
614
  };
496
615
  }
@@ -499,7 +618,7 @@ export function validatePluginManifest(manifest, sourcePath = "") {
499
618
  if (!manifest || typeof manifest !== "object") throw new Error("manifest must be an object");
500
619
  if (manifest.schema !== SCHEMA) throw new Error(`schema must be ${SCHEMA}`);
501
620
  const id = String(manifest.id || "").trim();
502
- 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");
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");
503
622
  const version = String(manifest.version || "").trim();
504
623
  if (!version) throw new Error("version is required");
505
624
  const plugin = {
@@ -588,6 +707,143 @@ function saveSupervisorState(state) {
588
707
  });
589
708
  }
590
709
 
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
+
591
847
  function existingById(state) {
592
848
  return new Map((state.plugins || []).map((plugin) => [plugin.id, plugin]));
593
849
  }
@@ -734,11 +990,33 @@ export function registerPlugin(manifestPath, actor = "localhost") {
734
990
  }
735
991
  const discovery = discoverPlugins();
736
992
  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
+
737
1014
  return operation("plugin.register", { manifest_path: manifestPath, plugin_id: manifest.id }, null, {
738
1015
  ok: Boolean(registered) && registered.state !== "manifest_invalid",
739
1016
  state: unchanged ? "unchanged" : "registered",
740
1017
  plugin_state: registered?.state || "not_found",
741
1018
  surfaces: registered?.surfaces?.map((surface) => surface.id) || [],
1019
+ restarted,
742
1020
  }, actor);
743
1021
  }
744
1022
 
@@ -759,12 +1037,26 @@ export function registerPlugin(manifestPath, actor = "localhost") {
759
1037
  // exist, or to cache what was found, that rebuilds the thing that was deleted —
760
1038
  // stop instead.
761
1039
  // ─────────────────────────────────────────────────────────────────────────────
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.
762
1054
  const PRODUCT_REPO_MANIFESTS = [
763
1055
  { repo: "regen-root", manifest: "packages/codeflow/clauth-plugin.json" },
764
1056
  { repo: "regen-root", manifest: "apps/dev-center/clauth-plugin.json" },
1057
+ { repo: "regen-root", manifest: "apps/codeflow-explorer/clauth-plugin.json" },
765
1058
  { repo: "regen-root", manifest: "mcp-servers/regen-media/clauth-plugin.json" },
766
1059
  { repo: "regen-root", manifest: "mcp-servers/web-research/clauth-plugin.json" },
767
- { repo: "rdc-skills", manifest: "clauth-plugin.json" },
768
1060
  ];
769
1061
 
770
1062
  // Sweep outcomes that mean "nothing was there to sync", as distinct from
@@ -897,6 +1189,16 @@ export function deregisterPlugin(id, actor = "localhost", { dryRun = false, expe
897
1189
  // all resolve back INSIDE the root and sail through containment. Only the
898
1190
  // charset rule stops them. Relaxing it to admit a separator, a colon or a
899
1191
  // 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.
900
1202
  if (!/^[a-zA-Z0-9_.-]+$/.test(pluginId) || /^\.+$/.test(pluginId)) {
901
1203
  return operation("plugin.deregister", { plugin_id: pluginId }, null, {
902
1204
  ok: false, state: "invalid_plugin_id", error: "plugin id may contain only letters, numbers, dot, underscore, and dash, and may not be all dots",
@@ -1077,6 +1379,28 @@ export function readSupervisorEvents(limit = 100) {
1077
1379
  });
1078
1380
  }
1079
1381
 
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.
1080
1404
  export function operation(action, target, prior, result, actor = "localhost") {
1081
1405
  const receipt = {
1082
1406
  operationId: crypto.randomUUID(),
@@ -1119,18 +1443,6 @@ export function operation(action, target, prior, result, actor = "localhost") {
1119
1443
  return receipt;
1120
1444
  }
1121
1445
 
1122
- export function setPluginEnabled(id, enabled, actor = "localhost") {
1123
- const state = loadSupervisorState();
1124
- const prior = (state.plugins || []).find((plugin) => plugin.id === id);
1125
- if (!prior) return { error: "plugin_not_found" };
1126
- if (prior.state === "manifest_invalid") return { error: "manifest_invalid" };
1127
- const nextPlugin = { ...prior, enabled: Boolean(enabled), state: enabled ? "current" : "awaiting_enable" };
1128
- state.plugins = state.plugins.map((plugin) => plugin.id === id ? nextPlugin : plugin);
1129
- state.surfaces = (state.surfaces || []).map((surface) => surface.plugin_id === id ? { ...surface, enabled: Boolean(enabled), state: nextPlugin.state } : surface);
1130
- saveSupervisorState(state);
1131
- return operation(enabled ? "enable" : "disable", { plugin_id: id }, prior, nextPlugin, actor);
1132
- }
1133
-
1134
1446
  export function runPluginAction(id, action, actor = "localhost") {
1135
1447
  if (!["test", "promote"].includes(action)) return { error: "invalid_action" };
1136
1448
  const state = loadSupervisorState();
@@ -1175,6 +1487,17 @@ export function runSurfaceAction(id, action, actor = "localhost") {
1175
1487
  if (surface.plugin_id === "codeflow" || surface.tags?.includes("codeflow")) {
1176
1488
  return operation(action, { surface_id: id }, surface, { ok: false, state: "observed_only", reason: "codeflow_self_owned" }, actor);
1177
1489
  }
1490
+ // Promotion changes a surface's release state. It must go through
1491
+ // runSurfacePromotion(), which requires both a rollback command and a
1492
+ // post-activation health check; this generic command runner is deliberately
1493
+ // not allowed to bypass those invariants.
1494
+ if (action === "promote") {
1495
+ return operation(action, { surface_id: id }, surface, {
1496
+ ok: false,
1497
+ state: "atomic_promotion_required",
1498
+ reason: "use_runSurfacePromotion",
1499
+ }, actor);
1500
+ }
1178
1501
  if (action === "test") {
1179
1502
  const port = surface.port === "auto" || !surface.port ? 0 : surface.port;
1180
1503
  return operation(action, { surface_id: id }, surface, { ok: true, state: "candidate_testing", port, private: true, public_route: false, evidence: ["candidate surfaces bind localhost only"] }, actor);
@@ -1182,19 +1505,12 @@ export function runSurfaceAction(id, action, actor = "localhost") {
1182
1505
  if (surface.lifecycle_owner === "plugin") {
1183
1506
  return operation(action, { surface_id: id }, surface, { ok: true, state: "delegated_to_plugin_adapter", evidence: ["plugin lifecycle owner retained"] }, actor);
1184
1507
  }
1185
- if (action === "promote" || action === "rollback") {
1186
- return operation(action, { surface_id: id }, surface, {
1187
- ok: false,
1188
- state: "unsupported_surface_action",
1189
- reason: `${action}_is_plugin_candidate_lifecycle`,
1190
- evidence: ["surface action did not execute a process command"],
1191
- }, actor);
1192
- }
1193
1508
  let command = null;
1194
1509
  if (action === "stop") command = surface.stop;
1195
1510
  else if (action === "start") command = surface.start;
1196
1511
  else if (action === "restart") command = surface.restart;
1197
1512
  else if (action === "reconcile") command = surface.enabled === false ? surface.stop : (surface.restart || surface.start);
1513
+ else if (action === "rollback") command = surface.rollback;
1198
1514
  if (!command || command.length === 0) {
1199
1515
  return operation(action, { surface_id: id }, surface, { ok: false, state: "command_missing" }, actor);
1200
1516
  }
@@ -1240,6 +1556,151 @@ export function runSurfaceAction(id, action, actor = "localhost") {
1240
1556
  }, actor);
1241
1557
  }
1242
1558
 
1559
+ /**
1560
+ * Promote a clauth-owned surface as one bounded lifecycle operation. A
1561
+ * declared rollback and a resolvable health target are preconditions, so a
1562
+ * manifest cannot accidentally turn a one-way command into a "promotion".
1563
+ */
1564
+ export async function runSurfacePromotion(id, actor = "localhost", { fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_HEALTH_TIMEOUT_MS } = {}) {
1565
+ const surface = findSurface(id);
1566
+ 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
+ if (surface.lifecycle_owner === "external" || surface.lifecycle_owner === "plugin") {
1592
+ return operation("promote", { surface_id: id }, surface, { ok: false, state: "observed_only", reason: "lifecycle_not_owned_by_clauth" }, actor);
1593
+ }
1594
+ if (surface.plugin_id === "codeflow" || surface.tags?.includes("codeflow")) {
1595
+ return operation("promote", { surface_id: id }, surface, { ok: false, state: "observed_only", reason: "codeflow_self_owned" }, actor);
1596
+ }
1597
+ if (!Array.isArray(surface.promote) || surface.promote.length === 0) {
1598
+ return operation("promote", { surface_id: id }, surface, { ok: false, state: "command_missing" }, actor);
1599
+ }
1600
+ if (!Array.isArray(surface.rollback) || surface.rollback.length === 0) {
1601
+ return operation("promote", { surface_id: id }, surface, { ok: false, state: "atomic_rollback_missing" }, actor);
1602
+ }
1603
+ const healthUrl = surfaceProbeUrl(surface);
1604
+ if (!healthUrl) {
1605
+ return operation("promote", { surface_id: id }, surface, { ok: false, state: "atomic_health_probe_missing" }, actor);
1606
+ }
1607
+
1608
+ // Reuse the guarded command runner internally without exposing a promote
1609
+ // bypass to API callers. Its action is labelled restart only for execution;
1610
+ // 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 });
1650
+
1651
+ const commandOk = promotion?.resulting_state?.ok === true;
1652
+ const health = commandOk ? await probeSurfaceHealth(healthUrl, fetchImpl, timeoutMs) : { healthy: false, error: promotion?.resulting_state?.state || "promotion_command_failed" };
1653
+ 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
+ return operation("promote", { surface_id: id }, surface, {
1681
+ 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
+ ],
1687
+ }, actor);
1688
+ }
1689
+
1690
+ await updateSurfaceState(qualifiedId, { restart: surface.rollback });
1691
+ const rollback = runSurfaceAction(qualifiedId, "restart", actor);
1692
+ await updateSurfaceState(qualifiedId, { restart: surface.restart });
1693
+ const rollbackOk = rollback?.resulting_state?.ok === true;
1694
+ return operation("promote", { surface_id: id }, surface, {
1695
+ ok: false,
1696
+ state: rollbackOk ? "promotion_rolled_back" : "promotion_rollback_failed",
1697
+ health_url: healthUrl,
1698
+ health_error: health.error || null,
1699
+ rollback_ok: rollbackOk,
1700
+ evidence: ["rollback_command_declared=true", `post_promotion_health=${health.healthy ? "healthy" : "unhealthy"}`],
1701
+ }, actor);
1702
+ }
1703
+
1243
1704
  export function addTunnelRoute(tunnelId, route, actor = "localhost") {
1244
1705
  const tunnels = listTunnels();
1245
1706
  const tunnel = tunnels.find((item) => item.id === tunnelId);