@lifeaitools/clauth 2.10.1 → 2.15.2

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,88 @@ 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;
48
+
49
+ function hasMcpToken(value) {
50
+ return /(^|[^a-z0-9])mcp([^a-z0-9]|$)/i.test(String(value || ""));
51
+ }
52
+
53
+ function routeDeclaresMcp(route) {
54
+ if (!route || typeof route !== "object") return false;
55
+ if (hasMcpToken(route.id) || hasMcpToken(route.kind)) return true;
56
+ try {
57
+ return new URL(String(route.url || "")).pathname
58
+ .split("/")
59
+ .some((segment) => segment.toLowerCase() === "mcp");
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ function normalizeCapabilities(value) {
66
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { kinds: [] };
67
+ const kinds = Array.isArray(value.kinds)
68
+ ? [...new Set(value.kinds.map((kind) => String(kind || "").trim().toLowerCase())
69
+ .filter((kind) => /^[a-z0-9_.-]+$/.test(kind)))]
70
+ : [];
71
+ return { kinds };
72
+ }
73
+
74
+ function normalizeMcpContract(value) {
75
+ if (value === true) return { declared: true, transport: null, url: null, stdio: [], tools: [] };
76
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
77
+ return {
78
+ declared: true,
79
+ transport: value.transport ? String(value.transport) : null,
80
+ url: value.url ? String(value.url) : null,
81
+ stdio: normalizeCommand(value.stdio, "mcp.stdio"),
82
+ tools: Array.isArray(value.tools)
83
+ ? value.tools.map((tool) => String(tool || "").trim()).filter((tool) => /^[a-zA-Z0-9_.-]+$/.test(tool))
84
+ : [],
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Classify an MCP server from its own manifest contract. New plugins declare
90
+ * `mcp` or `capabilities.kinds`; route/name/document signals retain backwards
91
+ * compatibility with manifests created before those fields existed.
92
+ */
93
+ export function isMcpServerPlugin(plugin) {
94
+ if (!plugin || typeof plugin !== "object") return false;
95
+ if (plugin.mcp === true || (plugin.mcp && typeof plugin.mcp === "object")) return true;
96
+ if ((plugin.capabilities?.kinds || []).some((kind) => ["mcp", "mcp-server"].includes(String(kind).toLowerCase()))) return true;
97
+
98
+ const routes = [
99
+ ...(Array.isArray(plugin.routes) ? plugin.routes : []),
100
+ ...(Array.isArray(plugin.surfaces) ? plugin.surfaces.flatMap((surface) => surface?.routes || []) : []),
101
+ ];
102
+ if (routes.some(routeDeclaresMcp)) return true;
103
+ if (hasMcpToken(plugin.id)) return true;
104
+ if ((plugin.surfaces || []).some((surface) => hasMcpToken(surface?.id) || hasMcpToken(surface?.name))) return true;
105
+ return hasMcpToken(plugin.documentation?.agent_context);
106
+ }
22
107
 
23
108
  export function getSupervisorPort() {
24
109
  return Number(process.env.CLAUTH_SUPERVISOR_PORT || DEFAULT_SUPERVISOR_PORT);
@@ -35,6 +120,53 @@ export function getClauthPm2Home() {
35
120
  return path.join(getSupervisorDir(), "pm2-home");
36
121
  }
37
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
+
38
170
  function file(name) {
39
171
  return path.join(getSupervisorDir(), name);
40
172
  }
@@ -48,9 +180,35 @@ function readJson(filePath, fallback) {
48
180
  }
49
181
  }
50
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.
51
210
  function writeJson(filePath, value) {
52
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
53
- fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
211
+ atomicWriteTextSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
54
212
  }
55
213
 
56
214
  function appendJsonl(filePath, value) {
@@ -73,15 +231,17 @@ function healthUrlForSurface(surface) {
73
231
  return `http://127.0.0.1:${surface.port}${String(surface.health).startsWith("/") ? surface.health : `/${surface.health}`}`;
74
232
  }
75
233
 
76
- function updateSurfaceState(surfaceId, patch) {
77
- const state = loadSupervisorState();
78
- state.surfaces = (state.surfaces || []).map((surface) => (
79
- `${surface.plugin_id}:${surface.id}` === surfaceId || surface.id === surfaceId
80
- ? { ...surface, ...patch }
81
- : surface
82
- ));
83
- saveSupervisorState(state);
84
- 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
+ });
85
245
  }
86
246
 
87
247
  function appendSupervisorEvent(event) {
@@ -123,19 +283,19 @@ export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, tim
123
283
  const error = health.error;
124
284
 
125
285
  if (healthy) {
126
- 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 });
127
287
  inspected.push({ surface_id: id, state: "healthy", observed_at: observedAt });
128
288
  continue;
129
289
  }
130
290
 
131
291
  const lastAttempt = Date.parse(surface.last_reconcile_at || "") || 0;
132
292
  if (Date.now() - lastAttempt < HEALTH_RECONCILE_COOLDOWN_MS) {
133
- 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 });
134
294
  inspected.push({ surface_id: id, state: "unavailable", error, cooldown: true, observed_at: observedAt });
135
295
  continue;
136
296
  }
137
297
 
138
- updateSurfaceState(id, {
298
+ await updateSurfaceState(id, {
139
299
  state: "unavailable",
140
300
  last_health_at: observedAt,
141
301
  last_health_ok: false,
@@ -147,7 +307,7 @@ export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, tim
147
307
  const commandCompleted = receipt?.resulting_state?.ok === true;
148
308
  const postHealth = commandCompleted ? await probeSurfaceHealth(url, fetchImpl, timeoutMs) : { healthy: false, error: receipt?.resulting_state?.state || "reconcile_failed" };
149
309
  const repaired = commandCompleted && postHealth.healthy;
150
- updateSurfaceState(id, {
310
+ await updateSurfaceState(id, {
151
311
  state: repaired ? "current" : "unavailable",
152
312
  last_health_at: now(),
153
313
  last_health_ok: repaired,
@@ -254,25 +414,41 @@ export async function probeAllSurfaceHealth({ fetchImpl = globalThis.fetch, time
254
414
  // load-modify-save per call, so N concurrent probes calling it would each
255
415
  // save a copy loaded before its siblings finished — last writer wins and the
256
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.
257
431
  const byId = new Map(probed.map((entry) => [entry.surface_id, entry]));
258
- const state = loadSupervisorState();
259
- state.surfaces = (state.surfaces || []).map((surface) => {
260
- const entry = byId.get(`${surface.plugin_id}:${surface.id}`);
261
- if (!entry) return surface;
262
- // last_health_url is the RESOLVED probe target, not the manifest's health
263
- // field. A remote surface carries a relative "/health" by contract, so the
264
- // manifest value alone cannot be opened in a browser only the resolved
265
- // one can, and it is the fallback when the Open root turns out to be dead.
266
- const next = { ...surface, last_open_url: entry.open_url, last_open_ok: entry.open_ok, last_health_url: entry.url };
267
- if (entry.health === "no_probe") return next;
268
- return {
269
- ...next,
270
- last_health_at: entry.observed_at,
271
- last_health_ok: entry.health === "healthy",
272
- last_health_error: entry.health === "healthy" ? null : entry.error,
273
- };
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);
274
451
  });
275
- saveSupervisorState(state);
276
452
  return { probed };
277
453
  }
278
454
 
@@ -389,7 +565,7 @@ function localhostHealth(pathOrUrl, port) {
389
565
  function normalizeSurface(surface, plugin) {
390
566
  if (!surface || typeof surface !== "object") throw new Error("surface must be an object");
391
567
  const id = String(surface.id || "").trim();
392
- 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");
393
569
  const destination = normalizeDestination(surface.destination || plugin.destination);
394
570
  // A remote surface names a service running somewhere else (Vultr/Coolify).
395
571
  // It is reached by URL and its port is the deployment registry's fact, not
@@ -432,6 +608,8 @@ function normalizeSurface(surface, plugin) {
432
608
  start: normalizeCommand(surface.start || plugin.start, "surface.start"),
433
609
  stop: normalizeCommand(surface.stop || plugin.stop, "surface.stop"),
434
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"),
435
613
  routes: Array.isArray(surface.routes) ? surface.routes : [],
436
614
  };
437
615
  }
@@ -440,7 +618,7 @@ export function validatePluginManifest(manifest, sourcePath = "") {
440
618
  if (!manifest || typeof manifest !== "object") throw new Error("manifest must be an object");
441
619
  if (manifest.schema !== SCHEMA) throw new Error(`schema must be ${SCHEMA}`);
442
620
  const id = String(manifest.id || "").trim();
443
- 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");
444
622
  const version = String(manifest.version || "").trim();
445
623
  if (!version) throw new Error("version is required");
446
624
  const plugin = {
@@ -454,6 +632,7 @@ export function validatePluginManifest(manifest, sourcePath = "") {
454
632
  core: manifest.core === true,
455
633
  enable_default: manifest.enable_default === true,
456
634
  sourcePath,
635
+ package_root: manifest._clauth?.package_root ? path.resolve(String(manifest._clauth.package_root)) : null,
457
636
  destination: normalizeDestination(manifest.destination),
458
637
  lifecycle_owner: normalizeLifecycleOwner(manifest.lifecycle_owner),
459
638
  credentials: Array.isArray(manifest.credentials) ? manifest.credentials.map((c) => ({
@@ -462,6 +641,8 @@ export function validatePluginManifest(manifest, sourcePath = "") {
462
641
  description: String(c.description || ""),
463
642
  required: c.required !== false,
464
643
  })).filter((c) => /^[a-zA-Z0-9_.-]+$/.test(c.name)) : [],
644
+ capabilities: normalizeCapabilities(manifest.capabilities),
645
+ mcp: normalizeMcpContract(manifest.mcp),
465
646
  surfaces: [],
466
647
  routes: Array.isArray(manifest.routes) ? manifest.routes : [],
467
648
  test: manifest.test && typeof manifest.test === "object" ? {
@@ -472,6 +653,7 @@ export function validatePluginManifest(manifest, sourcePath = "") {
472
653
  } : null,
473
654
  };
474
655
  plugin.surfaces = (Array.isArray(manifest.surfaces) ? manifest.surfaces : []).map((surface) => normalizeSurface(surface, plugin));
656
+ plugin.mcp_server = isMcpServerPlugin(plugin);
475
657
  return plugin;
476
658
  }
477
659
 
@@ -525,6 +707,143 @@ function saveSupervisorState(state) {
525
707
  });
526
708
  }
527
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
+
528
847
  function existingById(state) {
529
848
  return new Map((state.plugins || []).map((plugin) => [plugin.id, plugin]));
530
849
  }
@@ -635,7 +954,10 @@ export function registerPlugin(manifestPath, actor = "localhost") {
635
954
  .replace(/%PACKAGE_ROOT%/gi, packageRoot);
636
955
  let manifest;
637
956
  try {
638
- manifest = validatePluginManifest(JSON.parse(raw), manifestPath);
957
+ const parsed = JSON.parse(raw);
958
+ parsed._clauth = { package_root: packageRoot };
959
+ raw = `${JSON.stringify(parsed, null, 2)}\n`;
960
+ manifest = validatePluginManifest(parsed, manifestPath);
639
961
  } catch (error) {
640
962
  return operation("plugin.register", { manifest_path: manifestPath }, null, {
641
963
  ok: false, state: "manifest_invalid", error: error instanceof Error ? error.message : String(error),
@@ -668,11 +990,33 @@ export function registerPlugin(manifestPath, actor = "localhost") {
668
990
  }
669
991
  const discovery = discoverPlugins();
670
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
+
671
1014
  return operation("plugin.register", { manifest_path: manifestPath, plugin_id: manifest.id }, null, {
672
1015
  ok: Boolean(registered) && registered.state !== "manifest_invalid",
673
1016
  state: unchanged ? "unchanged" : "registered",
674
1017
  plugin_state: registered?.state || "not_found",
675
1018
  surfaces: registered?.surfaces?.map((surface) => surface.id) || [],
1019
+ restarted,
676
1020
  }, actor);
677
1021
  }
678
1022
 
@@ -693,12 +1037,26 @@ export function registerPlugin(manifestPath, actor = "localhost") {
693
1037
  // exist, or to cache what was found, that rebuilds the thing that was deleted —
694
1038
  // stop instead.
695
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.
696
1054
  const PRODUCT_REPO_MANIFESTS = [
697
1055
  { repo: "regen-root", manifest: "packages/codeflow/clauth-plugin.json" },
698
1056
  { repo: "regen-root", manifest: "apps/dev-center/clauth-plugin.json" },
1057
+ { repo: "regen-root", manifest: "apps/codeflow-explorer/clauth-plugin.json" },
699
1058
  { repo: "regen-root", manifest: "mcp-servers/regen-media/clauth-plugin.json" },
700
1059
  { repo: "regen-root", manifest: "mcp-servers/web-research/clauth-plugin.json" },
701
- { repo: "rdc-skills", manifest: "clauth-plugin.json" },
702
1060
  ];
703
1061
 
704
1062
  // Sweep outcomes that mean "nothing was there to sync", as distinct from
@@ -820,7 +1178,7 @@ export function syncPluginsFromRepos(repoRoots = {}, actor = "localhost") {
820
1178
  // reaching this function is attacker-influenced input (a CLI arg or an HTTP
821
1179
  // field, with no manifest validation upstream to lean on) and this deletes
822
1180
  // recursively, so both guards below are load-bearing.
823
- export function deregisterPlugin(id, actor = "localhost", { dryRun = false } = {}) {
1181
+ export function deregisterPlugin(id, actor = "localhost", { dryRun = false, expectedRoot = null, force = false } = {}) {
824
1182
  const pluginId = String(id ?? "").trim();
825
1183
  // Guard 1 — charset + all-dots, the same rule a manifest id must satisfy.
826
1184
  //
@@ -831,6 +1189,16 @@ export function deregisterPlugin(id, actor = "localhost", { dryRun = false } = {
831
1189
  // all resolve back INSIDE the root and sail through containment. Only the
832
1190
  // charset rule stops them. Relaxing it to admit a separator, a colon or a
833
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.
834
1202
  if (!/^[a-zA-Z0-9_.-]+$/.test(pluginId) || /^\.+$/.test(pluginId)) {
835
1203
  return operation("plugin.deregister", { plugin_id: pluginId }, null, {
836
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",
@@ -838,7 +1206,7 @@ export function deregisterPlugin(id, actor = "localhost", { dryRun = false } = {
838
1206
  }
839
1207
  const roots = rootEntries();
840
1208
  const managedRoots = roots.filter((entry) => entry.source === "managed");
841
- const prior = (loadSupervisorState().plugins || []).find((plugin) => plugin.id === pluginId) || null;
1209
+ let prior = (loadSupervisorState().plugins || []).find((plugin) => plugin.id === pluginId) || null;
842
1210
 
843
1211
  // Resolve the plugin's ACTUAL directory rather than assuming a flat
844
1212
  // <first-managed-root>/<id> layout. Three real layouts exist that assumption
@@ -899,6 +1267,26 @@ export function deregisterPlugin(id, actor = "localhost", { dryRun = false } = {
899
1267
  ok: false, state: "not_managed", error: "resolved plugin directory is not inside a managed plugin root",
900
1268
  }, actor);
901
1269
  }
1270
+ if (!prior) {
1271
+ prior = discoverPlugins().plugins.find((plugin) => plugin.id === pluginId) || null;
1272
+ }
1273
+ if (expectedRoot) {
1274
+ const expected = path.resolve(String(expectedRoot));
1275
+ if (!prior?.package_root) {
1276
+ return operation("plugin.deregister", { plugin_id: pluginId }, prior, {
1277
+ ok: false, state: "ownership_unverified", error: "registered plugin has no package-root ownership record; re-register it before deregistering",
1278
+ }, actor);
1279
+ }
1280
+ const actual = path.resolve(prior.package_root);
1281
+ const matches = process.platform === "win32"
1282
+ ? actual.toLowerCase() === expected.toLowerCase()
1283
+ : actual === expected;
1284
+ if (!matches) {
1285
+ return operation("plugin.deregister", { plugin_id: pluginId }, prior, {
1286
+ ok: false, state: "ownership_mismatch", error: `registered package root ${actual} does not match expected root ${expected}`,
1287
+ }, actor);
1288
+ }
1289
+ }
902
1290
  if (dryRun) {
903
1291
  return operation("plugin.deregister.dry_run", { plugin_id: pluginId }, prior, {
904
1292
  ok: true,
@@ -906,8 +1294,43 @@ export function deregisterPlugin(id, actor = "localhost", { dryRun = false } = {
906
1294
  target_dir: targetDir,
907
1295
  plugin_state: prior?.state || "unknown",
908
1296
  surfaces: (prior?.surfaces || []).map((surface) => surface.id),
1297
+ package_root: prior?.package_root || null,
909
1298
  }, actor);
910
1299
  }
1300
+
1301
+ const cleanup = [];
1302
+ const seenStopCommands = new Set();
1303
+ for (const surface of prior?.surfaces || []) {
1304
+ if (surface.lifecycle_owner !== "clauth" || surface.destination !== "local/clauth/pm2") continue;
1305
+ const signature = `${surface.cwd || ""}\0${JSON.stringify(surface.stop || [])}`;
1306
+ if (seenStopCommands.has(signature)) continue;
1307
+ seenStopCommands.add(signature);
1308
+ if (!Array.isArray(surface.stop) || surface.stop.length === 0) {
1309
+ const unavailable = { surface_id: surface.id, ok: false, state: "command_missing", forced: Boolean(force) };
1310
+ cleanup.push(unavailable);
1311
+ if (!force) {
1312
+ return operation("plugin.deregister", { plugin_id: pluginId }, prior, {
1313
+ ok: false, state: "surface_cleanup_unavailable", error: `managed surface ${surface.id} has no stop command`, cleanup,
1314
+ }, actor);
1315
+ }
1316
+ continue;
1317
+ }
1318
+ const stopReceipt = runSurfaceAction(`${pluginId}:${surface.id}`, "stop", actor);
1319
+ const summary = {
1320
+ surface_id: surface.id,
1321
+ ok: stopReceipt.resulting_state?.ok === true,
1322
+ state: stopReceipt.resulting_state?.state || stopReceipt.error || "unknown",
1323
+ };
1324
+ cleanup.push(summary);
1325
+ if (!summary.ok) {
1326
+ summary.forced = Boolean(force);
1327
+ if (!force) {
1328
+ return operation("plugin.deregister", { plugin_id: pluginId }, prior, {
1329
+ ok: false, state: "surface_cleanup_failed", error: `failed to stop managed surface ${surface.id}`, cleanup,
1330
+ }, actor);
1331
+ }
1332
+ }
1333
+ }
911
1334
  try {
912
1335
  fs.rmSync(targetDir, { recursive: true, force: true });
913
1336
  } catch (error) {
@@ -919,9 +1342,12 @@ export function deregisterPlugin(id, actor = "localhost", { dryRun = false } = {
919
1342
  const after = discovery.plugins.find((plugin) => plugin.id === pluginId);
920
1343
  return operation("plugin.deregister", { plugin_id: pluginId }, prior, {
921
1344
  ok: true,
922
- state: "deregistered",
1345
+ state: force && cleanup.some((entry) => !entry.ok) ? "deregistered_forced" : "deregistered",
923
1346
  plugin_state: after?.state || "not_found",
924
1347
  surfaces: (prior?.surfaces || []).map((surface) => surface.id),
1348
+ cleanup,
1349
+ forced: Boolean(force),
1350
+ evidence: force && cleanup.some((entry) => !entry.ok) ? ["force_cleanup_bypass=true"] : [],
925
1351
  }, actor);
926
1352
  }
927
1353
 
@@ -953,6 +1379,28 @@ export function readSupervisorEvents(limit = 100) {
953
1379
  });
954
1380
  }
955
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.
956
1404
  export function operation(action, target, prior, result, actor = "localhost") {
957
1405
  const receipt = {
958
1406
  operationId: crypto.randomUUID(),
@@ -995,18 +1443,6 @@ export function operation(action, target, prior, result, actor = "localhost") {
995
1443
  return receipt;
996
1444
  }
997
1445
 
998
- export function setPluginEnabled(id, enabled, actor = "localhost") {
999
- const state = loadSupervisorState();
1000
- const prior = (state.plugins || []).find((plugin) => plugin.id === id);
1001
- if (!prior) return { error: "plugin_not_found" };
1002
- if (prior.state === "manifest_invalid") return { error: "manifest_invalid" };
1003
- const nextPlugin = { ...prior, enabled: Boolean(enabled), state: enabled ? "current" : "awaiting_enable" };
1004
- state.plugins = state.plugins.map((plugin) => plugin.id === id ? nextPlugin : plugin);
1005
- state.surfaces = (state.surfaces || []).map((surface) => surface.plugin_id === id ? { ...surface, enabled: Boolean(enabled), state: nextPlugin.state } : surface);
1006
- saveSupervisorState(state);
1007
- return operation(enabled ? "enable" : "disable", { plugin_id: id }, prior, nextPlugin, actor);
1008
- }
1009
-
1010
1446
  export function runPluginAction(id, action, actor = "localhost") {
1011
1447
  if (!["test", "promote"].includes(action)) return { error: "invalid_action" };
1012
1448
  const state = loadSupervisorState();
@@ -1051,6 +1487,17 @@ export function runSurfaceAction(id, action, actor = "localhost") {
1051
1487
  if (surface.plugin_id === "codeflow" || surface.tags?.includes("codeflow")) {
1052
1488
  return operation(action, { surface_id: id }, surface, { ok: false, state: "observed_only", reason: "codeflow_self_owned" }, actor);
1053
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
+ }
1054
1501
  if (action === "test") {
1055
1502
  const port = surface.port === "auto" || !surface.port ? 0 : surface.port;
1056
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);
@@ -1058,19 +1505,12 @@ export function runSurfaceAction(id, action, actor = "localhost") {
1058
1505
  if (surface.lifecycle_owner === "plugin") {
1059
1506
  return operation(action, { surface_id: id }, surface, { ok: true, state: "delegated_to_plugin_adapter", evidence: ["plugin lifecycle owner retained"] }, actor);
1060
1507
  }
1061
- if (action === "promote" || action === "rollback") {
1062
- return operation(action, { surface_id: id }, surface, {
1063
- ok: false,
1064
- state: "unsupported_surface_action",
1065
- reason: `${action}_is_plugin_candidate_lifecycle`,
1066
- evidence: ["surface action did not execute a process command"],
1067
- }, actor);
1068
- }
1069
1508
  let command = null;
1070
1509
  if (action === "stop") command = surface.stop;
1071
1510
  else if (action === "start") command = surface.start;
1072
1511
  else if (action === "restart") command = surface.restart;
1073
1512
  else if (action === "reconcile") command = surface.enabled === false ? surface.stop : (surface.restart || surface.start);
1513
+ else if (action === "rollback") command = surface.rollback;
1074
1514
  if (!command || command.length === 0) {
1075
1515
  return operation(action, { surface_id: id }, surface, { ok: false, state: "command_missing" }, actor);
1076
1516
  }
@@ -1116,6 +1556,151 @@ export function runSurfaceAction(id, action, actor = "localhost") {
1116
1556
  }, actor);
1117
1557
  }
1118
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
+
1119
1704
  export function addTunnelRoute(tunnelId, route, actor = "localhost") {
1120
1705
  const tunnels = listTunnels();
1121
1706
  const tunnel = tunnels.find((item) => item.id === tunnelId);