From 6ce47d44450465fff029231b367fb71d6484ba1c Mon Sep 17 00:00:00 2001 From: Julius Brussee Date: Mon, 1 Jun 2026 21:04:58 +0200 Subject: [PATCH] fix(mcp-shrink): shell:true on Windows so npx/.cmd upstream resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/mcp-servers/caveman-shrink/index.js | 6 ++-- .../caveman-shrink/spawn-options.js | 21 ++++++++++++ tests/test_mcp_shrink.js | 32 +++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 src/mcp-servers/caveman-shrink/spawn-options.js diff --git a/src/mcp-servers/caveman-shrink/index.js b/src/mcp-servers/caveman-shrink/index.js index 0744101..aed3bcc 100644 --- a/src/mcp-servers/caveman-shrink/index.js +++ b/src/mcp-servers/caveman-shrink/index.js @@ -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`); diff --git a/src/mcp-servers/caveman-shrink/spawn-options.js b/src/mcp-servers/caveman-shrink/spawn-options.js new file mode 100644 index 0000000..9658957 --- /dev/null +++ b/src/mcp-servers/caveman-shrink/spawn-options.js @@ -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 }; diff --git a/tests/test_mcp_shrink.js b/tests/test_mcp_shrink.js index b87c6ee..38357fc 100644 --- a/tests/test_mcp_shrink.js +++ b/tests/test_mcp_shrink.js @@ -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);