Repository navigation
fix(mcp): find code and claude .cmd shims on Windows #286
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
riglar
wants to merge
3
commits into
dev
Choose a base branch
from
fix/mcp-install-windows-cmd
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.