@mindrian_os/install 1.13.0-beta.21 → 1.13.0-beta.24
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +31 -0
- package/bin/mindrian-brain-mcp-client.cjs +16 -3
- package/bin/mindrian-mcp-server.cjs +18 -3
- package/hooks/hooks.json +8 -8
- package/lib/core/mcp-dep-heal.cjs +246 -0
- package/lib/core/mcp-dep-heal.test.cjs +253 -0
- package/lib/core/npm-cli-resolve.cjs +151 -0
- package/lib/core/npm-cli-resolve.test.cjs +153 -0
- package/lib/core/npm-install-lock.cjs +302 -0
- package/lib/core/npm-install-lock.test.cjs +325 -0
- package/package.json +2 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mos",
|
|
3
3
|
"description": "MindrianOS -- Your AI innovation co-founder. Larry thinks with you through PWS methodology, builds your Data Room as you explore, and chains frameworks intelligently. Install and go.",
|
|
4
|
-
"version": "1.13.0-beta.
|
|
4
|
+
"version": "1.13.0-beta.24",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Jonathan Sagir",
|
|
7
7
|
"url": "https://mindrian.ai"
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
## [1.13.0-beta.24] - 2026-05-22
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
- **Brain unreachable on the first session after a plugin update -- now fixed on all three platforms.** `claude plugin update` lands a fresh plugin cache directory with no `node_modules`. Both bundled MCP servers (`mindrian-brain` + `mindrian-os`, both `alwaysLoad`) then crashed at module load (`Cannot find module '@modelcontextprotocol/sdk/server/mcp.js'`), the Brain was unreachable, and `/reload-plugins` reported a load error. On Windows the gap was *permanent* -- the repair never ran (see the portable self-heal note below). Root cause: a startup-order race plus a cross-platform defect in the repair (debug session `mcp-servers-cache-missing-node-modules`). The fix below makes Brain connectivity true by construction on Windows, Mac, and Linux.
|
|
5
|
+
|
|
6
|
+
### Changed
|
|
7
|
+
- **Vendored production dependencies (the guarantee).** The plugin now ships its production `node_modules` with the released marketplace artifact, so the Brain shim's dependencies are present the instant the install cache lands -- no runtime install, no network, no startup race. Every production dependency was audited and confirmed pure-JavaScript (zero native/compiled binaries: no `.node` addons, no `binding.gyp`, no prebuilt platform binaries, no install lifecycle scripts), so the same vendored tree is correct on Windows, Mac, and Linux by construction. The vendored tree is built fresh from `package-lock.json` via `npm ci --omit=dev` during the release (`scripts/release.sh` Step 6.7), staged onto the tagged release commit only -- `main` HEAD stays clean. This is a new release lockstep surface (see `.claude/includes/release-process.md`); it can never drift from the lockfile.
|
|
8
|
+
- **Portable cross-platform self-heal (the backstop).** The runtime self-heal that repairs an incomplete cache is now cross-platform. The prior implementation ran a bare `spawnSync('npm', ...)`, which was *dead on Windows* (`npm` is `npm.cmd`, a batch file -- bare `spawnSync` returns `ENOENT`) and *fragile on Mac* (a GUI-launched Claude Code gives child processes a minimal `PATH` that often excludes the nvm / Homebrew bin directory where `npm` lives). The new `lib/core/npm-cli-resolve.cjs` resolves `npm` to its absolute `npm-cli.js` entry point off `process.execPath` -- npm ships in the same distribution as the running `node` binary -- and runs it as `node <abs npm-cli.js> install`. This sidesteps `PATH`, the `.cmd` extension, and `shell:true` entirely. Applied to both spawn sites (`lib/core/mcp-dep-heal.cjs` and `scripts/sessionstart-npm-reconcile.cjs`).
|
|
9
|
+
- **Hybrid self-heal for the plugin cache (carried from the prior staging of this beta).** The vendored tree is the primary guarantee; the self-heal is the backstop for a somehow-incomplete cache. Three coordinated changes still close the race from both ends:
|
|
10
|
+
1. The `sessionstart-npm-reconcile.cjs` hook is `async: false` and ordered FIRST in the `SessionStart` chain, so any needed dependency install completes before Claude Code reads `.mcp.json` and spawns the MCP servers.
|
|
11
|
+
2. Both MCP entry points (`bin/mindrian-brain-mcp-client.cjs`, `bin/mindrian-mcp-server.cjs`) self-heal: a missing dependency triggers a one-shot guarded `npm install` in the plugin cache root, then re-requires. On a normal install (vendored deps present) this is a cheap `stat()` pre-flight that spawns nothing.
|
|
12
|
+
3. A lockfile guard (`lib/core/npm-install-lock.cjs`) ensures that when both servers spawn together, exactly one runs `npm install` while the other blocks and waits -- two concurrent installs can never corrupt `node_modules`.
|
|
13
|
+
- New modules: `lib/core/mcp-dep-heal.cjs` (`ensureDepsPresent` + `requireWithHeal`) and `lib/core/npm-cli-resolve.cjs` (portable npm resolution). Zero network surface -- pure node built-ins plus a single guarded `npm install` child process (Canon Part 8). Mirrors the existing reconcile-hook detection logic rather than inventing a new mechanism (Canon Part 7).
|
|
14
|
+
- `package-lock.json` resynced with `package.json` (it was 13 betas stale; `npm ci` could not run against it). No runtime dependency versions changed -- a metadata-only catch-up.
|
|
15
|
+
|
|
16
|
+
### Fixed (dependency hygiene, surfaced by the vendoring audit)
|
|
17
|
+
- **`/mos:doctor` would crash with `Cannot find module 'semver'` on a production-only install.** `scripts/doctor.cjs` -- a user-facing runtime script invoked by `/mos:doctor` -- requires `semver` for version-ordering (`semver.compare`), but `semver` was declared as a `devDependency`. A full audit of every `require()` of a declared dependency across all shipped code paths confirmed `semver` was the only misclassification. Moved to `dependencies` so it is present on every install (and in the vendored tree). `devDependencies` is now empty.
|
|
18
|
+
|
|
19
|
+
### Fixed (lockfile + probe correctness, surfaced by a remote code review of the self-heal backstop)
|
|
20
|
+
- **Concurrent-install corruption from a non-atomic lock (`bug_004`).** `lib/core/npm-install-lock.cjs` created its lock with `openSync('wx')` and then populated it with a *separate* `writeSync`. The create is atomic, but the file existed empty between the two syscalls -- a racing peer that hit `EEXIST` then read the empty file, `JSON.parse('')` threw, the lock was misclassified as corrupt, the winner's live lock was unlinked, and both servers ran `npm install` at once. Lock creation is now atomic via `fs.linkSync` (the payload is written to a private temp file in full, then atomically linked into place), so a winner's lock is always observed fully-written. As defence-in-depth `readLock` now distinguishes a transient empty mid-write file (sentinel `'EMPTY'` -- caller retries) from genuinely corrupt non-empty JSON (`null` -- safe to clear), and both `acquireInstallLock` and `waitForUnlock` treat an empty file as transient instead of as a cleared/dead lock.
|
|
21
|
+
- **False-stale reclaim of a healthy long install (`bug_001`).** The lock's `STALE_THRESHOLD_MS` was 90s, but `runGuardedInstall` gives `npm install` a 120s timeout -- a healthy install legitimately running 90-120s was declared abandoned, and because the staleness check used OR (`age > STALE || !pidAlive`) a peer reclaimed the *live* lock and started a second concurrent install. The threshold is raised to 180s (strictly above the 120s install timeout, 60s headroom) and the staleness check is now an AND-gate: a lock is reclaimed only when it is BOTH older than the threshold AND its owning pid is dead. `WAIT_TIMEOUT_MS` raised to 200s to stay above the new stale threshold.
|
|
22
|
+
- **Dependency probe too narrow (`bug_011`).** `ensureDepsPresent` probed only `['@modelcontextprotocol/sdk', 'zod']`. A partially-populated `node_modules` (those two present, `@modelcontextprotocol/ext-apps` or another production dep absent) passed the probe, no heal ran, and a bare `require` deeper in the `lib/mcp/*` chain then threw `MODULE_NOT_FOUND` at module-init scope and crashed the server. The probe now defaults to the FULL production dependency set read from the plugin's `package.json` (`Object.keys(pkg.dependencies)`), matching what `scripts/sessionstart-npm-reconcile.cjs` already does; a missing or unreadable `package.json` falls back to the MCP-critical pair rather than crashing. New regression suites: `lib/core/npm-install-lock.test.cjs` (18 tests) and `lib/core/mcp-dep-heal.test.cjs` (9 tests).
|
|
23
|
+
|
|
24
|
+
## [1.13.0-beta.22] - 2026-05-21
|
|
25
|
+
|
|
26
|
+
### Documentation
|
|
27
|
+
- **Brain-query moat guard recorded (Phase 127.1 Plan 05).** The moat-guard code shipped inside the v1.13.0-beta.21 tag but beta.21's changelog never recorded it. Backfilled here: on the `mcp-server-brain` Brain server, `brain_query` is now gated to the `admin` plan (D-MOAT-1), and four Cypher execution safeguards ported from the official Neo4j `mcp-neo4j-cypher` recipe bound every read the gate permits (EXPLAIN estimated-row reject, row cap, byte cap, read timeout; D-MOAT-2). The Brain's Neo4j was confirmed on the Aura Free tier (D-MOAT-4); a scoped `neo4j_reader` credential (D-MOAT-3) is deferred because Aura Free has no role-based access control. `mcp-server-brain/CLAUDE.md` carries the full "Brain-query moat guard" section. This is a Brain-server change, not shipped-plugin code; no plugin behavior changes in this beta.
|
|
28
|
+
|
|
29
|
+
### Internal
|
|
30
|
+
- **Phase 128.1 (session isolation) parked on branch `phase-128.1`.** Phase 128.1 is a v1.13.1 milestone phase. It was pulled off the v1.13.0 release line so the line stays clean; `main` carries zero 128.1 code. 128.1 is complete on its branch (6 of 6 plans) and ships in the v1.13.1-beta.3 band with phases 128 and 129.
|
|
31
|
+
|
|
1
32
|
## [1.13.0-beta.21] - 2026-05-20
|
|
2
33
|
|
|
3
34
|
### Added
|
|
@@ -24,9 +24,22 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
const path = require('path');
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
|
|
28
|
+
// -- Dependency self-heal (Option D, debug session
|
|
29
|
+
// mcp-servers-cache-missing-node-modules). `claude plugin update` can land a
|
|
30
|
+
// fresh plugin cache with NO node_modules; on the first post-update session
|
|
31
|
+
// this MCP server may spawn before the SessionStart reconcile hook finishes its
|
|
32
|
+
// npm install. mcp-dep-heal.cjs + npm-install-lock.cjs are pure node-built-in
|
|
33
|
+
// modules (safe to require with node_modules absent). ensureDepsPresent runs a
|
|
34
|
+
// guarded one-shot `npm install` if node_modules is missing/incomplete BEFORE
|
|
35
|
+
// the SDK/zod requires below; requireWithHeal is the per-require backstop.
|
|
36
|
+
const { ensureDepsPresent, requireWithHeal } = require('../lib/core/mcp-dep-heal.cjs');
|
|
37
|
+
const healLog = (msg) => { try { process.stderr.write(msg + '\n'); } catch (e) { /* swallow */ } };
|
|
38
|
+
ensureDepsPresent({ log: healLog });
|
|
39
|
+
|
|
40
|
+
const { McpServer } = requireWithHeal('@modelcontextprotocol/sdk/server/mcp.js', { log: healLog });
|
|
41
|
+
const { StdioServerTransport } = requireWithHeal('@modelcontextprotocol/sdk/server/stdio.js', { log: healLog });
|
|
42
|
+
const { z } = requireWithHeal('zod', { log: healLog });
|
|
30
43
|
|
|
31
44
|
const brainClient = require('../lib/core/brain-client.cjs');
|
|
32
45
|
const { wrapDirective } = require('../lib/core/directive-envelope.cjs');
|
|
@@ -36,8 +36,23 @@
|
|
|
36
36
|
|
|
37
37
|
const path = require('path');
|
|
38
38
|
const fs = require('fs');
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
|
|
40
|
+
// -- Dependency self-heal (Option D, debug session
|
|
41
|
+
// mcp-servers-cache-missing-node-modules). `claude plugin update` can land a
|
|
42
|
+
// fresh plugin cache with NO node_modules; on the first post-update session
|
|
43
|
+
// this MCP server may spawn before the SessionStart reconcile hook finishes its
|
|
44
|
+
// npm install. mcp-dep-heal.cjs + npm-install-lock.cjs are pure node-built-in
|
|
45
|
+
// modules (safe to require with node_modules absent). ensureDepsPresent runs a
|
|
46
|
+
// guarded one-shot `npm install` if node_modules is missing/incomplete BEFORE
|
|
47
|
+
// any npm-dependency require below (the SDK direct requires AND the transitive
|
|
48
|
+
// requires inside the lib/mcp/* modules). requireWithHeal is the per-require
|
|
49
|
+
// backstop, including the lazy express / streamableHttp requires in main().
|
|
50
|
+
const { ensureDepsPresent, requireWithHeal } = require('../lib/core/mcp-dep-heal.cjs');
|
|
51
|
+
const healLog = (msg) => { try { process.stderr.write(msg + '\n'); } catch (e) { /* swallow */ } };
|
|
52
|
+
ensureDepsPresent({ log: healLog });
|
|
53
|
+
|
|
54
|
+
const { McpServer } = requireWithHeal('@modelcontextprotocol/sdk/server/mcp.js', { log: healLog });
|
|
55
|
+
const { StdioServerTransport } = requireWithHeal('@modelcontextprotocol/sdk/server/stdio.js', { log: healLog });
|
|
41
56
|
const { detectSurface } = require('../lib/mcp/surface-detect.cjs');
|
|
42
57
|
const { registerCapabilities } = require('../lib/mcp/capability-registry.cjs');
|
|
43
58
|
const { computeCatchUp, registerShutdownHandler } = require('../lib/mcp/session-catchup.cjs');
|
|
@@ -80,7 +95,7 @@ registerRouterTools(server, roomDir, pluginRoot, larryContext);
|
|
|
80
95
|
// claims and route filing through Phase 109 navigation.cjs
|
|
81
96
|
// Both wrap pure lib/core entries; safe for Desktop/Cowork stdio transport.
|
|
82
97
|
// -----------------------------------------------------------------------------
|
|
83
|
-
const { z } =
|
|
98
|
+
const { z } = requireWithHeal('zod', { log: healLog });
|
|
84
99
|
const dualPathDetector = require('../lib/core/dual-path-detector.cjs');
|
|
85
100
|
const shallowDocParser = require('../lib/core/shallow-doc-parser.cjs');
|
|
86
101
|
|
package/hooks/hooks.json
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/
|
|
10
|
-
"timeout":
|
|
9
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/sessionstart-npm-reconcile.cjs\"",
|
|
10
|
+
"timeout": 120000,
|
|
11
11
|
"async": false,
|
|
12
|
-
"statusMessage": "
|
|
12
|
+
"statusMessage": "Reconciling dependencies..."
|
|
13
13
|
}
|
|
14
14
|
]
|
|
15
15
|
},
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"hooks": [
|
|
19
19
|
{
|
|
20
20
|
"type": "command",
|
|
21
|
-
"command": "
|
|
21
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start",
|
|
22
22
|
"timeout": 10000,
|
|
23
23
|
"async": false,
|
|
24
24
|
"statusMessage": "Loading room context..."
|
|
@@ -30,10 +30,10 @@
|
|
|
30
30
|
"hooks": [
|
|
31
31
|
{
|
|
32
32
|
"type": "command",
|
|
33
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/sessionstart-
|
|
34
|
-
"timeout":
|
|
35
|
-
"async":
|
|
36
|
-
"statusMessage": "
|
|
33
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/sessionstart-coordinator.cjs\"",
|
|
34
|
+
"timeout": 10000,
|
|
35
|
+
"async": false,
|
|
36
|
+
"statusMessage": "Loading room context..."
|
|
37
37
|
}
|
|
38
38
|
]
|
|
39
39
|
},
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Copyright (c) 2026 Mindrian. BSL 1.1.
|
|
6
|
+
*
|
|
7
|
+
* MindrianOS Plugin -- MCP dependency self-heal (Option D, hybrid self-heal).
|
|
8
|
+
*
|
|
9
|
+
* THE PROBLEM (debug session mcp-servers-cache-missing-node-modules):
|
|
10
|
+
* `claude plugin update` lands a fresh plugin cache directory with NO
|
|
11
|
+
* node_modules. The SessionStart reconcile hook (scripts/sessionstart-npm-
|
|
12
|
+
* reconcile.cjs) repairs it -- but on the FIRST post-update session Claude Code
|
|
13
|
+
* spawns the bundled MCP servers (.mcp.json, alwaysLoad) at a moment that can
|
|
14
|
+
* precede the hook's npm install finishing. The servers then crash at module
|
|
15
|
+
* load with MODULE_NOT_FOUND for @modelcontextprotocol/sdk.
|
|
16
|
+
*
|
|
17
|
+
* THE FIX (this module): make each MCP entry point self-sufficient. Each server
|
|
18
|
+
* calls `requireWithHeal(...)` instead of bare `require(...)`. On a
|
|
19
|
+
* MODULE_NOT_FOUND it runs a ONE-SHOT synchronous `npm install` in the plugin
|
|
20
|
+
* cache root, then re-requires. Combined with flipping the reconcile hook to
|
|
21
|
+
* synchronous (async:false) in hooks.json, this closes the race from both ends:
|
|
22
|
+
* - healthy session: requireWithHeal succeeds first try, near-zero cost.
|
|
23
|
+
* - first post-update session: the hook usually wins; if it has not, the
|
|
24
|
+
* server heals itself before connecting its transport.
|
|
25
|
+
*
|
|
26
|
+
* RACE GUARD: both servers can spawn together. npm-install-lock.cjs guarantees
|
|
27
|
+
* exactly one runs `npm install` while the other WAITS, so two concurrent
|
|
28
|
+
* installs never corrupt node_modules.
|
|
29
|
+
*
|
|
30
|
+
* Canon Part 8: zero network surface. The only child process is `npm install`.
|
|
31
|
+
* No Brain calls, no external requests, no user data.
|
|
32
|
+
*
|
|
33
|
+
* Canon Part 7: reuse before build -- this mirrors the detection logic already
|
|
34
|
+
* in scripts/sessionstart-npm-reconcile.cjs (the hook) rather than inventing a
|
|
35
|
+
* new mechanism; it is the same `npm install --no-audit --no-fund --silent`
|
|
36
|
+
* invocation, wrapped for the require-time crash path.
|
|
37
|
+
*
|
|
38
|
+
* CROSS-PLATFORM (escalated mandate 2026-05-21): the npm invocation is resolved
|
|
39
|
+
* through lib/core/npm-cli-resolve.cjs, which runs npm via its absolute
|
|
40
|
+
* npm-cli.js entry off process.execPath -- correct on Windows (no `.cmd`
|
|
41
|
+
* dependency), Mac (no PATH dependency for GUI-launched Claude Code), and Linux.
|
|
42
|
+
* The bare spawnSync('npm') the prior fix used was dead on Windows and fragile
|
|
43
|
+
* on Mac. The self-heal here is the BACKSTOP; the primary guarantee is the
|
|
44
|
+
* vendored production node_modules shipped with the plugin (see CHANGELOG
|
|
45
|
+
* v1.13.0-beta.23) -- on a normal install ensureDepsPresent finds the deps
|
|
46
|
+
* already present and never spawns anything.
|
|
47
|
+
*
|
|
48
|
+
* HARD RULE: no em-dashes anywhere in this file (hyphens only).
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
const fs = require('node:fs');
|
|
52
|
+
const path = require('node:path');
|
|
53
|
+
const { spawnSync } = require('node:child_process');
|
|
54
|
+
|
|
55
|
+
const {
|
|
56
|
+
acquireInstallLock,
|
|
57
|
+
releaseInstallLock,
|
|
58
|
+
waitForUnlock,
|
|
59
|
+
} = require('./npm-install-lock.cjs');
|
|
60
|
+
const { resolveNpmCli, buildInstallArgs } = require('./npm-cli-resolve.cjs');
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the plugin cache root the install must run in. CLAUDE_PLUGIN_ROOT is
|
|
64
|
+
* set by Claude Code when it spawns plugin processes; the __dirname fallback
|
|
65
|
+
* (lib/core -> plugin root) covers manual / test invocation.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} [fallbackDir] - explicit override (used by callers / tests)
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function resolvePluginRoot(fallbackDir) {
|
|
71
|
+
return (
|
|
72
|
+
process.env.CLAUDE_PLUGIN_ROOT ||
|
|
73
|
+
process.env.MINDRIAN_OS_ROOT ||
|
|
74
|
+
fallbackDir ||
|
|
75
|
+
path.resolve(__dirname, '..', '..')
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Run `npm install` once in `dir`, guarded so two racing servers cannot run it
|
|
81
|
+
* concurrently. The loser waits for the winner instead.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} dir
|
|
84
|
+
* @returns {{ ran: boolean, waited: boolean, ok: boolean }}
|
|
85
|
+
*/
|
|
86
|
+
function runGuardedInstall(dir) {
|
|
87
|
+
const haveLock = acquireInstallLock(dir);
|
|
88
|
+
|
|
89
|
+
if (!haveLock) {
|
|
90
|
+
// Another live process is installing. Wait for it, then return without
|
|
91
|
+
// running our own install -- node_modules should now exist.
|
|
92
|
+
const cleared = waitForUnlock(dir);
|
|
93
|
+
return { ran: false, waited: true, ok: cleared };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
// Portable npm resolution: run npm via its absolute npm-cli.js off the
|
|
98
|
+
// current node binary (process.execPath). This is correct on Windows
|
|
99
|
+
// (no `.cmd` extension dependency), Mac (no PATH dependency), and Linux.
|
|
100
|
+
const npm = resolveNpmCli();
|
|
101
|
+
const result = spawnSync(
|
|
102
|
+
npm.command,
|
|
103
|
+
buildInstallArgs(npm),
|
|
104
|
+
{ cwd: dir, timeout: 120000, stdio: 'ignore', shell: npm.shell }
|
|
105
|
+
);
|
|
106
|
+
const ok = !!result && result.status === 0;
|
|
107
|
+
return { ran: true, waited: false, ok };
|
|
108
|
+
} catch (_) {
|
|
109
|
+
return { ran: true, waited: false, ok: false };
|
|
110
|
+
} finally {
|
|
111
|
+
releaseInstallLock(dir);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* require() a module; on MODULE_NOT_FOUND, run a one-shot guarded `npm install`
|
|
117
|
+
* in the plugin cache root and retry exactly once.
|
|
118
|
+
*
|
|
119
|
+
* Any non-MODULE_NOT_FOUND error is re-thrown immediately (a real bug, not a
|
|
120
|
+
* missing-dependency situation -- healing would not help).
|
|
121
|
+
*
|
|
122
|
+
* @param {string} moduleId - the module specifier to require
|
|
123
|
+
* @param {object} [opts]
|
|
124
|
+
* @param {string} [opts.pluginRoot] - explicit plugin cache root override
|
|
125
|
+
* @param {function} [opts.log] - sink for a one-line stderr breadcrumb
|
|
126
|
+
* @returns {*} the required module
|
|
127
|
+
*/
|
|
128
|
+
function requireWithHeal(moduleId, opts) {
|
|
129
|
+
opts = opts || {};
|
|
130
|
+
const log = typeof opts.log === 'function' ? opts.log : () => {};
|
|
131
|
+
try {
|
|
132
|
+
return require(moduleId);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (!err || err.code !== 'MODULE_NOT_FOUND') throw err;
|
|
135
|
+
|
|
136
|
+
const dir = resolvePluginRoot(opts.pluginRoot);
|
|
137
|
+
log('[mcp-dep-heal] missing dependency for ' + moduleId + '; self-healing npm install in ' + dir);
|
|
138
|
+
|
|
139
|
+
const outcome = runGuardedInstall(dir);
|
|
140
|
+
log(
|
|
141
|
+
'[mcp-dep-heal] install ' +
|
|
142
|
+
(outcome.waited ? 'waited-for-peer' : outcome.ran ? 'ran' : 'skipped') +
|
|
143
|
+
'; ok=' + outcome.ok
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Retry the require. If the peer-install or our own install succeeded the
|
|
147
|
+
// module now resolves. If it still fails, the error propagates -- the
|
|
148
|
+
// server crashes with a clear MODULE_NOT_FOUND, exactly as before, and the
|
|
149
|
+
// SessionStart reconcile hook is the remaining safety net for next session.
|
|
150
|
+
return require(moduleId);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The full production dependency set the plugin requires at runtime, read from
|
|
156
|
+
* the plugin's own package.json. This is the bug_011 fix: a probe limited to
|
|
157
|
+
* just ['@modelcontextprotocol/sdk', 'zod'] passes on a PARTIALLY-populated
|
|
158
|
+
* node_modules (sdk + zod present, @modelcontextprotocol/ext-apps or another
|
|
159
|
+
* dep absent), no heal runs, then a bare `require` deeper in the lib/mcp/*
|
|
160
|
+
* chain (capability-registry.cjs -> app-views.cjs -> ext-apps/server) throws
|
|
161
|
+
* MODULE_NOT_FOUND at module-init scope and crashes the server. Probing the
|
|
162
|
+
* full `dependencies` set -- exactly as scripts/sessionstart-npm-reconcile.cjs
|
|
163
|
+
* already does -- catches an incomplete tree before any require runs.
|
|
164
|
+
*
|
|
165
|
+
* Defensive: a missing or unreadable package.json yields the MCP-critical pair
|
|
166
|
+
* as a fallback rather than crashing -- the heal pre-flight must never throw.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} dir - resolved plugin cache root
|
|
169
|
+
* @returns {string[]} dependency names to stat-check
|
|
170
|
+
*/
|
|
171
|
+
function productionDepNames(dir) {
|
|
172
|
+
const fallback = ['@modelcontextprotocol/sdk', 'zod'];
|
|
173
|
+
try {
|
|
174
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
175
|
+
if (!fs.existsSync(pkgPath)) return fallback;
|
|
176
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
177
|
+
const names = Object.keys(pkg.dependencies || {});
|
|
178
|
+
return names.length ? names : fallback;
|
|
179
|
+
} catch (_) {
|
|
180
|
+
// Unreadable / unparseable package.json -- fall back gracefully.
|
|
181
|
+
return fallback;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Heal the plugin's node_modules up front, BEFORE any dependency require runs.
|
|
187
|
+
* Cheap pre-flight: a few stat() calls on a healthy box, the guarded install
|
|
188
|
+
* only on a genuinely-missing cache.
|
|
189
|
+
*
|
|
190
|
+
* MCP entry points call this once at the very top so the subsequent SDK / zod
|
|
191
|
+
* requires are guaranteed to resolve. Idempotent and defensive: any error is
|
|
192
|
+
* swallowed (the per-require requireWithHeal path remains as a second net).
|
|
193
|
+
*
|
|
194
|
+
* @param {object} [opts]
|
|
195
|
+
* @param {string} [opts.pluginRoot]
|
|
196
|
+
* @param {string[]} [opts.probe] - explicit dependency names to stat-check.
|
|
197
|
+
* When omitted, defaults to the FULL
|
|
198
|
+
* production dependency set from the plugin's
|
|
199
|
+
* package.json (bug_011) so a partially
|
|
200
|
+
* populated node_modules is detected, not just
|
|
201
|
+
* a totally absent one.
|
|
202
|
+
* @param {function} [opts.log]
|
|
203
|
+
* @returns {{ healed: boolean, ok: boolean }}
|
|
204
|
+
*/
|
|
205
|
+
function ensureDepsPresent(opts) {
|
|
206
|
+
opts = opts || {};
|
|
207
|
+
const log = typeof opts.log === 'function' ? opts.log : () => {};
|
|
208
|
+
const dir = resolvePluginRoot(opts.pluginRoot);
|
|
209
|
+
const probe = Array.isArray(opts.probe) && opts.probe.length
|
|
210
|
+
? opts.probe
|
|
211
|
+
: productionDepNames(dir);
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const nm = path.join(dir, 'node_modules');
|
|
215
|
+
let missing = false;
|
|
216
|
+
if (!fs.existsSync(nm)) {
|
|
217
|
+
missing = true;
|
|
218
|
+
} else {
|
|
219
|
+
for (const dep of probe) {
|
|
220
|
+
if (!fs.existsSync(path.join(nm, ...dep.split('/')))) { missing = true; break; }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (!missing) return { healed: false, ok: true };
|
|
224
|
+
|
|
225
|
+
log('[mcp-dep-heal] node_modules missing/incomplete; self-healing npm install in ' + dir);
|
|
226
|
+
const outcome = runGuardedInstall(dir);
|
|
227
|
+
log(
|
|
228
|
+
'[mcp-dep-heal] install ' +
|
|
229
|
+
(outcome.waited ? 'waited-for-peer' : 'ran') +
|
|
230
|
+
'; ok=' + outcome.ok
|
|
231
|
+
);
|
|
232
|
+
return { healed: true, ok: outcome.ok };
|
|
233
|
+
} catch (_) {
|
|
234
|
+
// Never let the heal pre-flight itself crash the server -- requireWithHeal
|
|
235
|
+
// is the backstop.
|
|
236
|
+
return { healed: false, ok: false };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = {
|
|
241
|
+
requireWithHeal,
|
|
242
|
+
ensureDepsPresent,
|
|
243
|
+
runGuardedInstall,
|
|
244
|
+
resolvePluginRoot,
|
|
245
|
+
productionDepNames,
|
|
246
|
+
};
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Copyright (c) 2026 Mindrian. BSL 1.1.
|
|
6
|
+
*
|
|
7
|
+
* Regression tests for lib/core/mcp-dep-heal.cjs -- focused on the bug_011
|
|
8
|
+
* fix folded into v1.13.0-beta.23.
|
|
9
|
+
*
|
|
10
|
+
* bug_011 -- dependency probe too narrow.
|
|
11
|
+
* ensureDepsPresent's probe defaulted to only ['@modelcontextprotocol/sdk',
|
|
12
|
+
* 'zod']. A partially-populated node_modules (sdk + zod present, but
|
|
13
|
+
* @modelcontextprotocol/ext-apps or another production dep absent) PASSED the
|
|
14
|
+
* probe, no heal ran, and a bare `require` deeper in the lib/mcp/* chain
|
|
15
|
+
* (capability-registry.cjs -> app-views.cjs -> ext-apps/server) then threw
|
|
16
|
+
* MODULE_NOT_FOUND at module-init scope and crashed the server.
|
|
17
|
+
*
|
|
18
|
+
* The fix: when no explicit `probe` is supplied, ensureDepsPresent defaults
|
|
19
|
+
* to the FULL production dependency set read from the plugin's package.json
|
|
20
|
+
* (Object.keys(pkg.dependencies)), exactly as scripts/sessionstart-npm-
|
|
21
|
+
* reconcile.cjs already does. A missing / unreadable package.json must fall
|
|
22
|
+
* back gracefully, never crash.
|
|
23
|
+
*
|
|
24
|
+
* These tests exercise productionDepNames directly and ensureDepsPresent
|
|
25
|
+
* against synthetic plugin roots so no real `npm install` is ever spawned.
|
|
26
|
+
*
|
|
27
|
+
* HARD RULE: no em-dashes.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const assert = require('node:assert/strict');
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const os = require('node:os');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
|
|
35
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
36
|
+
const MODULE_PATH = path.join(REPO_ROOT, 'lib', 'core', 'mcp-dep-heal.cjs');
|
|
37
|
+
const { productionDepNames, ensureDepsPresent } = require(MODULE_PATH);
|
|
38
|
+
|
|
39
|
+
const FALLBACK = ['@modelcontextprotocol/sdk', 'zod'];
|
|
40
|
+
|
|
41
|
+
let passed = 0;
|
|
42
|
+
let failed = 0;
|
|
43
|
+
|
|
44
|
+
function ok(name) {
|
|
45
|
+
passed += 1;
|
|
46
|
+
process.stdout.write(' ok ' + name + '\n');
|
|
47
|
+
}
|
|
48
|
+
function fail(name, err) {
|
|
49
|
+
failed += 1;
|
|
50
|
+
process.stdout.write(' FAIL ' + name + '\n');
|
|
51
|
+
process.stdout.write(' ' + (err && err.message ? err.message : String(err)) + '\n');
|
|
52
|
+
}
|
|
53
|
+
function test(name, fn) {
|
|
54
|
+
try { fn(); ok(name); } catch (err) { fail(name, err); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function tmpdir() {
|
|
58
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'mos-dep-heal-test-'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build a synthetic plugin root: a package.json + a node_modules tree
|
|
63
|
+
* containing exactly `present` (a subset of the declared deps).
|
|
64
|
+
*/
|
|
65
|
+
function makeRoot(deps, present) {
|
|
66
|
+
const dir = tmpdir();
|
|
67
|
+
fs.writeFileSync(
|
|
68
|
+
path.join(dir, 'package.json'),
|
|
69
|
+
JSON.stringify({ name: 'fake', version: '0.0.0', dependencies: deps }, null, 2)
|
|
70
|
+
);
|
|
71
|
+
const nm = path.join(dir, 'node_modules');
|
|
72
|
+
fs.mkdirSync(nm, { recursive: true });
|
|
73
|
+
for (const name of present || []) {
|
|
74
|
+
fs.mkdirSync(path.join(nm, ...name.split('/')), { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
return dir;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- productionDepNames ----------------------------------------------------
|
|
80
|
+
|
|
81
|
+
// bug_011 core: productionDepNames reads the FULL dependencies set, not the
|
|
82
|
+
// 2-element MCP-critical pair.
|
|
83
|
+
test('bug_011: productionDepNames returns the full dependency set from package.json', () => {
|
|
84
|
+
const deps = {
|
|
85
|
+
'@modelcontextprotocol/sdk': '^1.29.0',
|
|
86
|
+
'@modelcontextprotocol/ext-apps': '^1.5.0',
|
|
87
|
+
zod: '^3.25.76',
|
|
88
|
+
express: '^5.2.1',
|
|
89
|
+
};
|
|
90
|
+
const dir = makeRoot(deps, []);
|
|
91
|
+
try {
|
|
92
|
+
const names = productionDepNames(dir);
|
|
93
|
+
assert.deepEqual(
|
|
94
|
+
names.slice().sort(),
|
|
95
|
+
Object.keys(deps).slice().sort(),
|
|
96
|
+
'must return every declared production dependency'
|
|
97
|
+
);
|
|
98
|
+
assert.ok(
|
|
99
|
+
names.indexOf('@modelcontextprotocol/ext-apps') !== -1,
|
|
100
|
+
'ext-apps -- the dep bug_011 cited -- must be in the probe set'
|
|
101
|
+
);
|
|
102
|
+
assert.ok(names.length > FALLBACK.length, 'full set must be broader than the 2-dep fallback');
|
|
103
|
+
} finally {
|
|
104
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// productionDepNames matches the REAL plugin package.json when given the repo
|
|
109
|
+
// root -- proving the live MCP entry points get the full probe.
|
|
110
|
+
test('bug_011: productionDepNames matches the real plugin package.json', () => {
|
|
111
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8'));
|
|
112
|
+
const expected = Object.keys(pkg.dependencies || {});
|
|
113
|
+
const names = productionDepNames(REPO_ROOT);
|
|
114
|
+
assert.deepEqual(names.slice().sort(), expected.slice().sort(),
|
|
115
|
+
'live probe set must equal the plugin package.json dependencies');
|
|
116
|
+
assert.ok(names.indexOf('@modelcontextprotocol/ext-apps') !== -1,
|
|
117
|
+
'the real probe set must include ext-apps');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Graceful fallback: a missing package.json must not crash, returns the pair.
|
|
121
|
+
test('bug_011: productionDepNames falls back gracefully when package.json is missing', () => {
|
|
122
|
+
const dir = tmpdir(); // no package.json written
|
|
123
|
+
try {
|
|
124
|
+
const names = productionDepNames(dir);
|
|
125
|
+
assert.deepEqual(names, FALLBACK, 'missing package.json => MCP-critical fallback');
|
|
126
|
+
} finally {
|
|
127
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Graceful fallback: an unparseable package.json must not crash.
|
|
132
|
+
test('bug_011: productionDepNames falls back gracefully on an unparseable package.json', () => {
|
|
133
|
+
const dir = tmpdir();
|
|
134
|
+
fs.writeFileSync(path.join(dir, 'package.json'), '{ this is : not json');
|
|
135
|
+
try {
|
|
136
|
+
const names = productionDepNames(dir);
|
|
137
|
+
assert.deepEqual(names, FALLBACK, 'corrupt package.json => MCP-critical fallback');
|
|
138
|
+
} finally {
|
|
139
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Fallback: a package.json with no dependencies block returns the pair (the
|
|
144
|
+
// install machinery still has something MCP-critical to probe).
|
|
145
|
+
test('bug_011: productionDepNames falls back when dependencies is empty', () => {
|
|
146
|
+
const dir = tmpdir();
|
|
147
|
+
fs.writeFileSync(path.join(dir, 'package.json'),
|
|
148
|
+
JSON.stringify({ name: 'fake', version: '0.0.0' }));
|
|
149
|
+
try {
|
|
150
|
+
const names = productionDepNames(dir);
|
|
151
|
+
assert.deepEqual(names, FALLBACK, 'no dependencies block => MCP-critical fallback');
|
|
152
|
+
} finally {
|
|
153
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// --- ensureDepsPresent probe behavior -------------------------------------
|
|
158
|
+
|
|
159
|
+
// THE decisive bug_011 test: a partially-populated node_modules (sdk + zod
|
|
160
|
+
// present, ext-apps ABSENT) must be detected as incomplete. Pre-fix the narrow
|
|
161
|
+
// probe passed and no heal ran. We pass a no-op runner via the probe so no real
|
|
162
|
+
// `npm install` is spawned -- instead we assert the missing-detection by
|
|
163
|
+
// inspecting what ensureDepsPresent decides.
|
|
164
|
+
test('bug_011: ensureDepsPresent detects a partial tree (sdk+zod present, ext-apps absent)', () => {
|
|
165
|
+
const deps = {
|
|
166
|
+
'@modelcontextprotocol/sdk': '^1.29.0',
|
|
167
|
+
'@modelcontextprotocol/ext-apps': '^1.5.0',
|
|
168
|
+
zod: '^3.25.76',
|
|
169
|
+
};
|
|
170
|
+
// node_modules has sdk + zod but NOT ext-apps -- the exact bug_011 scenario.
|
|
171
|
+
const dir = makeRoot(deps, ['@modelcontextprotocol/sdk', 'zod']);
|
|
172
|
+
try {
|
|
173
|
+
// With the narrow 2-dep probe (the pre-fix default) this tree looks
|
|
174
|
+
// healthy. Confirm that first, to prove the bug was real.
|
|
175
|
+
let narrowSawMissing = false;
|
|
176
|
+
for (const d of FALLBACK) {
|
|
177
|
+
if (!fs.existsSync(path.join(dir, 'node_modules', ...d.split('/')))) {
|
|
178
|
+
narrowSawMissing = true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
assert.equal(narrowSawMissing, false,
|
|
182
|
+
'pre-condition: the narrow sdk+zod probe would (wrongly) see this tree as healthy');
|
|
183
|
+
|
|
184
|
+
// The full probe -- the fix -- must see ext-apps as missing.
|
|
185
|
+
const fullProbe = productionDepNames(dir);
|
|
186
|
+
let fullSawMissing = false;
|
|
187
|
+
for (const d of fullProbe) {
|
|
188
|
+
if (!fs.existsSync(path.join(dir, 'node_modules', ...d.split('/')))) {
|
|
189
|
+
fullSawMissing = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
assert.equal(fullSawMissing, true,
|
|
193
|
+
'the full-dep-set probe MUST detect the absent ext-apps');
|
|
194
|
+
|
|
195
|
+
// ensureDepsPresent with an explicit no-op probe set (deps already present)
|
|
196
|
+
// is a clean no-op -- confirms the explicit-probe path still works and
|
|
197
|
+
// never spawns when nothing is missing.
|
|
198
|
+
const res = ensureDepsPresent({ pluginRoot: dir, probe: ['@modelcontextprotocol/sdk', 'zod'] });
|
|
199
|
+
assert.equal(res.healed, false, 'explicit probe of present-only deps => no heal');
|
|
200
|
+
assert.equal(res.ok, true);
|
|
201
|
+
} finally {
|
|
202
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ensureDepsPresent on a fully-healthy tree (every declared dep present) is a
|
|
207
|
+
// pure no-op -- it must not spawn an install.
|
|
208
|
+
test('bug_011: ensureDepsPresent is a no-op when every production dep is present', () => {
|
|
209
|
+
const deps = {
|
|
210
|
+
'@modelcontextprotocol/sdk': '^1.29.0',
|
|
211
|
+
zod: '^3.25.76',
|
|
212
|
+
express: '^5.2.1',
|
|
213
|
+
};
|
|
214
|
+
const dir = makeRoot(deps, Object.keys(deps));
|
|
215
|
+
try {
|
|
216
|
+
const res = ensureDepsPresent({ pluginRoot: dir });
|
|
217
|
+
assert.equal(res.healed, false, 'a complete tree must not trigger a heal');
|
|
218
|
+
assert.equal(res.ok, true, 'a complete tree reports ok');
|
|
219
|
+
} finally {
|
|
220
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ensureDepsPresent must never crash on a pathological plugin root. A root
|
|
225
|
+
// with no package.json falls back to the MCP-critical pair; we pre-populate
|
|
226
|
+
// node_modules with that pair so the assertion stays on the no-throw contract
|
|
227
|
+
// without spawning a real `npm install` in the test.
|
|
228
|
+
test('bug_011: ensureDepsPresent never throws on a pathological plugin root', () => {
|
|
229
|
+
const dir = tmpdir(); // no package.json -- productionDepNames -> FALLBACK
|
|
230
|
+
const nm = path.join(dir, 'node_modules');
|
|
231
|
+
fs.mkdirSync(nm, { recursive: true });
|
|
232
|
+
for (const d of FALLBACK) {
|
|
233
|
+
fs.mkdirSync(path.join(nm, ...d.split('/')), { recursive: true });
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
const res = ensureDepsPresent({ pluginRoot: dir });
|
|
237
|
+
assert.ok(res && typeof res.healed === 'boolean' && typeof res.ok === 'boolean',
|
|
238
|
+
'must return a well-formed result object, never throw');
|
|
239
|
+
assert.equal(res.healed, false, 'fallback pair present => no heal, no install spawn');
|
|
240
|
+
} finally {
|
|
241
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// HARD RULE: no em-dashes in the module.
|
|
246
|
+
test('mcp-dep-heal.cjs has no em-dashes', () => {
|
|
247
|
+
const src = fs.readFileSync(MODULE_PATH, 'utf8');
|
|
248
|
+
const EM_DASH = String.fromCharCode(0x2014);
|
|
249
|
+
assert.ok(src.indexOf(EM_DASH) === -1, 'em-dash found in mcp-dep-heal.cjs');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
process.stdout.write('\nmcp-dep-heal: ' + passed + ' passed, ' + failed + ' failed\n');
|
|
253
|
+
process.exit(failed === 0 ? 0 : 1);
|