@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,32 +3,37 @@ import fs from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
6
8
|
|
|
7
9
|
import {
|
|
8
10
|
addTunnelRoute,
|
|
9
11
|
deregisterPlugin,
|
|
10
12
|
discoverPlugins,
|
|
11
13
|
getClauthPm2Home,
|
|
14
|
+
isMcpServerPlugin,
|
|
12
15
|
listPlugins,
|
|
13
16
|
listSurfaces,
|
|
14
17
|
probeAllSurfaceHealth,
|
|
15
18
|
surfaceOpenUrl,
|
|
19
|
+
isGenuinelyIsolatedInstance,
|
|
16
20
|
reconcileSurfaceHealth,
|
|
17
21
|
registerPlugin,
|
|
22
|
+
resolveIsolatedSupervisorDir,
|
|
18
23
|
runPluginAction,
|
|
19
24
|
runSurfaceAction,
|
|
20
25
|
removeTunnelRoute,
|
|
21
|
-
setPluginEnabled,
|
|
22
26
|
shellQuote,
|
|
23
27
|
supervisorHealth,
|
|
24
28
|
syncPluginsFromRepos,
|
|
25
29
|
SYNC_REPO_NAMES,
|
|
26
30
|
SYNC_SKIP_STATES,
|
|
27
31
|
validatePluginManifest,
|
|
32
|
+
withStateLock,
|
|
28
33
|
} from "./supervisor-registry.js";
|
|
29
34
|
import { isLoopbackAddress, supervisorLogDto, supervisorRequiresWriteToken } from "./commands/serve.js";
|
|
30
35
|
|
|
31
|
-
function withTempSupervisor(fn) {
|
|
36
|
+
async function withTempSupervisor(fn) {
|
|
32
37
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-"));
|
|
33
38
|
const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
|
|
34
39
|
const oldManaged = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
@@ -45,7 +50,7 @@ function withTempSupervisor(fn) {
|
|
|
45
50
|
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user-plugins");
|
|
46
51
|
delete process.env.CLAUTH_PM2_HOME;
|
|
47
52
|
try {
|
|
48
|
-
return fn(root);
|
|
53
|
+
return await fn(root);
|
|
49
54
|
} finally {
|
|
50
55
|
if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
|
|
51
56
|
else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
|
|
@@ -88,6 +93,7 @@ function baseManifest(id, overrides = {}) {
|
|
|
88
93
|
lifecycle_owner: "clauth",
|
|
89
94
|
port: 39111,
|
|
90
95
|
health: "/health",
|
|
96
|
+
stop: [process.execPath, "--version"],
|
|
91
97
|
restart: ["node", "--version"],
|
|
92
98
|
}],
|
|
93
99
|
test: { command: ["node", "--version"], port: "auto", health: "/health", selfTest: [["node", "--version"]] },
|
|
@@ -111,6 +117,50 @@ test("validatePluginManifest accepts LIFEAI plugin contract with isolated test c
|
|
|
111
117
|
assert.equal(plugin.documentation.operator_guide, "docs/systems/example/OPERATE.md");
|
|
112
118
|
});
|
|
113
119
|
|
|
120
|
+
test("MCP-server classification is derived from manifest capabilities and legacy contract signals", () => {
|
|
121
|
+
const rtp = validatePluginManifest(baseManifest("rtp", {
|
|
122
|
+
capabilities: { kinds: ["cli", "http-service", "mcp-server"] },
|
|
123
|
+
mcp: {
|
|
124
|
+
transport: "http+stdio",
|
|
125
|
+
url: "http://127.0.0.1:3116/mcp",
|
|
126
|
+
stdio: ["node", "bin/rtp.mjs", "mcp"],
|
|
127
|
+
tools: ["rtp_query", "rtp_parse"],
|
|
128
|
+
},
|
|
129
|
+
}));
|
|
130
|
+
assert.equal(isMcpServerPlugin(rtp), true);
|
|
131
|
+
assert.deepEqual(rtp.capabilities.kinds, ["cli", "http-service", "mcp-server"]);
|
|
132
|
+
assert.deepEqual(rtp.mcp.tools, ["rtp_query", "rtp_parse"]);
|
|
133
|
+
|
|
134
|
+
const legacyMcpManifests = [
|
|
135
|
+
baseManifest("codeflow-mcp"),
|
|
136
|
+
baseManifest("fs-mcp"),
|
|
137
|
+
baseManifest("rdc-skills", {
|
|
138
|
+
routes: [{ id: "provider", kind: "external", url: "https://rdc-skills.example/mcp" }],
|
|
139
|
+
}),
|
|
140
|
+
baseManifest("regen-media", {
|
|
141
|
+
routes: [{ id: "provider", kind: "external", url: "https://media.example/mcp" }],
|
|
142
|
+
}),
|
|
143
|
+
baseManifest("web-research", {
|
|
144
|
+
documentation: {
|
|
145
|
+
architecture: "ARCHITECTURE.md",
|
|
146
|
+
agent_context: ".claude/context/web-research-mcp.md",
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
baseManifest("regen-media-local", {
|
|
150
|
+
documentation: {
|
|
151
|
+
architecture: "ARCHITECTURE.md",
|
|
152
|
+
agent_context: ".claude/context/mcp-endpoint-design.md",
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
];
|
|
156
|
+
for (const manifest of legacyMcpManifests) {
|
|
157
|
+
assert.equal(isMcpServerPlugin(validatePluginManifest(manifest)), true, manifest.id);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
assert.equal(isMcpServerPlugin(validatePluginManifest(baseManifest("dev-center"))), false);
|
|
161
|
+
assert.equal(isMcpServerPlugin(validatePluginManifest(baseManifest("factory-test-plugin"))), false);
|
|
162
|
+
});
|
|
163
|
+
|
|
114
164
|
test("validatePluginManifest accepts empty test command arrays from the v1 template", () => {
|
|
115
165
|
const plugin = validatePluginManifest(baseManifest("empty-test-command", {
|
|
116
166
|
test: { command: [], port: "auto", health: "/health", selfTest: [] },
|
|
@@ -246,8 +296,6 @@ test("plugin test marks a private candidate and never creates a public route", (
|
|
|
246
296
|
writePlugin(root, "managed", "regen-media-local", baseManifest("regen-media-local"));
|
|
247
297
|
discoverPlugins();
|
|
248
298
|
|
|
249
|
-
const enabled = setPluginEnabled("regen-media-local", true);
|
|
250
|
-
assert.equal(enabled.resulting_state.enabled, true);
|
|
251
299
|
const receipt = runPluginAction("regen-media-local", "test");
|
|
252
300
|
assert.equal(receipt.resulting_state.state, "candidate_testing");
|
|
253
301
|
assert.equal(receipt.resulting_state.public_route, false);
|
|
@@ -308,7 +356,7 @@ test("reconcile falls back to the declared start command when restart reports a
|
|
|
308
356
|
assert.equal(receipt.resulting_state.evidence.includes("reconcile_start_fallback=true"), true);
|
|
309
357
|
}));
|
|
310
358
|
|
|
311
|
-
test("surface
|
|
359
|
+
test("surface promotion requires the atomic health-checked path and rolls back on failed health", async () => withTempSupervisor(async (root) => {
|
|
312
360
|
const managed = path.join(root, "managed");
|
|
313
361
|
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
314
362
|
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
@@ -318,16 +366,132 @@ test("surface promote and rollback never fall through to restart commands", () =
|
|
|
318
366
|
name: "Demo surface",
|
|
319
367
|
health: "http://127.0.0.1:3333/health",
|
|
320
368
|
restart: [process.execPath, "--version"],
|
|
369
|
+
promote: [process.execPath, "--version"],
|
|
370
|
+
rollback: [process.execPath, "--version"],
|
|
321
371
|
}],
|
|
322
372
|
}));
|
|
323
373
|
discoverPlugins();
|
|
324
374
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
}
|
|
375
|
+
const direct = runSurfaceAction("demo:demo-surface", "promote");
|
|
376
|
+
assert.equal(direct.resulting_state.state, "atomic_promotion_required");
|
|
377
|
+
const { runSurfacePromotion } = await import("./supervisor-registry.js");
|
|
378
|
+
const healthy = await runSurfacePromotion("demo:demo-surface", "test", { fetchImpl: async () => ({ ok: true }) });
|
|
379
|
+
assert.equal(healthy.resulting_state.state, "promotion_healthy");
|
|
380
|
+
const rolledBack = await runSurfacePromotion("demo:demo-surface", "test", { fetchImpl: async () => ({ ok: false, status: 503 }) });
|
|
381
|
+
assert.equal(rolledBack.resulting_state.state, "promotion_rolled_back");
|
|
382
|
+
assert.equal(rolledBack.resulting_state.rollback_ok, true);
|
|
383
|
+
}));
|
|
384
|
+
|
|
385
|
+
// rdc:review finding (2026-09-02): a healthy promotion always reported
|
|
386
|
+
// evidence "plugin_enabled=true" even when no plugin in state.json matched
|
|
387
|
+
// surface.plugin_id -- the enabled write silently no-op'd (Array.map found no
|
|
388
|
+
// match) while the receipt claimed success. Prove the receipt now tells the
|
|
389
|
+
// truth in that case, and still reports the honest positive when a match
|
|
390
|
+
// exists.
|
|
391
|
+
test("surface promotion reports honestly whether the enabled write actually matched a plugin in state", () => withTempSupervisor(async (root) => {
|
|
392
|
+
const managed = path.join(root, "managed");
|
|
393
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
394
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
395
|
+
writePlugin(root, "managed", "demo", baseManifest("demo", {
|
|
396
|
+
surfaces: [{
|
|
397
|
+
id: "demo-surface",
|
|
398
|
+
name: "Demo surface",
|
|
399
|
+
health: "http://127.0.0.1:3333/health",
|
|
400
|
+
restart: [process.execPath, "--version"],
|
|
401
|
+
promote: [process.execPath, "--version"],
|
|
402
|
+
rollback: [process.execPath, "--version"],
|
|
403
|
+
}],
|
|
404
|
+
}));
|
|
405
|
+
discoverPlugins();
|
|
406
|
+
const { runSurfacePromotion } = await import("./supervisor-registry.js");
|
|
407
|
+
|
|
408
|
+
const matched = await runSurfacePromotion("demo:demo-surface", "test", { fetchImpl: async () => ({ ok: true }) });
|
|
409
|
+
assert.equal(matched.resulting_state.state, "promotion_healthy");
|
|
410
|
+
assert.equal(matched.resulting_state.evidence.includes("plugin_enabled=true"), true);
|
|
411
|
+
|
|
412
|
+
// Remove the plugin from state.json entirely, out from under the surface
|
|
413
|
+
// that still resolves fine via the manifest/registry -- reproduces the gap:
|
|
414
|
+
// promote can run against a surface whose plugin no longer has a state.json
|
|
415
|
+
// row (e.g. removed by a concurrent deregister).
|
|
416
|
+
const statePath = path.join(process.env.CLAUTH_SUPERVISOR_DIR, "state.json");
|
|
417
|
+
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
418
|
+
state.plugins = (state.plugins || []).filter((p) => p.id !== "demo");
|
|
419
|
+
fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
|
|
420
|
+
|
|
421
|
+
const unmatched = await runSurfacePromotion("demo:demo-surface", "test", { fetchImpl: async () => ({ ok: true }) });
|
|
422
|
+
assert.equal(unmatched.resulting_state.state, "promotion_healthy");
|
|
423
|
+
assert.equal(unmatched.resulting_state.evidence.includes("plugin_enabled=false plugin_not_found_in_state"), true);
|
|
424
|
+
assert.equal(unmatched.resulting_state.evidence.includes("plugin_enabled=true"), false);
|
|
425
|
+
}));
|
|
426
|
+
|
|
427
|
+
// rdc:review finding (2026-09-02), second pass -- CONFIRMED regression in the
|
|
428
|
+
// first version of the withStateLock fix: runSurfacePromotion snapshotted the
|
|
429
|
+
// whole `surfaces` array once at function entry and wrote it back WHOLESALE
|
|
430
|
+
// on its pre-action swap and post-action revert. A concurrent write to a
|
|
431
|
+
// DIFFERENT surface (e.g. reconcileSurfaceHealth's health update) landing
|
|
432
|
+
// during promotion's own await gap (the health probe) was silently
|
|
433
|
+
// discarded the moment promotion's revert-write landed -- reopening, in a
|
|
434
|
+
// different shape, the exact class of race withStateLock was written to
|
|
435
|
+
// close. Fixed by routing every surface mutation in runSurfacePromotion
|
|
436
|
+
// through updateSurfaceState() (a single-entity merge-patch) instead of a
|
|
437
|
+
// whole-array snapshot/restore. This test reproduces the exact interleaving:
|
|
438
|
+
// a promotion paused mid-health-probe, a concurrent reconcile completing a
|
|
439
|
+
// write to an UNRELATED surface during that pause, then the promotion
|
|
440
|
+
// finishing -- and asserts the concurrent write survives.
|
|
441
|
+
test("surface promotion does not clobber a concurrent write to a DIFFERENT surface made during its health-probe pause", () => withTempSupervisor(async (root) => {
|
|
442
|
+
const managed = path.join(root, "managed");
|
|
443
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
444
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
445
|
+
writePlugin(root, "managed", "demo", baseManifest("demo", {
|
|
446
|
+
core: true,
|
|
447
|
+
enable_default: true,
|
|
448
|
+
surfaces: [
|
|
449
|
+
{
|
|
450
|
+
id: "demo-surface",
|
|
451
|
+
name: "Demo surface",
|
|
452
|
+
health: "http://127.0.0.1:3333/health",
|
|
453
|
+
restart: [process.execPath, "--version"],
|
|
454
|
+
promote: [process.execPath, "--version"],
|
|
455
|
+
rollback: [process.execPath, "--version"],
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
id: "other-surface",
|
|
459
|
+
name: "Other surface",
|
|
460
|
+
destination: "local/clauth/pm2",
|
|
461
|
+
lifecycle_owner: "clauth",
|
|
462
|
+
port: 39115,
|
|
463
|
+
health: "/health",
|
|
464
|
+
restart: [process.execPath, "--version"],
|
|
465
|
+
},
|
|
466
|
+
],
|
|
467
|
+
}));
|
|
468
|
+
discoverPlugins();
|
|
469
|
+
const { runSurfacePromotion } = await import("./supervisor-registry.js");
|
|
470
|
+
|
|
471
|
+
let releasePromotionHealthGate;
|
|
472
|
+
const promotionHealthGate = new Promise((resolve) => { releasePromotionHealthGate = resolve; });
|
|
473
|
+
const promotionPromise = runSurfacePromotion("demo:demo-surface", "test", {
|
|
474
|
+
fetchImpl: async () => { await promotionHealthGate; return { ok: true }; },
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Give the promotion's own microtask chain room to reach its health-probe
|
|
478
|
+
// await (its pre-action swap + runSurfaceAction already completed
|
|
479
|
+
// synchronously by this point; it is now genuinely blocked on the gate).
|
|
480
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
481
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
482
|
+
|
|
483
|
+
// Concurrent write to a DIFFERENT surface, completing entirely while the
|
|
484
|
+
// promotion above is still paused.
|
|
485
|
+
await reconcileSurfaceHealth({ fetchImpl: async () => ({ ok: true }) });
|
|
486
|
+
const otherAfterReconcile = listSurfaces().find((s) => s.id === "other-surface");
|
|
487
|
+
assert.equal(otherAfterReconcile.last_health_ok, true, "reconcile's own write should have landed before the promotion resumes");
|
|
488
|
+
|
|
489
|
+
releasePromotionHealthGate();
|
|
490
|
+
const result = await promotionPromise;
|
|
491
|
+
assert.equal(result.resulting_state.state, "promotion_healthy");
|
|
492
|
+
|
|
493
|
+
const otherAfterPromotion = listSurfaces().find((s) => s.id === "other-surface");
|
|
494
|
+
assert.equal(otherAfterPromotion.last_health_ok, true, "promotion's own surface writes must not revert a concurrent write to a different surface");
|
|
331
495
|
}));
|
|
332
496
|
|
|
333
497
|
test("health reconciliation marks a failed clauth surface and repairs it through reconcile", async () => {
|
|
@@ -625,6 +789,7 @@ test("registerPlugin validates, writes into the managed root, and discovers the
|
|
|
625
789
|
assert.equal(written, true);
|
|
626
790
|
const found = listPlugins().find((plugin) => plugin.id === "registered-demo");
|
|
627
791
|
assert.equal(found.enabled, true);
|
|
792
|
+
assert.equal(found.package_root, path.resolve(sourceDir));
|
|
628
793
|
|
|
629
794
|
const second = registerPlugin(manifestPath, "test");
|
|
630
795
|
assert.equal(second.resulting_state.state, "unchanged", "re-registering identical content must be a no-op, not a rewrite");
|
|
@@ -632,6 +797,37 @@ test("registerPlugin validates, writes into the managed root, and discovers the
|
|
|
632
797
|
fs.rmSync(sourceDir, { recursive: true, force: true });
|
|
633
798
|
}));
|
|
634
799
|
|
|
800
|
+
test("registerPlugin restarts a clauth-owned surface when the manifest actually changed, and skips the restart when unchanged", () => withTempSupervisor((root) => {
|
|
801
|
+
// Dave: "plugin install pings clauth to reread -- it should restart the pm2
|
|
802
|
+
// -- fix the bug". A changed manifest (a version bump, a fresh npm install)
|
|
803
|
+
// used to only re-run discovery -- the live PM2 process kept serving the
|
|
804
|
+
// OLD code until something unrelated happened to restart it.
|
|
805
|
+
const managed = path.join(root, "managed");
|
|
806
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
807
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
808
|
+
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-register-restart-"));
|
|
809
|
+
const manifestPath = path.join(sourceDir, "clauth-plugin.json");
|
|
810
|
+
fs.writeFileSync(manifestPath, JSON.stringify(baseManifest("restart-on-change-demo")), "utf8");
|
|
811
|
+
|
|
812
|
+
const first = registerPlugin(manifestPath, "test");
|
|
813
|
+
assert.equal(first.resulting_state.state, "registered");
|
|
814
|
+
assert.equal(first.resulting_state.restarted.length, 1, "a fresh registration must attempt to restart its clauth-owned surface");
|
|
815
|
+
assert.equal(first.resulting_state.restarted[0].surface_id, "restart-on-change-demo-surface");
|
|
816
|
+
assert.equal(first.resulting_state.restarted[0].ok, true, "the fixture's restart command (node --version) must succeed");
|
|
817
|
+
|
|
818
|
+
const unchanged = registerPlugin(manifestPath, "test");
|
|
819
|
+
assert.equal(unchanged.resulting_state.state, "unchanged");
|
|
820
|
+
assert.equal(unchanged.resulting_state.restarted.length, 0, "re-registering identical content must NOT restart a live service for nothing");
|
|
821
|
+
|
|
822
|
+
fs.writeFileSync(manifestPath, JSON.stringify(baseManifest("restart-on-change-demo", { version: "1.0.1" })), "utf8");
|
|
823
|
+
const changed = registerPlugin(manifestPath, "test");
|
|
824
|
+
assert.equal(changed.resulting_state.state, "registered");
|
|
825
|
+
assert.equal(changed.resulting_state.restarted.length, 1, "a genuine content change must restart the surface again");
|
|
826
|
+
assert.equal(changed.resulting_state.restarted[0].ok, true);
|
|
827
|
+
|
|
828
|
+
fs.rmSync(sourceDir, { recursive: true, force: true });
|
|
829
|
+
}));
|
|
830
|
+
|
|
635
831
|
test("registerPlugin rejects a plugin id that would escape the managed-plugins root", () => withTempSupervisor((root) => {
|
|
636
832
|
// Code-review finding (confidence 95, live PoC): manifest.id of ".." passed
|
|
637
833
|
// the old id regex (dot is in the allowed character class with no
|
|
@@ -663,6 +859,75 @@ test("validatePluginManifest rejects an all-dots plugin or surface id", () => {
|
|
|
663
859
|
}), "clauth-plugin.json"), /may not be all dots/);
|
|
664
860
|
});
|
|
665
861
|
|
|
862
|
+
// rdc:review finding (2026-09-02): the charset regex validatePluginManifest()
|
|
863
|
+
// uses is not sufficient on Windows -- CON/NUL/AUX/PRN/COM1-9/LPT1-9 pass the
|
|
864
|
+
// charset check but are OS-reserved device names, so mkdirSync/writeFileSync
|
|
865
|
+
// against a path ending in one throws or targets the device instead of a real
|
|
866
|
+
// directory. Covers both call sites inside validatePluginManifest (manifest.id
|
|
867
|
+
// via normalizePlugin, surface.id via normalizeSurface) plus deregisterPlugin's
|
|
868
|
+
// own duplicated id check, all three of which are meant to be kept in lockstep.
|
|
869
|
+
// rdc:review finding (2026-09-02): the two RESERVED_DEVICE_NAMES copies
|
|
870
|
+
// (here, and standalone/install-clauth-plugin.mjs) are asserted "kept in
|
|
871
|
+
// sync deliberately" by comment alone -- no test compared the two literal
|
|
872
|
+
// regex sources, so a future edit to one gives no mechanical signal the
|
|
873
|
+
// other also needs it. Reads both files as TEXT (not import -- the
|
|
874
|
+
// standalone script does real fs/network work at module-load time from its
|
|
875
|
+
// own cwd, which a test must not trigger) and compares the literal regex
|
|
876
|
+
// source string.
|
|
877
|
+
test("RESERVED_DEVICE_NAMES stays byte-identical between supervisor-registry.js and the standalone installer", () => {
|
|
878
|
+
const extract = (filePath) => {
|
|
879
|
+
const src = fs.readFileSync(filePath, "utf8");
|
|
880
|
+
const match = src.match(/const RESERVED_DEVICE_NAMES = (\/.*\/i);/);
|
|
881
|
+
assert.ok(match, `RESERVED_DEVICE_NAMES declaration not found in ${filePath}`);
|
|
882
|
+
return match[1];
|
|
883
|
+
};
|
|
884
|
+
const inRegistry = extract(path.join(process.cwd(), "cli", "supervisor-registry.js"));
|
|
885
|
+
const inInstaller = extract(path.join(process.cwd(), "standalone", "install-clauth-plugin.mjs"));
|
|
886
|
+
assert.equal(inRegistry, inInstaller, "the two RESERVED_DEVICE_NAMES copies have drifted apart");
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
test("validatePluginManifest rejects Windows-reserved device names, case-insensitively, at registration", () => {
|
|
890
|
+
for (const reserved of ["CON", "nul", "Aux", "prn", "COM1", "lpt9"]) {
|
|
891
|
+
assert.throws(
|
|
892
|
+
() => validatePluginManifest(baseManifest(reserved, {}), "clauth-plugin.json"),
|
|
893
|
+
/reserved device name/,
|
|
894
|
+
`manifest.id=${reserved} should be rejected`,
|
|
895
|
+
);
|
|
896
|
+
assert.throws(
|
|
897
|
+
() => validatePluginManifest(baseManifest("valid-id", {
|
|
898
|
+
surfaces: [{ id: reserved, lifecycle_owner: "clauth" }],
|
|
899
|
+
}), "clauth-plugin.json"),
|
|
900
|
+
/reserved device name/,
|
|
901
|
+
`surface.id=${reserved} should be rejected`,
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
// Names that merely CONTAIN a reserved token are fine -- only an exact
|
|
905
|
+
// (case-insensitive) match to the whole id is a real device name.
|
|
906
|
+
assert.doesNotThrow(() => validatePluginManifest(baseManifest("nully-plugin", {}), "clauth-plugin.json"));
|
|
907
|
+
assert.doesNotThrow(() => validatePluginManifest(baseManifest("console-app", {}), "clauth-plugin.json"));
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
// rdc:review finding (2026-09-02), confirmed regression + fix: the first
|
|
911
|
+
// version of this check also rejected reserved-name ids in deregisterPlugin's
|
|
912
|
+
// own Guard 1 -- meaning a plugin that somehow got registered with such an id
|
|
913
|
+
// (pre-upgrade, or by any other path) could never be removed again, under
|
|
914
|
+
// force:true included, with no remediation. Live-probed the original crash
|
|
915
|
+
// premise directly on this host (Node fs.mkdirSync/writeFileSync for
|
|
916
|
+
// directories literally named con/nul/aux/prn/com1/lpt1 all succeeded, no
|
|
917
|
+
// throw, no device redirection) -- the reserved-name check is retained as
|
|
918
|
+
// low-cost defensive hygiene against NEW registrations only; removal must
|
|
919
|
+
// never be blocked by it.
|
|
920
|
+
test("deregisterPlugin does not reject a Windows-reserved-device-name id -- removal is never blocked by this check", () => withTempSupervisor((root) => {
|
|
921
|
+
const managed = path.join(root, "managed");
|
|
922
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
923
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
924
|
+
const receipt = deregisterPlugin("NUL", "test");
|
|
925
|
+
assert.notEqual(receipt.resulting_state.state, "invalid_plugin_id");
|
|
926
|
+
// No such plugin is registered in this fixture -- safe no-op, same
|
|
927
|
+
// contract as "deregisterPlugin on an unregistered id is a safe no-op".
|
|
928
|
+
assert.equal(receipt.resulting_state.ok, true);
|
|
929
|
+
}));
|
|
930
|
+
|
|
666
931
|
test("registerPlugin rejects an invalid manifest without writing anything", () => withTempSupervisor((root) => {
|
|
667
932
|
const managed = path.join(root, "managed");
|
|
668
933
|
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
@@ -679,8 +944,16 @@ test("registerPlugin rejects an invalid manifest without writing anything", () =
|
|
|
679
944
|
fs.rmSync(sourceDir, { recursive: true, force: true });
|
|
680
945
|
}));
|
|
681
946
|
|
|
682
|
-
// Builds throwaway product
|
|
947
|
+
// Builds a throwaway product repo laid out like the real one, so a sync sweep
|
|
683
948
|
// exercises the real relative manifest paths without reading a live checkout.
|
|
949
|
+
//
|
|
950
|
+
// rdc-skills is deliberately NOT part of PRODUCT_REPO_MANIFESTS (and so not
|
|
951
|
+
// part of `roots` below) -- it is npm-published and self-registers via its
|
|
952
|
+
// own postinstall against the INSTALLED package, never via this checkout
|
|
953
|
+
// sweep (see the comment on PRODUCT_REPO_MANIFESTS). `rdcSkills` is still
|
|
954
|
+
// returned as a plain unrelated directory: several tests below use it purely
|
|
955
|
+
// as a stand-in for "some directory that is not a known repo name", to prove
|
|
956
|
+
// an unrecognized override is reported rather than silently dropped.
|
|
684
957
|
function withTempProductRepos(fn) {
|
|
685
958
|
const base = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-sync-repos-"));
|
|
686
959
|
const regenRoot = path.join(base, "regen-root");
|
|
@@ -693,11 +966,12 @@ function withTempProductRepos(fn) {
|
|
|
693
966
|
};
|
|
694
967
|
writeManifest(regenRoot, "packages/codeflow/clauth-plugin.json", baseManifest("codeflow-mcp"));
|
|
695
968
|
writeManifest(regenRoot, "apps/dev-center/clauth-plugin.json", baseManifest("dev-center"));
|
|
969
|
+
writeManifest(regenRoot, "apps/codeflow-explorer/clauth-plugin.json", baseManifest("codeflow-explorer"));
|
|
696
970
|
writeManifest(regenRoot, "mcp-servers/regen-media/clauth-plugin.json", baseManifest("regen-media"));
|
|
697
971
|
writeManifest(regenRoot, "mcp-servers/web-research/clauth-plugin.json", baseManifest("web-research"));
|
|
698
972
|
writeManifest(rdcSkills, "clauth-plugin.json", baseManifest("rdc-skills"));
|
|
699
973
|
try {
|
|
700
|
-
return fn({ base, regenRoot, rdcSkills, writeManifest, roots: { "regen-root": regenRoot
|
|
974
|
+
return fn({ base, regenRoot, rdcSkills, writeManifest, roots: { "regen-root": regenRoot } });
|
|
701
975
|
} finally {
|
|
702
976
|
fs.rmSync(base, { recursive: true, force: true });
|
|
703
977
|
}
|
|
@@ -720,22 +994,19 @@ test("plugin sync inherits registerPlugin idempotence — a second sweep reports
|
|
|
720
994
|
assert.equal(second.every((entry) => entry.ok && entry.state === "unchanged"), true, "re-sweeping identical content must be a no-op");
|
|
721
995
|
})));
|
|
722
996
|
|
|
723
|
-
test("plugin sync warns and continues over a missing repo root instead of throwing", () => withTempSupervisor(() => withTempProductRepos(({ base
|
|
724
|
-
// A box that never checked out regen-root must still
|
|
997
|
+
test("plugin sync warns and continues over a missing repo root instead of throwing", () => withTempSupervisor(() => withTempProductRepos(({ base }) => {
|
|
998
|
+
// A box that never checked out regen-root must still return a receipt per
|
|
999
|
+
// manifest instead of throwing.
|
|
725
1000
|
const absent = path.join(base, "no-such-checkout");
|
|
726
1001
|
assert.equal(fs.existsSync(absent), false);
|
|
727
1002
|
let receipts;
|
|
728
1003
|
assert.doesNotThrow(() => {
|
|
729
|
-
receipts = syncPluginsFromRepos({ "regen-root": absent
|
|
1004
|
+
receipts = syncPluginsFromRepos({ "regen-root": absent }, "test");
|
|
730
1005
|
});
|
|
731
1006
|
assert.equal(receipts.length, 5, "a skipped repo still yields a receipt per attempted manifest");
|
|
732
1007
|
const missing = receipts.filter((entry) => entry.state === "repo_root_missing");
|
|
733
|
-
assert.equal(missing.length,
|
|
1008
|
+
assert.equal(missing.length, 5, "all five regen-root manifests report the missing root");
|
|
734
1009
|
assert.equal(missing.every((entry) => entry.ok === false), true);
|
|
735
|
-
const skills = receipts.find((entry) => entry.repo === "rdc-skills");
|
|
736
|
-
assert.equal(skills.ok, true);
|
|
737
|
-
assert.equal(skills.state, "registered");
|
|
738
|
-
assert.ok(listPlugins().find((plugin) => plugin.id === "rdc-skills"), "the reachable repo still registered");
|
|
739
1010
|
})));
|
|
740
1011
|
|
|
741
1012
|
test("plugin sync registers the remaining manifests when one is malformed", () => withTempSupervisor(() => withTempProductRepos(({ regenRoot, roots, writeManifest }) => {
|
|
@@ -750,7 +1021,7 @@ test("plugin sync registers the remaining manifests when one is malformed", () =
|
|
|
750
1021
|
assert.equal(good.length, 4);
|
|
751
1022
|
assert.equal(good.every((entry) => entry.ok && entry.state === "registered"), true, "one bad manifest must not abort the sweep");
|
|
752
1023
|
const ids = new Set(listPlugins().map((plugin) => plugin.id));
|
|
753
|
-
for (const id of ["codeflow-mcp", "
|
|
1024
|
+
for (const id of ["codeflow-mcp", "codeflow-explorer", "regen-media", "web-research"]) {
|
|
754
1025
|
assert.ok(ids.has(id), `${id} must still be registered`);
|
|
755
1026
|
}
|
|
756
1027
|
})));
|
|
@@ -763,9 +1034,11 @@ test("deregisterPlugin removes only the named plugin and leaves siblings intact"
|
|
|
763
1034
|
const receipt = deregisterPlugin("web-research", "test");
|
|
764
1035
|
assert.equal(receipt.resulting_state.ok, true);
|
|
765
1036
|
assert.equal(receipt.resulting_state.state, "deregistered");
|
|
1037
|
+
assert.equal(receipt.resulting_state.cleanup.length, 1);
|
|
1038
|
+
assert.equal(receipt.resulting_state.cleanup[0].ok, true);
|
|
766
1039
|
assert.equal(fs.existsSync(path.join(managed, "web-research")), false, "the named plugin directory is gone");
|
|
767
1040
|
|
|
768
|
-
for (const sibling of ["codeflow-mcp", "dev-center", "
|
|
1041
|
+
for (const sibling of ["codeflow-mcp", "dev-center", "codeflow-explorer", "regen-media"]) {
|
|
769
1042
|
assert.equal(fs.existsSync(path.join(managed, sibling)), true, `${sibling} must survive`);
|
|
770
1043
|
}
|
|
771
1044
|
// discovery re-ran, so the removed managed plugin is reported missing, not current
|
|
@@ -887,6 +1160,63 @@ test("deregisterPlugin removes a scoped @scope/pkg plugin instead of falsely rep
|
|
|
887
1160
|
assert.equal(after.state, "missing_default");
|
|
888
1161
|
}));
|
|
889
1162
|
|
|
1163
|
+
test("deregisterPlugin proves package-root ownership before stopping or deleting", () => withTempSupervisor((root) => {
|
|
1164
|
+
const managed = path.join(root, "managed");
|
|
1165
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
1166
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
1167
|
+
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-owned-source-"));
|
|
1168
|
+
const manifestPath = path.join(sourceDir, "clauth-plugin.json");
|
|
1169
|
+
fs.writeFileSync(manifestPath, JSON.stringify(baseManifest("owned-demo", { core: true, enable_default: true })), "utf8");
|
|
1170
|
+
assert.equal(registerPlugin(manifestPath, "test").resulting_state.ok, true);
|
|
1171
|
+
|
|
1172
|
+
const wrong = deregisterPlugin("owned-demo", "test", { expectedRoot: path.join(root, "other-install") });
|
|
1173
|
+
assert.equal(wrong.resulting_state.ok, false);
|
|
1174
|
+
assert.equal(wrong.resulting_state.state, "ownership_mismatch");
|
|
1175
|
+
assert.equal(fs.existsSync(path.join(managed, "owned-demo")), true, "ownership mismatch deleted the plugin");
|
|
1176
|
+
|
|
1177
|
+
const correct = deregisterPlugin("owned-demo", "test", { expectedRoot: sourceDir });
|
|
1178
|
+
assert.equal(correct.resulting_state.ok, true);
|
|
1179
|
+
assert.equal(correct.resulting_state.state, "deregistered");
|
|
1180
|
+
assert.deepEqual(correct.resulting_state.cleanup.map((entry) => entry.ok), [true]);
|
|
1181
|
+
assert.equal(fs.existsSync(path.join(managed, "owned-demo")), false);
|
|
1182
|
+
fs.rmSync(sourceDir, { recursive: true, force: true });
|
|
1183
|
+
}));
|
|
1184
|
+
|
|
1185
|
+
test("deregisterPlugin requires an explicit audited force to recover stale metadata after its stop executable disappears", () => withTempSupervisor((root) => {
|
|
1186
|
+
const managed = path.join(root, "managed");
|
|
1187
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
1188
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
1189
|
+
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-stale-source-"));
|
|
1190
|
+
const manifestPath = path.join(sourceDir, "clauth-plugin.json");
|
|
1191
|
+
fs.writeFileSync(manifestPath, JSON.stringify(baseManifest("stale-demo", {
|
|
1192
|
+
core: true,
|
|
1193
|
+
enable_default: true,
|
|
1194
|
+
surfaces: [{
|
|
1195
|
+
id: "primary",
|
|
1196
|
+
destination: "local/clauth/pm2",
|
|
1197
|
+
lifecycle_owner: "clauth",
|
|
1198
|
+
port: 39111,
|
|
1199
|
+
health: "/health",
|
|
1200
|
+
stop: [process.execPath, path.join(sourceDir, "removed-stop-script.cjs")],
|
|
1201
|
+
}],
|
|
1202
|
+
})), "utf8");
|
|
1203
|
+
assert.equal(registerPlugin(manifestPath, "test").resulting_state.ok, true);
|
|
1204
|
+
|
|
1205
|
+
const ordinary = deregisterPlugin("stale-demo", "test", { expectedRoot: sourceDir });
|
|
1206
|
+
assert.equal(ordinary.resulting_state.ok, false);
|
|
1207
|
+
assert.equal(ordinary.resulting_state.state, "surface_cleanup_failed");
|
|
1208
|
+
assert.equal(fs.existsSync(path.join(managed, "stale-demo")), true, "default failure must preserve recovery metadata");
|
|
1209
|
+
|
|
1210
|
+
const forced = deregisterPlugin("stale-demo", "test", { force: true });
|
|
1211
|
+
assert.equal(forced.resulting_state.ok, true);
|
|
1212
|
+
assert.equal(forced.resulting_state.state, "deregistered_forced");
|
|
1213
|
+
assert.equal(forced.resulting_state.cleanup[0].ok, false);
|
|
1214
|
+
assert.equal(forced.resulting_state.cleanup[0].forced, true);
|
|
1215
|
+
assert.deepEqual(forced.evidence, ["force_cleanup_bypass=true"]);
|
|
1216
|
+
assert.equal(fs.existsSync(path.join(managed, "stale-demo")), false);
|
|
1217
|
+
fs.rmSync(sourceDir, { recursive: true, force: true });
|
|
1218
|
+
}));
|
|
1219
|
+
|
|
890
1220
|
test("deregisterPlugin finds a plugin in a non-first managed root and refuses a user-root plugin", () => withTempSupervisor((root) => {
|
|
891
1221
|
// CLAUTH_MANAGED_PLUGIN_ROOTS is a path-delimited LIST; honoring only the
|
|
892
1222
|
// first entry silently reports a real plugin as absent.
|
|
@@ -933,25 +1263,30 @@ test("plugin sync reports an unknown repo-root override instead of silently swee
|
|
|
933
1263
|
assert.equal(rejected[0].ok, false);
|
|
934
1264
|
assert.match(rejected[0].error, /unknown repo name/);
|
|
935
1265
|
assert.equal(SYNC_SKIP_STATES.includes("unknown_repo_name"), false, "a typo'd repo name must fail the sweep, not be skipped");
|
|
936
|
-
assert.deepEqual([...SYNC_REPO_NAMES].sort(), ["
|
|
1266
|
+
assert.deepEqual([...SYNC_REPO_NAMES].sort(), ["regen-root"]);
|
|
937
1267
|
})));
|
|
938
1268
|
|
|
939
1269
|
test("plugin sync never throws on a malformed repo root — the contract absence must not break", () => withTempSupervisor(() => withTempProductRepos(({ rdcSkills }) => {
|
|
940
1270
|
// path.resolve() throws on a non-string; doing that before the existence
|
|
941
1271
|
// guard aborted the whole sweep and lost every later repo's receipt.
|
|
1272
|
+
// rdc-skills is passed alongside the bad root purely as an unrecognized
|
|
1273
|
+
// override key -- it must be reported (unknown_repo_name), not thrown, and
|
|
1274
|
+
// its presence must not stop regen-root's own manifests from still being
|
|
1275
|
+
// reported too.
|
|
942
1276
|
for (const badRoot of [123, " ", {}, [], true]) {
|
|
943
1277
|
let receipts;
|
|
944
1278
|
assert.doesNotThrow(() => {
|
|
945
1279
|
receipts = syncPluginsFromRepos({ "regen-root": badRoot, "rdc-skills": rdcSkills }, "test");
|
|
946
1280
|
}, `root ${JSON.stringify(badRoot)} must not throw`);
|
|
947
|
-
assert.equal(receipts.length,
|
|
1281
|
+
assert.equal(receipts.length, 6, "every attempted manifest plus the rejected override still yields a receipt");
|
|
948
1282
|
assert.equal(
|
|
949
1283
|
receipts.filter((entry) => entry.repo === "regen-root" && entry.state === "repo_root_unknown").length,
|
|
950
|
-
|
|
1284
|
+
5,
|
|
951
1285
|
`root ${JSON.stringify(badRoot)} must be reported, not thrown`,
|
|
952
1286
|
);
|
|
953
1287
|
const skills = receipts.find((entry) => entry.repo === "rdc-skills");
|
|
954
|
-
assert.equal(skills.ok,
|
|
1288
|
+
assert.equal(skills.ok, false, "rdc-skills is not a known sync repo name -- it must be rejected, not swept");
|
|
1289
|
+
assert.equal(skills.state, "unknown_repo_name");
|
|
955
1290
|
}
|
|
956
1291
|
})));
|
|
957
1292
|
|
|
@@ -1127,3 +1462,208 @@ test("surfaceOpenUrl never points a browser at an MCP transport endpoint", () =>
|
|
|
1127
1462
|
);
|
|
1128
1463
|
assert.equal(surfaceOpenUrl({}), null);
|
|
1129
1464
|
});
|
|
1465
|
+
|
|
1466
|
+
// rdc:review finding (2026-09-02), part 3 of 3: `--isolated` never set
|
|
1467
|
+
// CLAUTH_SUPERVISOR_DIR, so an isolated instance shared state.json with the
|
|
1468
|
+
// live daemon by default. resolveIsolatedSupervisorDir is the pure decision
|
|
1469
|
+
// extracted out of cli/commands/serve.js's actionForeground so it's testable
|
|
1470
|
+
// without spawning a server.
|
|
1471
|
+
test("resolveIsolatedSupervisorDir picks a port-scoped dir, and never overrides an explicit CLAUTH_SUPERVISOR_DIR", () => {
|
|
1472
|
+
const a = resolveIsolatedSupervisorDir(52440, undefined);
|
|
1473
|
+
const b = resolveIsolatedSupervisorDir(53137, undefined);
|
|
1474
|
+
assert.notEqual(a, b, "two different ports must not resolve to the same dir");
|
|
1475
|
+
assert.match(a, /clauth-isolated/);
|
|
1476
|
+
assert.match(a, /52440/);
|
|
1477
|
+
assert.equal(resolveIsolatedSupervisorDir(52440, "C:\\custom\\explicit-dir"), "C:\\custom\\explicit-dir");
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
// CRITICAL rdc:review finding (2026-09-02), confirmed regression: the first
|
|
1481
|
+
// version of the isolation fix called resolveIsolatedSupervisorDir() whenever
|
|
1482
|
+
// `opts.isolated` was true, with no further check. actionSupervisor() in
|
|
1483
|
+
// cli/commands/serve.js unconditionally sets `opts.isolated = true` for an
|
|
1484
|
+
// unrelated reason (skip vault password auth on the internal supervisor child
|
|
1485
|
+
// process every normal `clauth serve start` spawns). The result: the REAL
|
|
1486
|
+
// production supervisor's state.json got silently redirected to an empty
|
|
1487
|
+
// temp dir on every normal boot, disabling the whole health-reconcile/
|
|
1488
|
+
// auto-repair loop with no error. isGenuinelyIsolatedInstance() is the actual
|
|
1489
|
+
// gate now used in serve.js.
|
|
1490
|
+
//
|
|
1491
|
+
// CORRECTED (4th review round): the first fix keyed this off `port ===
|
|
1492
|
+
// supervisorPort`, which collided with test/serve-http-routes.test.mjs's own
|
|
1493
|
+
// sanctioned pattern of setting CLAUTH_SUPERVISOR_PORT to match its --port --
|
|
1494
|
+
// structurally identical from the outside, so a stricter port-based refusal
|
|
1495
|
+
// elsewhere broke 39 tests. This version keys off the unambiguous
|
|
1496
|
+
// __CLAUTH_SUPERVISOR_DAEMON marker ensureSupervisorStarted() already sets
|
|
1497
|
+
// (and nothing previously read) instead of port matching.
|
|
1498
|
+
test("isGenuinelyIsolatedInstance excludes the real internal daemon (via its marker) even when isolated=true, and includes every other isolated invocation", () => {
|
|
1499
|
+
// The exact regressed case: actionSupervisor() sets isolated=true for its
|
|
1500
|
+
// automatic boot, which DOES carry the internal-daemon marker -- must NOT
|
|
1501
|
+
// be treated as a throwaway isolated instance.
|
|
1502
|
+
assert.equal(isGenuinelyIsolatedInstance(true, true), false);
|
|
1503
|
+
// Any other isolated invocation (serve test, a manual --isolated --port
|
|
1504
|
+
// run, or a test fixture deliberately constructing a supervisor-port
|
|
1505
|
+
// scenario) never carries the marker and is a genuine throwaway instance.
|
|
1506
|
+
assert.equal(isGenuinelyIsolatedInstance(true, false), true);
|
|
1507
|
+
// Not isolated at all -- never redirect, regardless of the marker.
|
|
1508
|
+
assert.equal(isGenuinelyIsolatedInstance(false, false), false);
|
|
1509
|
+
assert.equal(isGenuinelyIsolatedInstance(false, true), false);
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
// rdc:review finding (2026-09-02), part 1 of 3: state.json's read-modify-write
|
|
1513
|
+
// had no lock. withStateLock is the in-process promise-chained mutex added to
|
|
1514
|
+
// close it. This test proves the primitive itself provides real mutual
|
|
1515
|
+
// exclusion using the textbook counter-race shape (read, await, increment,
|
|
1516
|
+
// write) -- the exact shape an async critical section with a network/health
|
|
1517
|
+
// probe in the middle has. Without the lock this loses updates (every
|
|
1518
|
+
// concurrent reader sees the same pre-race value); with it, none are lost.
|
|
1519
|
+
// withStateLock now does real cross-process file I/O (a lockfile alongside
|
|
1520
|
+
// state.json) as well as in-process serialization -- MUST run under
|
|
1521
|
+
// withTempSupervisor. Without it, every withStateLock call below reads
|
|
1522
|
+
// CLAUTH_SUPERVISOR_DIR from whatever the ambient environment happens to be
|
|
1523
|
+
// (unset -> the LIVE %APPDATA%/clauth/supervisor/ directory), and this test
|
|
1524
|
+
// would create/delete a real state.lock file there.
|
|
1525
|
+
test("withStateLock serializes overlapping async critical sections -- no lost updates", () => withTempSupervisor(async () => {
|
|
1526
|
+
let counter = 0;
|
|
1527
|
+
const N = 25;
|
|
1528
|
+
async function unsafeIncrement() {
|
|
1529
|
+
const seen = counter;
|
|
1530
|
+
await new Promise((resolve) => setImmediate(resolve)); // force a real interleaving window
|
|
1531
|
+
counter = seen + 1;
|
|
1532
|
+
}
|
|
1533
|
+
async function lockedIncrement() {
|
|
1534
|
+
return withStateLock(async () => {
|
|
1535
|
+
const seen = counter;
|
|
1536
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1537
|
+
counter = seen + 1;
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
counter = 0;
|
|
1542
|
+
await Promise.all(Array.from({ length: N }, () => unsafeIncrement()));
|
|
1543
|
+
const unsafeResult = counter;
|
|
1544
|
+
assert.ok(unsafeResult < N, `expected the unlocked version to lose updates (got ${unsafeResult}/${N} -- if this ever equals ${N}, the interleaving window isn't forcing a real race and this test needs a stronger delay)`);
|
|
1545
|
+
|
|
1546
|
+
counter = 0;
|
|
1547
|
+
await Promise.all(Array.from({ length: N }, () => lockedIncrement()));
|
|
1548
|
+
assert.equal(counter, N, "withStateLock must serialize every critical section -- zero lost updates");
|
|
1549
|
+
}));
|
|
1550
|
+
|
|
1551
|
+
// rdc:review finding (2026-09-02), fourth pass -- confirmed, material to the
|
|
1552
|
+
// prior interview: an in-process mutex provides ZERO protection between two
|
|
1553
|
+
// separate OS processes, and `clauth serve start`'s STANDARD topology
|
|
1554
|
+
// (ensureSupervisorStarted's real detached spawn() for the :52439 supervisor
|
|
1555
|
+
// child, sharing state.json with the main :52437 daemon by design) is
|
|
1556
|
+
// exactly that -- not an edge case. The in-process test above cannot catch
|
|
1557
|
+
// this; it never leaves one process. This test spawns two REAL, separate
|
|
1558
|
+
// Node processes racing to increment a shared counter file, each increment
|
|
1559
|
+
// guarded by the real, exported withStateLock -- proving the cross-process
|
|
1560
|
+
// lockfile (acquireCrossProcessStateLock/releaseCrossProcessStateLock)
|
|
1561
|
+
// actually serializes across process boundaries, not just within one.
|
|
1562
|
+
test("withStateLock provides real cross-process mutual exclusion, not just in-process", () => withTempSupervisor(async (root) => {
|
|
1563
|
+
const counterPath = path.join(root, "counter.txt");
|
|
1564
|
+
fs.writeFileSync(counterPath, "0");
|
|
1565
|
+
const registryUrl = pathToFileURL(path.join(process.cwd(), "cli", "supervisor-registry.js")).href;
|
|
1566
|
+
const workerPath = path.join(root, "cross-process-lock-worker.mjs");
|
|
1567
|
+
fs.writeFileSync(workerPath, `
|
|
1568
|
+
import { withStateLock } from ${JSON.stringify(registryUrl)};
|
|
1569
|
+
import fs from "node:fs";
|
|
1570
|
+
const counterPath = process.env.COUNTER_PATH;
|
|
1571
|
+
const increments = Number(process.env.INCREMENTS);
|
|
1572
|
+
async function run() {
|
|
1573
|
+
for (let i = 0; i < increments; i++) {
|
|
1574
|
+
await withStateLock(async () => {
|
|
1575
|
+
const current = Number(fs.readFileSync(counterPath, "utf8"));
|
|
1576
|
+
// Force a real interleaving window -- without cross-process mutual
|
|
1577
|
+
// exclusion, the other process's concurrent read+write lands here.
|
|
1578
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1579
|
+
fs.writeFileSync(counterPath, String(current + 1));
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
run().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
|
|
1584
|
+
`);
|
|
1585
|
+
|
|
1586
|
+
const INCREMENTS_PER_WORKER = 12;
|
|
1587
|
+
const env = { ...process.env, CLAUTH_SUPERVISOR_DIR: root, COUNTER_PATH: counterPath, INCREMENTS: String(INCREMENTS_PER_WORKER) };
|
|
1588
|
+
const spawnWorker = () => new Promise((resolve, reject) => {
|
|
1589
|
+
const child = spawn(process.execPath, [workerPath], { env, stdio: ["ignore", "inherit", "inherit"] });
|
|
1590
|
+
child.on("error", reject);
|
|
1591
|
+
child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`cross-process lock worker exited ${code}`))));
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
await Promise.all([spawnWorker(), spawnWorker()]);
|
|
1595
|
+
const final = Number(fs.readFileSync(counterPath, "utf8"));
|
|
1596
|
+
assert.equal(final, INCREMENTS_PER_WORKER * 2, "withStateLock must serialize across two real OS processes sharing CLAUTH_SUPERVISOR_DIR -- zero lost updates");
|
|
1597
|
+
}));
|
|
1598
|
+
|
|
1599
|
+
// rdc:review finding (2026-09-02), 4th independent round, empirically
|
|
1600
|
+
// reproduced against an instrumented mirror of the shipped algorithm: the
|
|
1601
|
+
// PREVIOUS reclaim mechanism (unlinkSync a stale lock, then loop back to
|
|
1602
|
+
// retry openSync('wx')) was not atomic -- multiple waiters racing the SAME
|
|
1603
|
+
// stale lock could each independently decide to reclaim and each
|
|
1604
|
+
// independently succeed, becoming simultaneous holders (2-3 in 5 of 12 runs
|
|
1605
|
+
// in the reviewer's repro). This test exercises exactly that scenario
|
|
1606
|
+
// against the REAL shipped code (not a mirror): pre-seeds a genuinely stale
|
|
1607
|
+
// lock (dead pid, 60s old -- past STATE_LOCK_STALE_MS), then spawns several
|
|
1608
|
+
// real processes SIMULTANEOUSLY, all of which must race the same stale
|
|
1609
|
+
// reclaim on their very first acquisition attempt. The current algorithm
|
|
1610
|
+
// closes this via an atomic renameSync claim -- only one racing process can
|
|
1611
|
+
// ever successfully rename a given stale directory instance away.
|
|
1612
|
+
// rdc:review finding (2026-09-02), 5th round: three consecutive hand-rolled
|
|
1613
|
+
// versions of this lock each had a distinct real concurrency bug (see
|
|
1614
|
+
// withStateLock's own header comment in supervisor-registry.js for the full
|
|
1615
|
+
// history) -- replaced with proper-lockfile, a mature library, rather than a
|
|
1616
|
+
// 4th hand-rolled attempt. Its on-disk shape and staleness mechanism are
|
|
1617
|
+
// different from any prior version: it locks `<file>.lock` (here,
|
|
1618
|
+
// state.json.lock, not state.lock) and judges staleness via the lock
|
|
1619
|
+
// directory's own mtime (continuously refreshed while a holder is active),
|
|
1620
|
+
// not a point-in-time pid/timestamp record inside it. This test is updated
|
|
1621
|
+
// to match that real contract rather than testing an obsolete on-disk shape.
|
|
1622
|
+
test("withStateLock's stale-lock reclaim is atomic under multiple processes racing the SAME abandoned lock", () => withTempSupervisor(async (root) => {
|
|
1623
|
+
const counterPath = path.join(root, "counter.txt");
|
|
1624
|
+
fs.writeFileSync(counterPath, "0");
|
|
1625
|
+
|
|
1626
|
+
// Pre-seed a stale lock at proper-lockfile's real path (state.json.lock,
|
|
1627
|
+
// sibling to state.json -- see stateLockTargetPath()) with an mtime well
|
|
1628
|
+
// past the stale threshold (30s) -- the one signal proper-lockfile's own
|
|
1629
|
+
// isLockStale() checks, so this is unambiguously reclaimable by design.
|
|
1630
|
+
const lockPath = path.join(root, "state.json.lock");
|
|
1631
|
+
fs.mkdirSync(lockPath, { recursive: true });
|
|
1632
|
+
const staleTime = new Date(Date.now() - 60000);
|
|
1633
|
+
fs.utimesSync(lockPath, staleTime, staleTime);
|
|
1634
|
+
|
|
1635
|
+
const registryUrl = pathToFileURL(path.join(process.cwd(), "cli", "supervisor-registry.js")).href;
|
|
1636
|
+
const workerPath = path.join(root, "stale-reclaim-worker.mjs");
|
|
1637
|
+
fs.writeFileSync(workerPath, `
|
|
1638
|
+
import { withStateLock } from ${JSON.stringify(registryUrl)};
|
|
1639
|
+
import fs from "node:fs";
|
|
1640
|
+
const counterPath = process.env.COUNTER_PATH;
|
|
1641
|
+
const increments = Number(process.env.INCREMENTS);
|
|
1642
|
+
async function run() {
|
|
1643
|
+
for (let i = 0; i < increments; i++) {
|
|
1644
|
+
await withStateLock(async () => {
|
|
1645
|
+
const current = Number(fs.readFileSync(counterPath, "utf8"));
|
|
1646
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1647
|
+
fs.writeFileSync(counterPath, String(current + 1));
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
run().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
|
|
1652
|
+
`);
|
|
1653
|
+
|
|
1654
|
+
const WORKER_COUNT = 6;
|
|
1655
|
+
const INCREMENTS_PER_WORKER = 5;
|
|
1656
|
+
const env = { ...process.env, CLAUTH_SUPERVISOR_DIR: root, COUNTER_PATH: counterPath, INCREMENTS: String(INCREMENTS_PER_WORKER) };
|
|
1657
|
+
const spawnWorker = () => new Promise((resolve, reject) => {
|
|
1658
|
+
const child = spawn(process.execPath, [workerPath], { env, stdio: ["ignore", "inherit", "inherit"] });
|
|
1659
|
+
child.on("error", reject);
|
|
1660
|
+
child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`stale-reclaim worker exited ${code}`))));
|
|
1661
|
+
});
|
|
1662
|
+
|
|
1663
|
+
// All WORKER_COUNT processes launched together -- their first acquisition
|
|
1664
|
+
// attempt genuinely races the same pre-seeded stale lock simultaneously,
|
|
1665
|
+
// which is the exact condition the prior algorithm failed under.
|
|
1666
|
+
await Promise.all(Array.from({ length: WORKER_COUNT }, () => spawnWorker()));
|
|
1667
|
+
const final = Number(fs.readFileSync(counterPath, "utf8"));
|
|
1668
|
+
assert.equal(final, WORKER_COUNT * INCREMENTS_PER_WORKER, "stale-lock reclaim must be atomic -- zero lost updates even when multiple processes race the same abandoned lock");
|
|
1669
|
+
}));
|