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