fix(mcp-shrink): shell:true on Windows so npx/.cmd upstream resolves

Folds in #387 — extract spawn options to spawn-options.js; shell:true only on win32 (PATHEXT resolution), POSIX unchanged. Args still come from installer-controlled config (trust boundary unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Julius Brussee
2026-06-01 21:04:58 +02:00
co-authored by Claude Opus 4.8
parent 46de578a7e
commit 6ce47d4445
3 changed files with 56 additions and 3 deletions
+3 -3
View File
@@ -40,9 +40,9 @@ const debug = process.env.CAVEMAN_SHRINK_DEBUG === '1';
const fields = (process.env.CAVEMAN_SHRINK_FIELDS || 'description')
.split(',').map(s => s.trim()).filter(Boolean);
const upstream = spawn(args[0], args.slice(1), {
stdio: ['pipe', 'pipe', 'inherit'],
});
const { getSpawnOptions } = require('./spawn-options');
const upstream = spawn(args[0], args.slice(1), getSpawnOptions());
upstream.on('error', err => {
process.stderr.write(`caveman-shrink: failed to spawn upstream: ${err.message}\n`);
@@ -0,0 +1,21 @@
// Spawn options for the upstream MCP child process.
//
// Windows: spawn('npx', ...) (and any .cmd shim such as 'gemini') hits ENOENT
// because PATHEXT resolution only happens when child_process spawns through
// a shell. POSIX systems resolve fine without a shell. Keep shell:false on
// POSIX to avoid argv quoting surprises.
//
// Exported standalone so the behavior is unit-testable without re-running
// the CLI entry point (index.js exits immediately when args are empty).
'use strict';
function getSpawnOptions(platform = process.platform) {
return {
stdio: ['pipe', 'pipe', 'inherit'],
shell: platform === 'win32',
windowsHide: true,
};
}
module.exports = { getSpawnOptions };
+32
View File
@@ -9,6 +9,9 @@ const ROOT = path.resolve(__dirname, '..');
const { compress, compressDescriptionsInPlace } = require(
path.join(ROOT, 'src', 'mcp-servers', 'caveman-shrink', 'compress.js')
);
const { getSpawnOptions } = require(
path.join(ROOT, 'src', 'mcp-servers', 'caveman-shrink', 'spawn-options.js')
);
let passed = 0;
let failed = 0;
@@ -123,5 +126,34 @@ test('compressDescriptionsInPlace skips non-string description fields', () => {
assert.deepStrictEqual(obj.description, { not: 'a string' });
});
// spawn-options: upstream MCP child process spawn flags.
// Confirms shell:true on Windows (so npx and other .cmd shims resolve) and
// shell:false on POSIX.
test('win32 enables shell so npx and .cmd shims resolve', () => {
const opts = getSpawnOptions('win32');
assert.equal(opts.shell, true);
assert.equal(opts.windowsHide, true);
assert.deepEqual(opts.stdio, ['pipe', 'pipe', 'inherit']);
});
test('linux keeps shell off to avoid argv quoting surprises', () => {
const opts = getSpawnOptions('linux');
assert.equal(opts.shell, false);
assert.deepEqual(opts.stdio, ['pipe', 'pipe', 'inherit']);
});
test('darwin keeps shell off', () => {
const opts = getSpawnOptions('darwin');
assert.equal(opts.shell, false);
});
test('defaults to current platform when no arg passed', () => {
const opts = getSpawnOptions();
assert.equal(opts.shell, process.platform === 'win32');
assert.equal(opts.windowsHide, true);
assert.deepEqual(opts.stdio, ['pipe', 'pipe', 'inherit']);
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);