Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
writeClientConfig,
} from '../utils/mcp-install.js';
import { colors } from '../utils/styling.js';
import { toolSpawn } from '../utils/tool-spawn.js';
import { type Field, ui } from '../utils/ui.js';

const RELOAD_HINT: Record<McpClient, string> = {
Expand All @@ -41,7 +42,12 @@ const RELOAD_HINT: Record<McpClient, string> = {

/** Run another tool's CLI without a shell. Undefined when it isn't installed. */
function runTool(command: string, args: string[]): { ok: boolean; output: string } | undefined {
const result = spawnSync(command, args, { encoding: 'utf8' });
const spawn = toolSpawn(command, args);
if (!spawn) return undefined;
const result = spawnSync(spawn.command, spawn.args, {
encoding: 'utf8',
windowsVerbatimArguments: spawn.windowsVerbatimArguments,
});
Comment thread
riglar marked this conversation as resolved.
Dismissed
if ((result.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return undefined;
return { ok: result.status === 0, output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() };
}
Expand Down
86 changes: 86 additions & 0 deletions src/utils/tool-spawn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* How to start another tool's CLI (`code`, `claude`) without a shell.
*
* On Windows these are often batch files (`code.cmd`, and npm's `claude.cmd`
* shim). Node's spawn searches PATH only for .com and .exe, and refuses to
* start a .cmd or .bat at all without a shell (CVE-2024-27980). So we find the
* file ourselves and, for a batch file, run it through cmd.exe with every
* argument escaped for cmd. This doesn't lean on how Node or Bun resolve
* commands, so the npm install and the standalone binary behave the same.
*/
import { existsSync } from 'node:fs';
import * as path from 'node:path';

import { CliError } from './cli.js';

export interface ToolSpawn {
command: string;
args: string[];
/** Pass the arguments to cmd.exe as written, already quoted. */
windowsVerbatimArguments?: boolean;
}

/** Characters cmd.exe treats specially, each escaped with a caret. */
const CMD_META = /([ !"%&()*,;<>?[\]^`|])/g;

/**
* What to spawn for `command args`, or undefined when Windows has no such
* command on PATH. Elsewhere the command is spawned as given, and a missing
* one shows up as ENOENT.
*/
export function toolSpawn(
command: string,
args: string[],
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
exists: (file: string) => boolean = existsSync,
): ToolSpawn | undefined {
if (platform !== 'win32') return { command, args };

const file = findOnWindowsPath(command, env, exists);
if (!file) return undefined;
if (!/\.(cmd|bat)$/i.test(file)) return { command: file, args };
// A line break ends cmd's line, and no escaping can stop that.
if (args.some((arg) => /[\0\n\r]/.test(arg))) {
throw new CliError(`Can't pass a line break to ${path.win32.basename(file)} through cmd.exe.`);
}

// /s strips the outer quotes and runs the rest as one line.
const line = [file.replace(CMD_META, '^$1'), ...args.map((arg) => cmdArgument(arg))].join(' ');
return {
command: env.ComSpec ?? env.COMSPEC ?? 'cmd.exe',
args: ['/d', '/s', '/c', `"${line}"`],
windowsVerbatimArguments: true,
};
}

/** The file Windows would run for `command`: each PATH folder, each PATHEXT extension. */
export function findOnWindowsPath(
command: string,
env: NodeJS.ProcessEnv,
exists: (file: string) => boolean,
): string | undefined {
const exts = (env.PATHEXT ?? env.Pathext ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
const dirs = (env.PATH ?? env.Path ?? '').split(';').filter(Boolean);
const hasExt = exts.some((ext) => command.toLowerCase().endsWith(ext.toLowerCase()));
const names = hasExt ? [command] : exts.map((ext) => command + ext.toLowerCase());
for (const dir of dirs) {
for (const name of names) {
const candidate = path.win32.join(dir.replaceAll('"', ''), name);
if (exists(candidate)) return candidate;
}
}

return undefined;
}

/**
* One argument for a batch file started by cmd.exe. First quote it the way
* the program's own parser expects (CommandLineToArgvW), then caret-escape
* every cmd character twice: once for the `cmd /c` line, and once more
* because the batch file passes it on with %*, which cmd parses again.
*/
export function cmdArgument(arg: string): string {
const quoted = `"${arg.replaceAll(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1')}"`;
return quoted.replace(CMD_META, '^$1').replace(CMD_META, '^$1');
}
128 changes: 128 additions & 0 deletions test/unit/tool-spawn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { expect } from 'chai';

import { vscodeAddArgs } from '../../src/utils/mcp-install.js';
import { cmdArgument, findOnWindowsPath, toolSpawn } from '../../src/utils/tool-spawn.js';

/** One cmd.exe parse pass: carets escape outside quotes, quotes toggle, & | < > outside quotes are operators. */
function cmdPass(line: string): string {
let out = '';
let quoted = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (!quoted && ch === '^') {
out += line[++i] ?? '';
continue;
}

if (!quoted && '&|<>'.includes(ch)) throw new Error(`cmd would treat ${ch} as an operator in: ${line}`);
if (ch === '"') quoted = !quoted;
out += ch;
}

return out;
}

/** CommandLineToArgvW's rules for the arguments after the program name. */
function argv(line: string): string[] {
const args: string[] = [];
let i = 0;
while (i < line.length) {
while (line[i] === ' ' || line[i] === '\t') i++;
if (i >= line.length) break;
let arg = '';
let quoted = false;
while (i < line.length && (quoted || (line[i] !== ' ' && line[i] !== '\t'))) {
let slashes = 0;
while (line[i] === '\\') {
slashes++;
i++;
}

if (line[i] === '"') {
arg += '\\'.repeat(Math.floor(slashes / 2));
if (slashes % 2) arg += '"';
else quoted = !quoted;
i++;
} else {
arg += '\\'.repeat(slashes);
if (i < line.length) arg += line[i++];
}
}

args.push(arg);
}

return args;
}

/** What a program started by `code.cmd ... %*` sees for the given arguments. */
function throughBatch(args: string[]): string[] {
// `cmd /s /c "<line>"` parses the line once; the batch file's %* line is parsed again.
return argv(cmdPass(cmdPass(args.map((arg) => cmdArgument(arg)).join(' '))));
}

describe('toolSpawn', () => {
const files = new Set([
String.raw`C:\Program Files\Microsoft VS Code\bin\code.cmd`,
String.raw`C:\Users\a\AppData\Roaming\npm\claude.cmd`,
String.raw`C:\tools\claude.exe`,
]);
const exists = (file: string) => files.has(file);
const env = {
ComSpec: String.raw`C:\Windows\system32\cmd.exe`,
PATH: String.raw`C:\Windows;"C:\Program Files\Microsoft VS Code\bin";C:\Users\a\AppData\Roaming\npm`,
PATHEXT: '.COM;.EXE;.BAT;.CMD',
};

it('spawns the command as given off Windows', () => {
expect(toolSpawn('code', ['--add-mcp', '{}'], 'darwin', env, exists)).to.deep.equal({
args: ['--add-mcp', '{}'],
command: 'code',
});
});

it('finds a .cmd on PATH, and an .exe before a .cmd in a later folder', () => {
expect(findOnWindowsPath('code', env, exists)).to.equal(String.raw`C:\Program Files\Microsoft VS Code\bin\code.cmd`);
expect(findOnWindowsPath('claude', { ...env, PATH: `C:\\tools;${env.PATH}` }, exists)).to.equal(
String.raw`C:\tools\claude.exe`,
);
expect(findOnWindowsPath('cursor', env, exists)).to.equal(undefined);
});

it('is undefined on Windows when the command is not on PATH', () => {
expect(toolSpawn('cursor', [], 'win32', env, exists)).to.equal(undefined);
});

it('spawns an .exe directly', () => {
expect(toolSpawn('claude', ['mcp'], 'win32', { ...env, PATH: 'C:\\tools' }, exists)).to.deep.equal({
args: ['mcp'],
command: String.raw`C:\tools\claude.exe`,
});
});

it('runs a .cmd through cmd.exe with verbatim arguments', () => {
const spawn = toolSpawn('code', ['--add-mcp', '{}'], 'win32', env, exists)!;
expect(spawn.command).to.equal(env.ComSpec);
expect(spawn.windowsVerbatimArguments).to.equal(true);
expect(spawn.args.slice(0, 3)).to.deep.equal(['/d', '/s', '/c']);
expect(cmdPass(spawn.args[3].slice(1, -1))).to.match(/^C:\\Program Files\\Microsoft VS Code\\bin\\code\.cmd /);
});

it('refuses a line break in an argument to a .cmd', () => {
expect(() => toolSpawn('code', ['a\nb'], 'win32', env, exists)).to.throw(/line break/);
expect(toolSpawn('claude', ['a\nb'], 'win32', { ...env, PATH: 'C:\\tools' }, exists)?.args).to.deep.equal(['a\nb']);
});

it('gets the --add-mcp JSON to VS Code unchanged', () => {
const add = vscodeAddArgs({
args: [String.raw`C:\Users\a b\AppData\Roaming\npm\node_modules\@devicecloud.dev\dcd\dist\index.js`, 'mcp', '--read-only'],
command: String.raw`C:\Program Files (x86)\nodejs\node.exe`,
});
expect(throughBatch(add)).to.deep.equal(add);
});

it('keeps cmd characters, quotes and trailing backslashes intact', () => {
const args = ['a&b|c', '<in>', '100%PATH%', 'say "hi"', String.raw`C:\dir\ `, String.raw`end\\`, '^caret!', '"&"', 'x" | "y', ''];
expect(throughBatch(args)).to.deep.equal(args);
});
});
Loading