@getmarrow/install 0.1.66 → 0.1.68
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/README.md +174 -286
- package/package.json +1 -1
- package/src/controller-manager.js +3 -1
- package/src/first-hour.js +1 -1
- package/src/governed-runner.js +1978 -114
- package/src/installer.js +821 -72
- package/src/mcp-runtime.js +447 -0
- package/src/pins.js +19 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A private local copy of the pinned @getmarrow/mcp, so hooks start it directly instead of
|
|
4
|
+
// through npx (npx costs 0.6 to 1.3 s per gated tool call before Marrow is contacted).
|
|
5
|
+
//
|
|
6
|
+
// Layout (all owner-only, directories 0700):
|
|
7
|
+
// ~/.marrow/runtime/mcp/<version>/node_modules/... the package, installed with --ignore-scripts
|
|
8
|
+
// ~/.marrow/runtime/mcp/<version>/.verified integrity record (0600)
|
|
9
|
+
// ~/.marrow/runtime/mcp/<version>/launch.cjs checks the files, then runs the entrypoint
|
|
10
|
+
// ~/.marrow/runtime/mcp/<version>/run /bin/sh script: absolute node + launch.cjs
|
|
11
|
+
// ~/.marrow/runtime/mcp/<version>/node link to the node binary captured at install
|
|
12
|
+
//
|
|
13
|
+
// The package's integrity is checked against the pin (or the registry-verified update target)
|
|
14
|
+
// when it is installed. Every start re-checks the installed files against the record; if the
|
|
15
|
+
// copy is missing, changed or its node is gone, the same entrypoint runs through npx exactly as
|
|
16
|
+
// before, so a hook keeps its fail-closed behaviour. Hook commands reach the runtime through
|
|
17
|
+
// $HOME, so project hook files stay the same for every user and name no local path.
|
|
18
|
+
|
|
19
|
+
const crypto = require('node:crypto');
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { spawnSync } = require('node:child_process');
|
|
23
|
+
|
|
24
|
+
const RUNTIME_SCHEMA = 'marrow-mcp-runtime.v1';
|
|
25
|
+
const VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
26
|
+
const INTEGRITY_RE = /^sha512-[A-Za-z0-9+/]{86}==$/;
|
|
27
|
+
// Paths written into the run script, quoted with single quotes: no quote, backslash or newline.
|
|
28
|
+
const SAFE_PATH_RE = /^[^'\\\n\r\0]+$/;
|
|
29
|
+
// A black-holed registry must not hold an install for minutes: past this, hooks stay on npx.
|
|
30
|
+
const NPM_INSTALL_TIMEOUT_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
function currentUid() {
|
|
33
|
+
return typeof process.getuid === 'function' ? process.getuid() : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function runtimeRoot(home) {
|
|
37
|
+
return path.join(home, '.marrow', 'runtime', 'mcp');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function runtimeDir(home, version) {
|
|
41
|
+
return path.join(runtimeRoot(home), version);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A directory Marrow owns: a real directory (not a link) owned by this user, not writable by
|
|
45
|
+
// others; created 0700 when missing.
|
|
46
|
+
function ensurePrivateDirectory(directory) {
|
|
47
|
+
if (!fs.existsSync(directory)) fs.mkdirSync(directory, { mode: 0o700 });
|
|
48
|
+
const stat = fs.lstatSync(directory);
|
|
49
|
+
const uid = currentUid();
|
|
50
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (uid !== null && stat.uid !== uid)) {
|
|
51
|
+
throw new Error(`unsafe runtime directory ${directory}`);
|
|
52
|
+
}
|
|
53
|
+
if ((stat.mode & 0o077) !== 0) fs.chmodSync(directory, 0o700);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function privateDirectoryOk(directory) {
|
|
57
|
+
try {
|
|
58
|
+
const stat = fs.lstatSync(directory);
|
|
59
|
+
const uid = currentUid();
|
|
60
|
+
return stat.isDirectory() && !stat.isSymbolicLink() && (uid === null || stat.uid === uid) && (stat.mode & 0o077) === 0;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function listFiles(base, relative = '') {
|
|
67
|
+
const directory = path.join(base, relative);
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
70
|
+
const rel = relative ? `${relative}/${entry.name}` : entry.name;
|
|
71
|
+
if (rel === 'node_modules/.bin') continue;
|
|
72
|
+
if (entry.isSymbolicLink()) throw new Error(`unexpected link in runtime package: ${rel}`);
|
|
73
|
+
if (entry.isDirectory()) out.push(...listFiles(base, rel));
|
|
74
|
+
else if (entry.isFile()) out.push(rel);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The installed package files, as the launcher hashes them on every start (npm's own lock copy
|
|
80
|
+
// and any .bin links are not package files; the runtime is never started through .bin).
|
|
81
|
+
function packageFiles(directory) {
|
|
82
|
+
return listFiles(directory, 'node_modules').filter((file) => file !== 'node_modules/.package-lock.json');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function manifestDigest(directory, files) {
|
|
86
|
+
const hash = crypto.createHash('sha256');
|
|
87
|
+
for (const file of files) {
|
|
88
|
+
hash.update(file);
|
|
89
|
+
hash.update('\0');
|
|
90
|
+
hash.update(fs.readFileSync(path.join(directory, file)));
|
|
91
|
+
hash.update('\0');
|
|
92
|
+
}
|
|
93
|
+
return hash.digest('hex');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const LAUNCHER_SOURCE = `'use strict';
|
|
97
|
+
// Written by @getmarrow/install. Runs the verified local @getmarrow/mcp. When the copy is
|
|
98
|
+
// missing or any of its files changed, the same entrypoint runs through npx instead.
|
|
99
|
+
const fs = require('fs');
|
|
100
|
+
const path = require('path');
|
|
101
|
+
const crypto = require('crypto');
|
|
102
|
+
const args = process.argv.slice(2);
|
|
103
|
+
// Every package file now present, listed as at install: an added file is a change too.
|
|
104
|
+
function listFiles(relative) {
|
|
105
|
+
const out = [];
|
|
106
|
+
const entries = fs.readdirSync(path.join(__dirname, relative), { withFileTypes: true })
|
|
107
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
const rel = relative + '/' + entry.name;
|
|
110
|
+
if (rel === 'node_modules/.bin') continue;
|
|
111
|
+
if (entry.isSymbolicLink()) throw new Error('link');
|
|
112
|
+
if (entry.isDirectory()) out.push(...listFiles(rel));
|
|
113
|
+
else if (entry.isFile()) out.push(rel);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
function verifiedEntry() {
|
|
118
|
+
try {
|
|
119
|
+
const record = JSON.parse(fs.readFileSync(path.join(__dirname, '.verified'), 'utf8'));
|
|
120
|
+
if (record.schema !== ${JSON.stringify(RUNTIME_SCHEMA)}) return null;
|
|
121
|
+
if (args[0] !== '--package=@getmarrow/mcp@' + record.version || args[1] !== 'marrow-mcp') return null;
|
|
122
|
+
const present = listFiles('node_modules').filter((file) => file !== 'node_modules/.package-lock.json');
|
|
123
|
+
if (JSON.stringify(present) !== JSON.stringify(record.files)) return null;
|
|
124
|
+
const hash = crypto.createHash('sha256');
|
|
125
|
+
for (const file of record.files) {
|
|
126
|
+
hash.update(file);
|
|
127
|
+
hash.update('\\0');
|
|
128
|
+
hash.update(fs.readFileSync(path.join(__dirname, file)));
|
|
129
|
+
hash.update('\\0');
|
|
130
|
+
}
|
|
131
|
+
return hash.digest('hex') === record.manifest_sha256 ? path.join(__dirname, record.bin) : null;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const entry = verifiedEntry();
|
|
137
|
+
if (entry) {
|
|
138
|
+
process.argv = [process.argv[0], entry, ...args.slice(2)];
|
|
139
|
+
require(entry);
|
|
140
|
+
} else {
|
|
141
|
+
const { spawn } = require('child_process');
|
|
142
|
+
const child = spawn(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['-y', ...args], { stdio: 'inherit' });
|
|
143
|
+
child.on('error', () => process.exit(1));
|
|
144
|
+
child.on('exit', (code, signal) => {
|
|
145
|
+
if (signal) process.kill(process.pid, signal);
|
|
146
|
+
else process.exit(code === null ? 1 : code);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
`;
|
|
150
|
+
|
|
151
|
+
function runScript(nodePath, launcherPath) {
|
|
152
|
+
return [
|
|
153
|
+
'#!/bin/sh',
|
|
154
|
+
'# Written by @getmarrow/install: the verified local @getmarrow/mcp, or npx when it is gone.',
|
|
155
|
+
`if [ -x '${nodePath}' ] && [ -f '${launcherPath}' ]; then exec '${nodePath}' '${launcherPath}' "$@"; fi`,
|
|
156
|
+
'exec npx -y "$@"',
|
|
157
|
+
'',
|
|
158
|
+
].join('\n');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readRecord(directory) {
|
|
162
|
+
try {
|
|
163
|
+
const stat = fs.lstatSync(path.join(directory, '.verified'));
|
|
164
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) return null;
|
|
165
|
+
const record = JSON.parse(fs.readFileSync(path.join(directory, '.verified'), 'utf8'));
|
|
166
|
+
return record && record.schema === RUNTIME_SCHEMA ? record : null;
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The runtime for one version, when its record matches the expected integrity and its files
|
|
173
|
+
// still hash to the record. Returns null otherwise.
|
|
174
|
+
function verifyMcpRuntime(home, version, integrity) {
|
|
175
|
+
if (!home || !VERSION_RE.test(String(version || ''))) return null;
|
|
176
|
+
const directory = runtimeDir(home, version);
|
|
177
|
+
if (![path.join(home, '.marrow'), path.dirname(runtimeRoot(home)), runtimeRoot(home), directory].every(privateDirectoryOk)) return null;
|
|
178
|
+
const record = readRecord(directory);
|
|
179
|
+
if (!record || record.version !== version) return null;
|
|
180
|
+
if (integrity && record.integrity !== integrity) return null;
|
|
181
|
+
try {
|
|
182
|
+
const files = packageFiles(directory);
|
|
183
|
+
if (JSON.stringify(files) !== JSON.stringify(record.files)) return null;
|
|
184
|
+
if (manifestDigest(directory, files) !== record.manifest_sha256) return null;
|
|
185
|
+
if (fs.readFileSync(path.join(directory, 'launch.cjs'), 'utf8') !== LAUNCHER_SOURCE) return null;
|
|
186
|
+
const runStat = fs.statSync(path.join(directory, 'run'));
|
|
187
|
+
if (!runStat.isFile() || (runStat.mode & 0o100) === 0) return null;
|
|
188
|
+
const nodeTarget = fs.readlinkSync(path.join(directory, 'node'));
|
|
189
|
+
if (fs.readFileSync(path.join(directory, 'run'), 'utf8') !== runScript(nodeTarget, path.join(directory, 'launch.cjs'))) return null;
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
return { state: 'verified', version, directory, run: path.join(directory, 'run'), node: path.join(directory, 'node') };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function defaultNpmInstall(stage, specs) {
|
|
197
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
198
|
+
const result = spawnSync(npm, ['install', '--no-audit', '--no-fund', '--ignore-scripts', '--no-bin-links', '--loglevel=error', ...specs], {
|
|
199
|
+
cwd: stage,
|
|
200
|
+
env: process.env,
|
|
201
|
+
stdio: 'ignore',
|
|
202
|
+
timeout: NPM_INSTALL_TIMEOUT_MS,
|
|
203
|
+
});
|
|
204
|
+
return result.status === 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function removeQuietly(target) {
|
|
208
|
+
try {
|
|
209
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
210
|
+
} catch {
|
|
211
|
+
// Best effort: a leftover directory is cleaned on the next install or update.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Removes every runtime version except `keep` (and leftover staging directories).
|
|
216
|
+
function removeOtherRuntimes(home, keep) {
|
|
217
|
+
const root = runtimeRoot(home);
|
|
218
|
+
const removed = [];
|
|
219
|
+
let entries = [];
|
|
220
|
+
try {
|
|
221
|
+
entries = fs.readdirSync(root);
|
|
222
|
+
} catch {
|
|
223
|
+
return removed;
|
|
224
|
+
}
|
|
225
|
+
for (const entry of entries) {
|
|
226
|
+
if (entry === keep) continue;
|
|
227
|
+
removeQuietly(path.join(root, entry));
|
|
228
|
+
removed.push(entry);
|
|
229
|
+
}
|
|
230
|
+
return removed;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Installs (or keeps) the runtime for one MCP version whose tarball integrity is known.
|
|
234
|
+
// `sdk` is the pinned SDK { version, integrity }; when the installed SDK has that version, its
|
|
235
|
+
// integrity must match too. Never throws: a failure leaves hooks on npx.
|
|
236
|
+
function ensureMcpRuntime({ home, version, integrity, sdk = null, nodePath = process.execPath, npmInstall = defaultNpmInstall, platform = process.platform, onProgress = null } = {}) {
|
|
237
|
+
if (platform === 'win32') return { state: 'unsupported', reason: 'windows' };
|
|
238
|
+
if (!home || !VERSION_RE.test(String(version || '')) || !INTEGRITY_RE.test(String(integrity || ''))) {
|
|
239
|
+
return { state: 'skipped', reason: 'no_verified_integrity' };
|
|
240
|
+
}
|
|
241
|
+
const existing = verifyMcpRuntime(home, version, integrity);
|
|
242
|
+
if (existing) {
|
|
243
|
+
const removed = removeOtherRuntimes(home, version);
|
|
244
|
+
return { ...existing, state: 'present', removed_versions: removed };
|
|
245
|
+
}
|
|
246
|
+
const directory = runtimeDir(home, version);
|
|
247
|
+
if (!SAFE_PATH_RE.test(directory) || !SAFE_PATH_RE.test(String(nodePath || '')) || !path.isAbsolute(String(nodePath || ''))) {
|
|
248
|
+
return { state: 'skipped', reason: 'unsafe_path' };
|
|
249
|
+
}
|
|
250
|
+
let stage = null;
|
|
251
|
+
try {
|
|
252
|
+
ensurePrivateDirectory(path.join(home, '.marrow'));
|
|
253
|
+
ensurePrivateDirectory(path.dirname(runtimeRoot(home)));
|
|
254
|
+
ensurePrivateDirectory(runtimeRoot(home));
|
|
255
|
+
stage = path.join(runtimeRoot(home), `.stage-${version}-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
256
|
+
fs.mkdirSync(stage, { mode: 0o700 });
|
|
257
|
+
const dependencies = { '@getmarrow/mcp': version };
|
|
258
|
+
if (sdk && VERSION_RE.test(String(sdk.version || ''))) dependencies['@getmarrow/sdk'] = sdk.version;
|
|
259
|
+
fs.writeFileSync(path.join(stage, 'package.json'), `${JSON.stringify({ name: 'marrow-mcp-runtime', private: true, dependencies }, null, 2)}\n`, { mode: 0o600 });
|
|
260
|
+
const specs = Object.entries(dependencies).map(([name, spec]) => `${name}@${spec}`);
|
|
261
|
+
if (typeof onProgress === 'function') {
|
|
262
|
+
onProgress(`Installing a local copy of @getmarrow/mcp@${version} so hooks start faster (up to ${NPM_INSTALL_TIMEOUT_MS / 1000} s; hooks keep using npx if it does not finish)...\n`);
|
|
263
|
+
}
|
|
264
|
+
if (!npmInstall(stage, specs)) return { state: 'failed', reason: 'npm_install_failed' };
|
|
265
|
+
const lock = JSON.parse(fs.readFileSync(path.join(stage, 'package-lock.json'), 'utf8'));
|
|
266
|
+
const packages = lock && typeof lock.packages === 'object' && lock.packages ? lock.packages : {};
|
|
267
|
+
const mcp = packages['node_modules/@getmarrow/mcp'] || {};
|
|
268
|
+
if (mcp.version !== version || mcp.integrity !== integrity) return { state: 'failed', reason: 'integrity_mismatch' };
|
|
269
|
+
const sdkEntry = packages['node_modules/@getmarrow/sdk'];
|
|
270
|
+
if (sdk && sdkEntry && sdkEntry.version === sdk.version && sdkEntry.integrity !== sdk.integrity) {
|
|
271
|
+
return { state: 'failed', reason: 'sdk_integrity_mismatch' };
|
|
272
|
+
}
|
|
273
|
+
for (const [key, entry] of Object.entries(packages)) {
|
|
274
|
+
if (key === '') continue;
|
|
275
|
+
if (!entry || typeof entry.integrity !== 'string' || !/^sha512-/.test(entry.integrity)) return { state: 'failed', reason: 'unverified_dependency' };
|
|
276
|
+
}
|
|
277
|
+
const installed = JSON.parse(fs.readFileSync(path.join(stage, 'node_modules', '@getmarrow', 'mcp', 'package.json'), 'utf8'));
|
|
278
|
+
if (installed.name !== '@getmarrow/mcp' || installed.version !== version) return { state: 'failed', reason: 'package_mismatch' };
|
|
279
|
+
const binRelative = typeof installed.bin === 'object' && installed.bin ? installed.bin['marrow-mcp'] : null;
|
|
280
|
+
if (typeof binRelative !== 'string' || !/^[A-Za-z0-9._/-]+$/.test(binRelative) || binRelative.includes('..')) {
|
|
281
|
+
return { state: 'failed', reason: 'package_entry_missing' };
|
|
282
|
+
}
|
|
283
|
+
const bin = `node_modules/@getmarrow/mcp/${binRelative.replace(/^\.\//, '')}`;
|
|
284
|
+
const files = packageFiles(stage);
|
|
285
|
+
if (!files.includes(bin)) return { state: 'failed', reason: 'package_entry_missing' };
|
|
286
|
+
const launcher = path.join(directory, 'launch.cjs');
|
|
287
|
+
fs.writeFileSync(path.join(stage, 'launch.cjs'), LAUNCHER_SOURCE, { mode: 0o600 });
|
|
288
|
+
fs.writeFileSync(path.join(stage, 'run'), runScript(nodePath, launcher), { mode: 0o700 });
|
|
289
|
+
fs.symlinkSync(nodePath, path.join(stage, 'node'));
|
|
290
|
+
fs.writeFileSync(path.join(stage, '.verified'), `${JSON.stringify({
|
|
291
|
+
schema: RUNTIME_SCHEMA,
|
|
292
|
+
version,
|
|
293
|
+
integrity,
|
|
294
|
+
sdk_version: sdkEntry ? sdkEntry.version : null,
|
|
295
|
+
bin,
|
|
296
|
+
files,
|
|
297
|
+
manifest_sha256: manifestDigest(stage, files),
|
|
298
|
+
installed_at: new Date().toISOString(),
|
|
299
|
+
}, null, 2)}\n`, { mode: 0o600 });
|
|
300
|
+
removeQuietly(directory);
|
|
301
|
+
fs.renameSync(stage, directory);
|
|
302
|
+
stage = null;
|
|
303
|
+
const verified = verifyMcpRuntime(home, version, integrity);
|
|
304
|
+
if (!verified) return { state: 'failed', reason: 'verification_failed' };
|
|
305
|
+
const removed = removeOtherRuntimes(home, version);
|
|
306
|
+
return { ...verified, state: 'installed', removed_versions: removed };
|
|
307
|
+
} catch (error) {
|
|
308
|
+
return { state: 'failed', reason: error && /unsafe runtime directory/.test(error.message) ? 'unsafe_directory' : 'install_error' };
|
|
309
|
+
} finally {
|
|
310
|
+
if (stage) removeQuietly(stage);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function removeMcpRuntime(home, { dryRun = false } = {}) {
|
|
315
|
+
const root = path.dirname(runtimeRoot(home));
|
|
316
|
+
if (!fs.existsSync(root)) return { removed: false, path: root };
|
|
317
|
+
if (!dryRun) removeQuietly(root);
|
|
318
|
+
return { removed: !dryRun, would_remove: dryRun, path: root };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Hook commands: the canonical npx form <-> the local runtime form.
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
const ARGS = '--package=@getmarrow/mcp@(\\d+\\.\\d+\\.\\d+) marrow-mcp ([a-z][a-z-]{0,63})';
|
|
326
|
+
const RUN = (version) => `$HOME/.marrow/runtime/mcp/${version}/run`;
|
|
327
|
+
const NODE = (version) => `$HOME/.marrow/runtime/mcp/${version}/node`;
|
|
328
|
+
|
|
329
|
+
// 1. A plain entrypoint command.
|
|
330
|
+
function localPlain(version, sub) {
|
|
331
|
+
const args = `--package=@getmarrow/mcp@${version} marrow-mcp ${sub}`;
|
|
332
|
+
return `/bin/sh -c 'M="${RUN(version)}"; if [ -x "$M" ]; then exec "$M" ${args}; fi; exec npx -y ${args}'`;
|
|
333
|
+
}
|
|
334
|
+
const LOCAL_PLAIN_RE = new RegExp(`^/bin/sh -c 'M="\\$HOME/\\.marrow/runtime/mcp/(\\d+\\.\\d+\\.\\d+)/run"; if \\[ -x "\\$M" \\]; then exec "\\$M" ${ARGS}; fi; exec npx -y --package=@getmarrow/mcp@\\2 marrow-mcp \\3'$`);
|
|
335
|
+
const CANONICAL_PLAIN_RE = new RegExp(`^npx -y ${ARGS}$`);
|
|
336
|
+
|
|
337
|
+
// 2. An entrypoint inside an `sh -c '...'` wrapper (Windsurf, Gemini).
|
|
338
|
+
function localInner(version, sub) {
|
|
339
|
+
const args = `--package=@getmarrow/mcp@${version} marrow-mcp ${sub}`;
|
|
340
|
+
return `{ M="${RUN(version)}"; if [ -x "$M" ]; then "$M" ${args}; else npx -y ${args}; fi; }`;
|
|
341
|
+
}
|
|
342
|
+
const LOCAL_INNER_RE = new RegExp(`\\{ M="\\$HOME/\\.marrow/runtime/mcp/(\\d+\\.\\d+\\.\\d+)/run"; if \\[ -x "\\$M" \\]; then "\\$M" ${ARGS}; else npx -y --package=@getmarrow/mcp@\\2 marrow-mcp \\3; fi; \\}`, 'g');
|
|
343
|
+
const CANONICAL_INNER_RE = new RegExp(`npx -y ${ARGS}`, 'g');
|
|
344
|
+
|
|
345
|
+
// 3. A node guard (`node -e '...'`) that spawns the entrypoint through npx.
|
|
346
|
+
const SPAWN_NPX = 'spawn(process.platform==="win32"?"npx.cmd":"npx",';
|
|
347
|
+
function localSpawn(version, argsJson) {
|
|
348
|
+
return `spawn(...((f,m,a)=>f.existsSync(m)?[m,a.slice(1)]:[process.platform==="win32"?"npx.cmd":"npx",a])(require("node:fs"),(process.env.HOME||"")+${JSON.stringify(`/.marrow/runtime/mcp/${version}/run`)},${argsJson}),`;
|
|
349
|
+
}
|
|
350
|
+
const LOCAL_SPAWN_RE = /spawn\(\.\.\.\(\(f,m,a\)=>f\.existsSync\(m\)\?\[m,a\.slice\(1\)\]:\[process\.platform==="win32"\?"npx\.cmd":"npx",a\]\)\(require\("node:fs"\),\(process\.env\.HOME\|\|""\)\+"\/\.marrow\/runtime\/mcp\/\d+\.\d+\.\d+\/run",(\["-y","--package=@getmarrow\/mcp@\d+\.\d+\.\d+","marrow-mcp","[a-z-]+"\])\),/g;
|
|
351
|
+
const CANONICAL_SPAWN_RE = /spawn\(process\.platform==="win32"\?"npx\.cmd":"npx",(\["-y","--package=@getmarrow\/mcp@(\d+\.\d+\.\d+)","marrow-mcp","[a-z-]+"\]),/g;
|
|
352
|
+
const localGuardPrefix = (version) => `N="${NODE(version)}"; [ -x "$N" ] || N=node; exec "$N" -e '`;
|
|
353
|
+
const LOCAL_GUARD_PREFIX_RE = /^N="\$HOME\/\.marrow\/runtime\/mcp\/\d+\.\d+\.\d+\/node"; \[ -x "\$N" \] \|\| N=node; exec "\$N" -e '/;
|
|
354
|
+
|
|
355
|
+
// The canonical (npx) form of a hook command; any other command is returned unchanged.
|
|
356
|
+
function delocalizeHookCommand(command) {
|
|
357
|
+
if (typeof command !== 'string') return command;
|
|
358
|
+
const plain = command.match(LOCAL_PLAIN_RE);
|
|
359
|
+
if (plain && plain[1] === plain[2]) return `npx -y --package=@getmarrow/mcp@${plain[2]} marrow-mcp ${plain[3]}`;
|
|
360
|
+
if (LOCAL_GUARD_PREFIX_RE.test(command)) {
|
|
361
|
+
return command.replace(LOCAL_GUARD_PREFIX_RE, 'node -e \'').replace(LOCAL_SPAWN_RE, `${SPAWN_NPX}$1,`);
|
|
362
|
+
}
|
|
363
|
+
if (command.startsWith('/bin/sh -c \'') && LOCAL_INNER_RE.test(command)) {
|
|
364
|
+
LOCAL_INNER_RE.lastIndex = 0;
|
|
365
|
+
const inner = command.replace(LOCAL_INNER_RE, (match, runVersion, version, sub) => (
|
|
366
|
+
runVersion === version ? `npx -y --package=@getmarrow/mcp@${version} marrow-mcp ${sub}` : match
|
|
367
|
+
));
|
|
368
|
+
return inner === command ? command : `sh -c '${inner.slice('/bin/sh -c \''.length)}`;
|
|
369
|
+
}
|
|
370
|
+
return command;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// The local runtime form of a canonical hook command for `version`, or the command unchanged
|
|
374
|
+
// when it is not a Marrow entrypoint for that version. The result always maps back exactly.
|
|
375
|
+
function localizeHookCommand(command, version) {
|
|
376
|
+
if (typeof command !== 'string' || !VERSION_RE.test(String(version || ''))) return command;
|
|
377
|
+
let local = command;
|
|
378
|
+
const plain = command.match(CANONICAL_PLAIN_RE);
|
|
379
|
+
if (plain) {
|
|
380
|
+
if (plain[1] !== version) return command;
|
|
381
|
+
local = localPlain(version, plain[2]);
|
|
382
|
+
} else if (command.startsWith('node -e \'') && command.includes(SPAWN_NPX)) {
|
|
383
|
+
let matched = false;
|
|
384
|
+
const body = command.slice('node -e \''.length).replace(CANONICAL_SPAWN_RE, (match, argsJson, specVersion) => {
|
|
385
|
+
if (specVersion !== version) return match;
|
|
386
|
+
matched = true;
|
|
387
|
+
return localSpawn(version, argsJson);
|
|
388
|
+
});
|
|
389
|
+
if (!matched) return command;
|
|
390
|
+
local = `${localGuardPrefix(version)}${body}`;
|
|
391
|
+
} else if (command.startsWith('sh -c \'')) {
|
|
392
|
+
let matched = false;
|
|
393
|
+
const body = command.slice('sh -c \''.length).replace(CANONICAL_INNER_RE, (match, specVersion, sub) => {
|
|
394
|
+
if (specVersion !== version) return match;
|
|
395
|
+
matched = true;
|
|
396
|
+
return localInner(version, sub);
|
|
397
|
+
});
|
|
398
|
+
if (!matched) return command;
|
|
399
|
+
local = `/bin/sh -c '${body}`;
|
|
400
|
+
} else {
|
|
401
|
+
return command;
|
|
402
|
+
}
|
|
403
|
+
return delocalizeHookCommand(local) === command ? local : command;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Every `command` string in a parsed hook settings object.
|
|
407
|
+
function mapHookCommands(value, map) {
|
|
408
|
+
if (Array.isArray(value)) return value.map((entry) => mapHookCommands(entry, map));
|
|
409
|
+
if (!value || typeof value !== 'object') return value;
|
|
410
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
|
|
411
|
+
key,
|
|
412
|
+
key === 'command' && typeof entry === 'string' ? map(entry) : mapHookCommands(entry, map),
|
|
413
|
+
]));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Hook settings text with Marrow's entrypoints for `version` switched to the local runtime.
|
|
417
|
+
function localizeHookSettingsText(text, version) {
|
|
418
|
+
if (typeof text !== 'string' || !text.trim()) return text;
|
|
419
|
+
let parsed;
|
|
420
|
+
try {
|
|
421
|
+
parsed = JSON.parse(text);
|
|
422
|
+
} catch {
|
|
423
|
+
return text;
|
|
424
|
+
}
|
|
425
|
+
let changed = false;
|
|
426
|
+
const mapped = mapHookCommands(parsed, (command) => {
|
|
427
|
+
const local = localizeHookCommand(command, version);
|
|
428
|
+
if (local !== command) changed = true;
|
|
429
|
+
return local;
|
|
430
|
+
});
|
|
431
|
+
return changed ? `${JSON.stringify(mapped, null, 2)}\n` : text;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
module.exports = {
|
|
435
|
+
NPM_INSTALL_TIMEOUT_MS,
|
|
436
|
+
RUNTIME_SCHEMA,
|
|
437
|
+
LAUNCHER_SOURCE,
|
|
438
|
+
delocalizeHookCommand,
|
|
439
|
+
ensureMcpRuntime,
|
|
440
|
+
localizeHookCommand,
|
|
441
|
+
localizeHookSettingsText,
|
|
442
|
+
mapHookCommands,
|
|
443
|
+
removeMcpRuntime,
|
|
444
|
+
runtimeDir,
|
|
445
|
+
runtimeRoot,
|
|
446
|
+
verifyMcpRuntime,
|
|
447
|
+
};
|
package/src/pins.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The Marrow packages this installer pins into hooks, MCP configuration and request headers.
|
|
2
|
+
// This is the only place they are set: a release changes the MCP version, source SHA and
|
|
3
|
+
// integrity here together (and the SDK pair together), and the tests read them from here.
|
|
4
|
+
module.exports = Object.freeze({
|
|
5
|
+
MCP_ADAPTER_VERSION: '3.9.100',
|
|
6
|
+
MCP_ADAPTER_SOURCE_SHA: 'dfe8ca59b7862c481443876f5b1d0f453d244a44',
|
|
7
|
+
MCP_ADAPTER_INTEGRITY: 'sha512-6YfkDSrYhDwjTUBAo7toJ808R1pzxefjX5Dk3fq8hVfr8FrR+DMLbZTHOM0vU1uhPOp8H5GFIniTWTNh2/5Sig==',
|
|
8
|
+
// The first MCP version that ships the host-approval hook entrypoints
|
|
9
|
+
// (claude-permission-request-hook, cursor-context-hook, gemini-context-hook) and reads Cursor's
|
|
10
|
+
// shell/MCP execution events and Gemini's BeforeAgent prompt. The installer writes the
|
|
11
|
+
// host-approval hook layout only when the MCP version it pins (or a registry-verified newer
|
|
12
|
+
// one) is at or above this; an older target keeps the earlier layout, because its MCP cannot
|
|
13
|
+
// answer those hooks and Cursor shell and MCP calls would then go ungated. Set this to the
|
|
14
|
+
// version the host-approvals MCP is published as when the release re-pins the MCP above.
|
|
15
|
+
MCP_HOST_APPROVAL_HOOKS_SINCE: '3.9.99',
|
|
16
|
+
// The SDK pin, at the backend's parity version. Change version and integrity together.
|
|
17
|
+
SDK_ADAPTER_VERSION: '3.7.65',
|
|
18
|
+
SDK_ADAPTER_INTEGRITY: 'sha512-U+rxqEBs7uIHaGbx+ADGl09RvXSMeqxQZXTuJHxwx6P8EhOEB6SSLcot0OAhAu3gfGuZ+020ch0Y0WieqjxtYw==',
|
|
19
|
+
});
|