@lifeaitools/clauth 2.10.2 → 2.15.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/ops/pm2-adapter.js +147 -4
- package/cli/services/file-io.js +21 -3
- package/cli/services/file-io.test.js +33 -1
- package/cli/supervisor-registry.js +516 -55
- package/cli/supervisor-registry.test.js +462 -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,8 @@ 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,
|
|
@@ -14,22 +16,24 @@ import {
|
|
|
14
16
|
listSurfaces,
|
|
15
17
|
probeAllSurfaceHealth,
|
|
16
18
|
surfaceOpenUrl,
|
|
19
|
+
isGenuinelyIsolatedInstance,
|
|
17
20
|
reconcileSurfaceHealth,
|
|
18
21
|
registerPlugin,
|
|
22
|
+
resolveIsolatedSupervisorDir,
|
|
19
23
|
runPluginAction,
|
|
20
24
|
runSurfaceAction,
|
|
21
25
|
removeTunnelRoute,
|
|
22
|
-
setPluginEnabled,
|
|
23
26
|
shellQuote,
|
|
24
27
|
supervisorHealth,
|
|
25
28
|
syncPluginsFromRepos,
|
|
26
29
|
SYNC_REPO_NAMES,
|
|
27
30
|
SYNC_SKIP_STATES,
|
|
28
31
|
validatePluginManifest,
|
|
32
|
+
withStateLock,
|
|
29
33
|
} from "./supervisor-registry.js";
|
|
30
34
|
import { isLoopbackAddress, supervisorLogDto, supervisorRequiresWriteToken } from "./commands/serve.js";
|
|
31
35
|
|
|
32
|
-
function withTempSupervisor(fn) {
|
|
36
|
+
async function withTempSupervisor(fn) {
|
|
33
37
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-"));
|
|
34
38
|
const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
|
|
35
39
|
const oldManaged = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
@@ -46,7 +50,7 @@ function withTempSupervisor(fn) {
|
|
|
46
50
|
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user-plugins");
|
|
47
51
|
delete process.env.CLAUTH_PM2_HOME;
|
|
48
52
|
try {
|
|
49
|
-
return fn(root);
|
|
53
|
+
return await fn(root);
|
|
50
54
|
} finally {
|
|
51
55
|
if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
|
|
52
56
|
else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
|
|
@@ -292,8 +296,6 @@ test("plugin test marks a private candidate and never creates a public route", (
|
|
|
292
296
|
writePlugin(root, "managed", "regen-media-local", baseManifest("regen-media-local"));
|
|
293
297
|
discoverPlugins();
|
|
294
298
|
|
|
295
|
-
const enabled = setPluginEnabled("regen-media-local", true);
|
|
296
|
-
assert.equal(enabled.resulting_state.enabled, true);
|
|
297
299
|
const receipt = runPluginAction("regen-media-local", "test");
|
|
298
300
|
assert.equal(receipt.resulting_state.state, "candidate_testing");
|
|
299
301
|
assert.equal(receipt.resulting_state.public_route, false);
|
|
@@ -354,7 +356,7 @@ test("reconcile falls back to the declared start command when restart reports a
|
|
|
354
356
|
assert.equal(receipt.resulting_state.evidence.includes("reconcile_start_fallback=true"), true);
|
|
355
357
|
}));
|
|
356
358
|
|
|
357
|
-
test("surface
|
|
359
|
+
test("surface promotion requires the atomic health-checked path and rolls back on failed health", async () => withTempSupervisor(async (root) => {
|
|
358
360
|
const managed = path.join(root, "managed");
|
|
359
361
|
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
360
362
|
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
@@ -364,16 +366,132 @@ test("surface promote and rollback never fall through to restart commands", () =
|
|
|
364
366
|
name: "Demo surface",
|
|
365
367
|
health: "http://127.0.0.1:3333/health",
|
|
366
368
|
restart: [process.execPath, "--version"],
|
|
369
|
+
promote: [process.execPath, "--version"],
|
|
370
|
+
rollback: [process.execPath, "--version"],
|
|
367
371
|
}],
|
|
368
372
|
}));
|
|
369
373
|
discoverPlugins();
|
|
370
374
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
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");
|
|
377
495
|
}));
|
|
378
496
|
|
|
379
497
|
test("health reconciliation marks a failed clauth surface and repairs it through reconcile", async () => {
|
|
@@ -679,6 +797,37 @@ test("registerPlugin validates, writes into the managed root, and discovers the
|
|
|
679
797
|
fs.rmSync(sourceDir, { recursive: true, force: true });
|
|
680
798
|
}));
|
|
681
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
|
+
|
|
682
831
|
test("registerPlugin rejects a plugin id that would escape the managed-plugins root", () => withTempSupervisor((root) => {
|
|
683
832
|
// Code-review finding (confidence 95, live PoC): manifest.id of ".." passed
|
|
684
833
|
// the old id regex (dot is in the allowed character class with no
|
|
@@ -710,6 +859,75 @@ test("validatePluginManifest rejects an all-dots plugin or surface id", () => {
|
|
|
710
859
|
}), "clauth-plugin.json"), /may not be all dots/);
|
|
711
860
|
});
|
|
712
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
|
+
|
|
713
931
|
test("registerPlugin rejects an invalid manifest without writing anything", () => withTempSupervisor((root) => {
|
|
714
932
|
const managed = path.join(root, "managed");
|
|
715
933
|
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
@@ -726,8 +944,16 @@ test("registerPlugin rejects an invalid manifest without writing anything", () =
|
|
|
726
944
|
fs.rmSync(sourceDir, { recursive: true, force: true });
|
|
727
945
|
}));
|
|
728
946
|
|
|
729
|
-
// Builds throwaway product
|
|
947
|
+
// Builds a throwaway product repo laid out like the real one, so a sync sweep
|
|
730
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.
|
|
731
957
|
function withTempProductRepos(fn) {
|
|
732
958
|
const base = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-sync-repos-"));
|
|
733
959
|
const regenRoot = path.join(base, "regen-root");
|
|
@@ -740,11 +966,12 @@ function withTempProductRepos(fn) {
|
|
|
740
966
|
};
|
|
741
967
|
writeManifest(regenRoot, "packages/codeflow/clauth-plugin.json", baseManifest("codeflow-mcp"));
|
|
742
968
|
writeManifest(regenRoot, "apps/dev-center/clauth-plugin.json", baseManifest("dev-center"));
|
|
969
|
+
writeManifest(regenRoot, "apps/codeflow-explorer/clauth-plugin.json", baseManifest("codeflow-explorer"));
|
|
743
970
|
writeManifest(regenRoot, "mcp-servers/regen-media/clauth-plugin.json", baseManifest("regen-media"));
|
|
744
971
|
writeManifest(regenRoot, "mcp-servers/web-research/clauth-plugin.json", baseManifest("web-research"));
|
|
745
972
|
writeManifest(rdcSkills, "clauth-plugin.json", baseManifest("rdc-skills"));
|
|
746
973
|
try {
|
|
747
|
-
return fn({ base, regenRoot, rdcSkills, writeManifest, roots: { "regen-root": regenRoot
|
|
974
|
+
return fn({ base, regenRoot, rdcSkills, writeManifest, roots: { "regen-root": regenRoot } });
|
|
748
975
|
} finally {
|
|
749
976
|
fs.rmSync(base, { recursive: true, force: true });
|
|
750
977
|
}
|
|
@@ -767,22 +994,19 @@ test("plugin sync inherits registerPlugin idempotence — a second sweep reports
|
|
|
767
994
|
assert.equal(second.every((entry) => entry.ok && entry.state === "unchanged"), true, "re-sweeping identical content must be a no-op");
|
|
768
995
|
})));
|
|
769
996
|
|
|
770
|
-
test("plugin sync warns and continues over a missing repo root instead of throwing", () => withTempSupervisor(() => withTempProductRepos(({ base
|
|
771
|
-
// 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.
|
|
772
1000
|
const absent = path.join(base, "no-such-checkout");
|
|
773
1001
|
assert.equal(fs.existsSync(absent), false);
|
|
774
1002
|
let receipts;
|
|
775
1003
|
assert.doesNotThrow(() => {
|
|
776
|
-
receipts = syncPluginsFromRepos({ "regen-root": absent
|
|
1004
|
+
receipts = syncPluginsFromRepos({ "regen-root": absent }, "test");
|
|
777
1005
|
});
|
|
778
1006
|
assert.equal(receipts.length, 5, "a skipped repo still yields a receipt per attempted manifest");
|
|
779
1007
|
const missing = receipts.filter((entry) => entry.state === "repo_root_missing");
|
|
780
|
-
assert.equal(missing.length,
|
|
1008
|
+
assert.equal(missing.length, 5, "all five regen-root manifests report the missing root");
|
|
781
1009
|
assert.equal(missing.every((entry) => entry.ok === false), true);
|
|
782
|
-
const skills = receipts.find((entry) => entry.repo === "rdc-skills");
|
|
783
|
-
assert.equal(skills.ok, true);
|
|
784
|
-
assert.equal(skills.state, "registered");
|
|
785
|
-
assert.ok(listPlugins().find((plugin) => plugin.id === "rdc-skills"), "the reachable repo still registered");
|
|
786
1010
|
})));
|
|
787
1011
|
|
|
788
1012
|
test("plugin sync registers the remaining manifests when one is malformed", () => withTempSupervisor(() => withTempProductRepos(({ regenRoot, roots, writeManifest }) => {
|
|
@@ -797,7 +1021,7 @@ test("plugin sync registers the remaining manifests when one is malformed", () =
|
|
|
797
1021
|
assert.equal(good.length, 4);
|
|
798
1022
|
assert.equal(good.every((entry) => entry.ok && entry.state === "registered"), true, "one bad manifest must not abort the sweep");
|
|
799
1023
|
const ids = new Set(listPlugins().map((plugin) => plugin.id));
|
|
800
|
-
for (const id of ["codeflow-mcp", "
|
|
1024
|
+
for (const id of ["codeflow-mcp", "codeflow-explorer", "regen-media", "web-research"]) {
|
|
801
1025
|
assert.ok(ids.has(id), `${id} must still be registered`);
|
|
802
1026
|
}
|
|
803
1027
|
})));
|
|
@@ -814,7 +1038,7 @@ test("deregisterPlugin removes only the named plugin and leaves siblings intact"
|
|
|
814
1038
|
assert.equal(receipt.resulting_state.cleanup[0].ok, true);
|
|
815
1039
|
assert.equal(fs.existsSync(path.join(managed, "web-research")), false, "the named plugin directory is gone");
|
|
816
1040
|
|
|
817
|
-
for (const sibling of ["codeflow-mcp", "dev-center", "
|
|
1041
|
+
for (const sibling of ["codeflow-mcp", "dev-center", "codeflow-explorer", "regen-media"]) {
|
|
818
1042
|
assert.equal(fs.existsSync(path.join(managed, sibling)), true, `${sibling} must survive`);
|
|
819
1043
|
}
|
|
820
1044
|
// discovery re-ran, so the removed managed plugin is reported missing, not current
|
|
@@ -1039,25 +1263,30 @@ test("plugin sync reports an unknown repo-root override instead of silently swee
|
|
|
1039
1263
|
assert.equal(rejected[0].ok, false);
|
|
1040
1264
|
assert.match(rejected[0].error, /unknown repo name/);
|
|
1041
1265
|
assert.equal(SYNC_SKIP_STATES.includes("unknown_repo_name"), false, "a typo'd repo name must fail the sweep, not be skipped");
|
|
1042
|
-
assert.deepEqual([...SYNC_REPO_NAMES].sort(), ["
|
|
1266
|
+
assert.deepEqual([...SYNC_REPO_NAMES].sort(), ["regen-root"]);
|
|
1043
1267
|
})));
|
|
1044
1268
|
|
|
1045
1269
|
test("plugin sync never throws on a malformed repo root — the contract absence must not break", () => withTempSupervisor(() => withTempProductRepos(({ rdcSkills }) => {
|
|
1046
1270
|
// path.resolve() throws on a non-string; doing that before the existence
|
|
1047
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.
|
|
1048
1276
|
for (const badRoot of [123, " ", {}, [], true]) {
|
|
1049
1277
|
let receipts;
|
|
1050
1278
|
assert.doesNotThrow(() => {
|
|
1051
1279
|
receipts = syncPluginsFromRepos({ "regen-root": badRoot, "rdc-skills": rdcSkills }, "test");
|
|
1052
1280
|
}, `root ${JSON.stringify(badRoot)} must not throw`);
|
|
1053
|
-
assert.equal(receipts.length,
|
|
1281
|
+
assert.equal(receipts.length, 6, "every attempted manifest plus the rejected override still yields a receipt");
|
|
1054
1282
|
assert.equal(
|
|
1055
1283
|
receipts.filter((entry) => entry.repo === "regen-root" && entry.state === "repo_root_unknown").length,
|
|
1056
|
-
|
|
1284
|
+
5,
|
|
1057
1285
|
`root ${JSON.stringify(badRoot)} must be reported, not thrown`,
|
|
1058
1286
|
);
|
|
1059
1287
|
const skills = receipts.find((entry) => entry.repo === "rdc-skills");
|
|
1060
|
-
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");
|
|
1061
1290
|
}
|
|
1062
1291
|
})));
|
|
1063
1292
|
|
|
@@ -1233,3 +1462,208 @@ test("surfaceOpenUrl never points a browser at an MCP transport endpoint", () =>
|
|
|
1233
1462
|
);
|
|
1234
1463
|
assert.equal(surfaceOpenUrl({}), null);
|
|
1235
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
|
+
}));
|
package/cli/watchdog-registry.js
CHANGED
|
@@ -66,6 +66,15 @@ function validateCommand(command, field) {
|
|
|
66
66
|
export function validateWatchdogService(service) {
|
|
67
67
|
if (!service || typeof service !== "object") throw new Error("service must be an object");
|
|
68
68
|
if (!service.id || typeof service.id !== "string") throw new Error("service.id is required");
|
|
69
|
+
// rdc:review finding (2026-09-02): shaped identically to the id checks in
|
|
70
|
+
// cli/supervisor-registry.js (validatePluginManifest, normalizeSurface),
|
|
71
|
+
// which also reject Windows-reserved device names (con/nul/aux/prn/
|
|
72
|
+
// com1-9/lpt1-9) at registration. NOT extended here deliberately: unlike
|
|
73
|
+
// those, service.id is not used to derive a filesystem path anywhere in
|
|
74
|
+
// this file today, so there is nothing for a reserved name to collide
|
|
75
|
+
// with. Revisit if a future change starts deriving a per-service path from
|
|
76
|
+
// service.id -- that would reintroduce the same defect class this comment
|
|
77
|
+
// exists to flag before it does.
|
|
69
78
|
if (!/^[a-zA-Z0-9_.-]+$/.test(service.id)) throw new Error("service.id may contain only letters, numbers, dot, underscore, and dash");
|
|
70
79
|
if (!service.label || typeof service.label !== "string") throw new Error("service.label is required");
|
|
71
80
|
if (!VALID_KINDS.has(service.kind)) throw new Error(`service.kind must be one of ${[...VALID_KINDS].join(", ")}`);
|