@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.
- package/.clauth-skill/references/operator-guide.md +2 -12
- package/cli/commands/serve.js +204 -15
- package/cli/http/components/callagent-terminal-component.js +5 -5
- package/cli/http/components/mcp-transport-component.js +10 -17
- package/cli/http/components/oauth-component.js +74 -40
- package/cli/http/components/supervisor-component.js +18 -14
- package/cli/http/components/test-static-oauth-component.js +233 -0
- package/cli/index.js +9 -17
- package/cli/services/file-io.js +21 -3
- package/cli/services/file-io.test.js +33 -1
- package/cli/supervisor-registry.js +644 -59
- package/cli/supervisor-registry.test.js +568 -28
- package/cli/watchdog-registry.js +9 -0
- package/package.json +2 -1
- 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 +38 -49
- package/supabase/migrations/004_remove_audit_and_rate_limiting.sql +29 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|