From ff92c66b64cb902e6faf9cc54f2973a96d29173a Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 5 Feb 2026 10:24:43 -0500 Subject: [PATCH 1/4] fix(doctor): use cmd.exe compatible quoting for Windows shell execution The escapeShellArg function was using PowerShell-style single quotes, but spawn({ shell: true }) uses cmd.exe by default on Windows. cmd.exe does not recognize single quotes as string delimiters, causing 'claude' '--version' to fail. Changed to use double quotes which work correctly in cmd.exe. --- src/utils/shell-executor.ts | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 9ee00511..4718e2e6 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -9,33 +9,22 @@ import { ErrorManager } from './error-manager'; import { getWebSearchHookEnv } from './websearch-manager'; /** - * Escape arguments for shell execution (Windows compatibility) - * Handles PowerShell special characters: backticks, $variables, double quotes + * Escape arguments for shell execution (cross-platform) + * + * IMPORTANT: On Windows, spawn({ shell: true }) uses cmd.exe by default, + * NOT PowerShell. cmd.exe does NOT recognize single quotes as string delimiters. + * We must use double quotes for cmd.exe compatibility. */ export function escapeShellArg(arg: string): string { const isWindows = process.platform === 'win32'; if (isWindows) { - // PowerShell: Use single quotes for literal strings to prevent variable expansion - // Escape single quotes by doubling them (PowerShell syntax) - // Fallback to double quotes with escapes if single quotes present - if (arg.includes("'")) { - // Contains single quote - use double quotes with escape sequences - return ( - '"' + - String(arg) - .replace(/\$/g, '`$') // Escape $ to prevent variable expansion - .replace(/`/g, '``') // Escape backticks - .replace(/"/g, '`"') + // Escape double quotes - '"' - ); - } else { - // No single quotes - use single quotes for literal string (safest) - return "'" + String(arg) + "'"; - } + // cmd.exe: Use double quotes, escape inner double quotes with backslash + // cmd.exe interprets "" as escaped double quote inside quoted string + return '"' + String(arg).replace(/"/g, '""') + '"'; } else { // Unix/macOS: Double quotes with escaped inner quotes - return '"' + String(arg).replace(/"/g, '""') + '"'; + return '"' + String(arg).replace(/"/g, '\\"') + '"'; } } From ed91f21994a3aa35a9e40539015676466b794144 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 5 Feb 2026 10:32:31 -0500 Subject: [PATCH 2/4] fix(shell): escape cmd.exe special chars (%, ^, newlines) Additional hardening for Windows shell escaping: - Escape % as %% to prevent variable expansion - Escape ^ as ^^ to prevent escape sequence interpretation - Replace newlines/tabs with space to prevent parsing errors --- src/utils/shell-executor.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 4718e2e6..40a104fc 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -21,7 +21,16 @@ export function escapeShellArg(arg: string): string { if (isWindows) { // cmd.exe: Use double quotes, escape inner double quotes with backslash // cmd.exe interprets "" as escaped double quote inside quoted string - return '"' + String(arg).replace(/"/g, '""') + '"'; + // Strip newlines/tabs that can break cmd.exe parsing + return ( + '"' + + String(arg) + .replace(/[\r\n\t]/g, ' ') // Replace newlines/tabs with space + .replace(/%/g, '%%') // Escape percent signs + .replace(/\^/g, '^^') // Escape carets + .replace(/"/g, '""') + // Escape quotes + '"' + ); } else { // Unix/macOS: Double quotes with escaped inner quotes return '"' + String(arg).replace(/"/g, '\\"') + '"'; From 48aa3cca30b2c5e7ff0b5faff865918759b048d1 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 5 Feb 2026 10:36:55 -0500 Subject: [PATCH 3/4] test(shell): add unit tests for escapeShellArg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 11 tests covering Unix and Windows escaping behavior - Tests for quotes, percent signs, carets, newlines, tabs - Fixes comment inaccuracy (backslash → doubling) --- src/utils/shell-executor.ts | 2 +- tests/unit/utils/shell-executor.test.ts | 79 +++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 tests/unit/utils/shell-executor.test.ts diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 40a104fc..c6bf8359 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -19,7 +19,7 @@ export function escapeShellArg(arg: string): string { const isWindows = process.platform === 'win32'; if (isWindows) { - // cmd.exe: Use double quotes, escape inner double quotes with backslash + // cmd.exe: Use double quotes, escape inner double quotes by doubling them // cmd.exe interprets "" as escaped double quote inside quoted string // Strip newlines/tabs that can break cmd.exe parsing return ( diff --git a/tests/unit/utils/shell-executor.test.ts b/tests/unit/utils/shell-executor.test.ts new file mode 100644 index 00000000..8dbe930e --- /dev/null +++ b/tests/unit/utils/shell-executor.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; + +// We need to mock process.platform for cross-platform testing +const originalPlatform = process.platform; + +describe('escapeShellArg', () => { + describe('Unix (non-Windows)', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + }); + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('wraps argument in double quotes', async () => { + const { escapeShellArg } = await import('../../../src/utils/shell-executor'); + expect(escapeShellArg('arg')).toBe('"arg"'); + }); + + it('escapes inner double quotes with backslash', async () => { + const { escapeShellArg } = await import('../../../src/utils/shell-executor'); + expect(escapeShellArg('say "hello"')).toBe('"say \\"hello\\""'); + }); + + it('handles paths with spaces', async () => { + const { escapeShellArg } = await import('../../../src/utils/shell-executor'); + expect(escapeShellArg('/path/to/my file')).toBe('"/path/to/my file"'); + }); + + it('handles empty string', async () => { + const { escapeShellArg } = await import('../../../src/utils/shell-executor'); + expect(escapeShellArg('')).toBe('""'); + }); + }); + + describe('Windows (cmd.exe)', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + }); + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('wraps argument in double quotes', async () => { + const { escapeShellArg } = await import('../../../src/utils/shell-executor'); + expect(escapeShellArg('arg')).toBe('"arg"'); + }); + + it('escapes inner double quotes by doubling them', async () => { + const { escapeShellArg } = await import('../../../src/utils/shell-executor'); + expect(escapeShellArg('say "hello"')).toBe('"say ""hello"""'); + }); + + it('escapes percent signs', async () => { + const { escapeShellArg } = await import('../../../src/utils/shell-executor'); + expect(escapeShellArg('%PATH%')).toBe('"%%PATH%%"'); + }); + + it('escapes caret characters', async () => { + const { escapeShellArg } = await import('../../../src/utils/shell-executor'); + expect(escapeShellArg('a^b')).toBe('"a^^b"'); + }); + + it('replaces newlines with spaces', async () => { + const { escapeShellArg } = await import('../../../src/utils/shell-executor'); + expect(escapeShellArg('line1\nline2')).toBe('"line1 line2"'); + }); + + it('replaces tabs with spaces', async () => { + const { escapeShellArg } = await import('../../../src/utils/shell-executor'); + expect(escapeShellArg('col1\tcol2')).toBe('"col1 col2"'); + }); + + it('handles Windows paths with spaces', async () => { + const { escapeShellArg } = await import('../../../src/utils/shell-executor'); + expect(escapeShellArg('C:\\Program Files\\App')).toBe('"C:\\Program Files\\App"'); + }); + }); +}); From 61bc54af0504d21bdf1e6a7e29dcf9ef322b89c7 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 5 Feb 2026 10:45:07 -0500 Subject: [PATCH 4/4] fix(shell): escape ! for cmd.exe delayed expansion Adds defensive escaping for exclamation marks in case delayed expansion is enabled in the user's cmd.exe environment. --- src/utils/shell-executor.ts | 1 + tests/unit/utils/shell-executor.test.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index c6bf8359..a3206d6d 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -28,6 +28,7 @@ export function escapeShellArg(arg: string): string { .replace(/[\r\n\t]/g, ' ') // Replace newlines/tabs with space .replace(/%/g, '%%') // Escape percent signs .replace(/\^/g, '^^') // Escape carets + .replace(/!/g, '^^!') // Escape exclamation marks (delayed expansion) .replace(/"/g, '""') + // Escape quotes '"' ); diff --git a/tests/unit/utils/shell-executor.test.ts b/tests/unit/utils/shell-executor.test.ts index 8dbe930e..cca6f0cb 100644 --- a/tests/unit/utils/shell-executor.test.ts +++ b/tests/unit/utils/shell-executor.test.ts @@ -75,5 +75,10 @@ describe('escapeShellArg', () => { const { escapeShellArg } = await import('../../../src/utils/shell-executor'); expect(escapeShellArg('C:\\Program Files\\App')).toBe('"C:\\Program Files\\App"'); }); + + it('escapes exclamation marks for delayed expansion', async () => { + const { escapeShellArg } = await import('../../../src/utils/shell-executor'); + expect(escapeShellArg('hello!')).toBe('"hello^^!"'); + }); }); });