From 596ad5df0c1188d083b8cf491f982e94eccb75a4 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 14 Jul 2026 16:34:48 +0200 Subject: [PATCH 1/7] Fix preferLocal command injection on Windows --- lib/arguments/command-file.js | 10 ++++++++-- lib/arguments/options.js | 8 ++++---- test/arguments/command-resolution.js | 30 ++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/lib/arguments/command-file.js b/lib/arguments/command-file.js index f3c1442ad6..812f4d5115 100644 --- a/lib/arguments/command-file.js +++ b/lib/arguments/command-file.js @@ -3,7 +3,6 @@ import {Buffer} from 'node:buffer'; import path from 'node:path'; import process from 'node:process'; import {whichCommandSync} from 'which-command'; -import pathKey from 'path-key'; /* On Windows, `node:child_process` cannot natively run many kinds of files (`.cmd`, `.bat`, shebang scripts, ...): without a shell it resolves neither `PATHEXT` nor shebangs, and it does not escape arguments. We resolve the command to its full file path and escape its arguments ourselves, so those work without an explicit shell, just like on Unix, where the OS handles all of this for us. @@ -77,10 +76,17 @@ const resolvePath = parsed => { const environment = parsed.options.env || process.env; return whichCommandSync(parsed.file, { cwd: parsed.options.cwd, - path: environment[pathKey({env: environment})], + path: getWindowsEnvironmentValue(environment, 'PATH'), + pathExt: getWindowsEnvironmentValue(environment, 'PATHEXT'), }); }; +// Node sorts Windows environment keys and uses the first case-insensitive match when spawning. +const getWindowsEnvironmentValue = (environment, name) => { + const environmentKey = Object.keys(environment).sort().find(key => key.toUpperCase() === name); + return environmentKey === undefined ? undefined : environment[environmentKey]; +}; + const SHEBANG_BYTE_LENGTH = 150; // Read the file's first bytes to find its shebang interpreter, if it has one diff --git a/lib/arguments/options.js b/lib/arguments/options.js index 3cbcb6b98f..240bfd2f24 100644 --- a/lib/arguments/options.js +++ b/lib/arguments/options.js @@ -21,18 +21,18 @@ export const normalizeOptions = (filePath, rawArguments, rawOptions) => { const sanitizedOptions = {__proto__: null, ...rawOptions}; sanitizedOptions.cwd = normalizeCwd(sanitizedOptions.cwd); const [processedFile, processedArguments, processedOptions] = handleNodeOption(filePath, rawArguments, sanitizedOptions); + const fdOptions = normalizeFdSpecificOptions(processedOptions); + const options = addDefaultOptions(fdOptions); + options.env = getEnv(options); - const {file, commandArguments, options: initialOptions} = parseCommandFile(processedFile, processedArguments, processedOptions); + const {file, commandArguments} = parseCommandFile(processedFile, processedArguments, options); - const fdOptions = normalizeFdSpecificOptions(initialOptions); - const options = addDefaultOptions(fdOptions); validateTimeout(options); validateEncoding(options); validateIpcInputOption(options); validateCancelSignal(options); validateGracefulCancel(options); options.shell = normalizeFileUrl(options.shell); - options.env = getEnv(options); options.killSignal = normalizeKillSignal(options.killSignal); options.forceKillAfterDelay = normalizeForceKillAfterDelay(options.forceKillAfterDelay); options.lines = options.lines.map((lines, fdNumber) => lines && !BINARY_ENCODINGS.has(options.encoding) && options.buffer[fdNumber]); diff --git a/test/arguments/command-resolution.js b/test/arguments/command-resolution.js index 3942b9fec4..4c7acd0fdb 100644 --- a/test/arguments/command-resolution.js +++ b/test/arguments/command-resolution.js @@ -200,6 +200,36 @@ if (isWindows) { const {stdout: stdoutSync} = execaSync(shimPath, [commandArgument]); t.is(stdoutSync, commandArgument); }); + + test.serial('Double-escapes metacharacters for preferLocal cmd-shims', async t => { + await setupCmdShim(); + const commandArgument = 'a&whoami'; + const originalPathExt = process.env.PATHEXT; + process.env.PATHEXT = '.EXE'; + const options = { + preferLocal: true, + localDir: FIXTURES_DIRECTORY, + extendEnv: false, + env: { + Path: path.dirname(process.execPath), + PathExt: '.EXE;.CMD', + }, + }; + + try { + const {stdout} = await execa('echo-cmd-shim', [commandArgument], options); + t.is(stdout, commandArgument); + + const {stdout: stdoutSync} = execaSync('echo-cmd-shim', [commandArgument], options); + t.is(stdoutSync, commandArgument); + } finally { + if (originalPathExt === undefined) { + delete process.env.PATHEXT; + } else { + process.env.PATHEXT = originalPathExt; + } + } + }); } // On Windows, `.cmd`/`.bat` files are run through `cmd.exe`, which interprets several From 744498ae4658c189aee09d07c037a78e57c0922e Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 14 Jul 2026 17:02:23 +0200 Subject: [PATCH 2/7] Fix Windows command extension resolution --- lib/arguments/command-file.js | 9 +++-- package.json | 2 +- test/arguments/command-resolution.js | 49 ++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/lib/arguments/command-file.js b/lib/arguments/command-file.js index 812f4d5115..d688e1a37e 100644 --- a/lib/arguments/command-file.js +++ b/lib/arguments/command-file.js @@ -29,8 +29,9 @@ const escapeWindowsCommand = parsed => { // Resolve the file to an absolute path, following its shebang to the interpreter if any const resolvedFile = resolveWithShebang(parsed); - // A directly executable file is spawned as-is, bypassing `cmd.exe` and its escaping + // A directly executable file is spawned by its resolved path, bypassing `cmd.exe` and its escaping if (resolvedFile !== undefined && directlyExecutableRegExp.test(resolvedFile)) { + parsed.file = resolvedFile; return parsed; } @@ -74,10 +75,14 @@ const resolveWithShebang = parsed => { // Search `PATH` for the command, resolving its Windows executable extension via `PATHEXT` const resolvePath = parsed => { const environment = parsed.options.env || process.env; + const environmentPathExt = getWindowsEnvironmentValue(environment, 'PATHEXT'); + const commandExtension = path.extname(parsed.file); + // Explicitly named files must be tried verbatim so shebang scripts work even when their extension is excluded from PATHEXT. + const pathExt = commandExtension === '' ? environmentPathExt : `${commandExtension}${path.delimiter}${environmentPathExt ?? ''}`; return whichCommandSync(parsed.file, { cwd: parsed.options.cwd, path: getWindowsEnvironmentValue(environment, 'PATH'), - pathExt: getWindowsEnvironmentValue(environment, 'PATHEXT'), + pathExt, }); }; diff --git a/package.json b/package.json index 88e163f3e7..71d5a3ff64 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", - "path-key": "^4.0.0", "pretty-ms": "^9.3.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", @@ -74,6 +73,7 @@ "is-running": "^2.1.0", "log-process-errors": "^12.0.1", "path-exists": "^5.0.0", + "path-key": "^4.0.0", "tempfile": "^6.0.1", "tsd": "^0.33.0", "typescript": "^6.0.3", diff --git a/test/arguments/command-resolution.js b/test/arguments/command-resolution.js index 4c7acd0fdb..627c1cce54 100644 --- a/test/arguments/command-resolution.js +++ b/test/arguments/command-resolution.js @@ -172,6 +172,26 @@ if (isWindows) { t.is(stdout, 'Hello World'); }); + test('Runs a .com executable selected by PATHEXT', async t => { + const binaryDirectory = path.join(FIXTURES_DIRECTORY, 'node_modules', '.bin'); + await mkdir(binaryDirectory, {recursive: true}); + await cp(process.execPath, path.join(binaryDirectory, 'node-com.com')); + const options = { + preferLocal: true, + localDir: FIXTURES_DIRECTORY, + extendEnv: false, + env: { + Path: path.dirname(process.execPath), + PathExt: '.COM', + }, + }; + const {stdout} = await execa('node-com', ['--version'], options); + t.is(stdout, process.version); + + const {stdout: stdoutSync} = execaSync('node-com', ['--version'], options); + t.is(stdoutSync, process.version); + }); + // A `.cmd` file needs `cmd.exe`, so its forward-slash path must be normalized to // backslashes, otherwise it fails with ENOENT. test('Runs a .cmd file given as a relative POSIX-style subpath', async t => { @@ -201,6 +221,35 @@ if (isWindows) { t.is(stdoutSync, commandArgument); }); + test('Double-escapes explicit batch files excluded from PATHEXT', async t => { + const commandArgument = '"& whoami &"'; + const options = { + extendEnv: false, + env: { + Path: path.dirname(process.execPath), + PathExt: '.EXE', + }, + }; + const command = path.join(FIXTURES_DIRECTORY, 'echo-shim.cmd'); + const {stdout} = await execa(command, [commandArgument], options); + t.is(stdout, commandArgument); + + const {stdout: stdoutSync} = execaSync(command, [commandArgument], options); + t.is(stdoutSync, commandArgument); + }); + + test('Runs an explicit shebang script excluded from PATHEXT', async t => { + const command = path.join(FIXTURES_DIRECTORY, 'echo.js'); + const options = { + extendEnv: false, + env: { + Path: path.dirname(process.execPath), + PathExt: '.EXE', + }, + }; + await testResolvesCommand(t, command, options); + }); + test.serial('Double-escapes metacharacters for preferLocal cmd-shims', async t => { await setupCmdShim(); const commandArgument = 'a&whoami'; From 3d77820d7e7f35d8d6ebb8adbdb1fbd795462cd0 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 14 Jul 2026 17:30:36 +0200 Subject: [PATCH 3/7] Fix Windows command resolution regressions --- lib/arguments/command-file.js | 40 +++++++-- test/arguments/command-resolution.js | 120 +++++++++++++++++++++------ 2 files changed, 130 insertions(+), 30 deletions(-) diff --git a/lib/arguments/command-file.js b/lib/arguments/command-file.js index d688e1a37e..51275ce59f 100644 --- a/lib/arguments/command-file.js +++ b/lib/arguments/command-file.js @@ -31,6 +31,10 @@ const escapeWindowsCommand = parsed => { // A directly executable file is spawned by its resolved path, bypassing `cmd.exe` and its escaping if (resolvedFile !== undefined && directlyExecutableRegExp.test(resolvedFile)) { + if (parsed.options.argv0 === undefined) { + parsed.options.argv0 = parsed.file; + } + parsed.file = resolvedFile; return parsed; } @@ -75,15 +79,41 @@ const resolveWithShebang = parsed => { // Search `PATH` for the command, resolving its Windows executable extension via `PATHEXT` const resolvePath = parsed => { const environment = parsed.options.env || process.env; + const cwd = parsed.options.cwd ?? process.cwd(); const environmentPathExt = getWindowsEnvironmentValue(environment, 'PATHEXT'); const commandExtension = path.extname(parsed.file); - // Explicitly named files must be tried verbatim so shebang scripts work even when their extension is excluded from PATHEXT. + // Commands with an explicit extension must be tried verbatim so shebang scripts work even when their extension is excluded from PATHEXT. const pathExt = commandExtension === '' ? environmentPathExt : `${commandExtension}${path.delimiter}${environmentPathExt ?? ''}`; - return whichCommandSync(parsed.file, { - cwd: parsed.options.cwd, - path: getWindowsEnvironmentValue(environment, 'PATH'), + if (hasWindowsPathSeparator(parsed.file)) { + return whichCommandSync(path.resolve(cwd, parsed.file), {cwd, pathExt}); + } + + const searchPath = getWindowsEnvironmentValue(environment, 'PATH') ?? getWindowsEnvironmentValue(process.env, 'PATH') ?? ''; + const resolveOptions = { + cwd, + path: searchPath, pathExt, - }); + }; + + return shouldSearchCurrentDirectory() ? whichCommandSync(parsed.file, resolveOptions) : resolvePathDirectories(parsed.file, resolveOptions); +}; + +const hasWindowsPathSeparator = file => file.includes('/') || file.includes('\\') || file.includes(':'); + +const shouldSearchCurrentDirectory = () => getWindowsEnvironmentValue(process.env, 'NODEFAULTCURRENTDIRECTORYINEXEPATH') === undefined; + +const resolvePathDirectories = (file, {cwd, path: searchPath, pathExt}) => { + for (const directory of searchPath.split(path.delimiter)) { + const unquotedDirectory = directory.length > 1 && directory.startsWith('"') && directory.endsWith('"') ? directory.slice(1, -1) : directory; + if (unquotedDirectory === '') { + continue; + } + + const resolvedFile = whichCommandSync(path.resolve(cwd, unquotedDirectory, file), {cwd, pathExt}); + if (resolvedFile !== undefined) { + return resolvedFile; + } + } }; // Node sorts Windows environment keys and uses the first case-insensitive match when spawning. diff --git a/test/arguments/command-resolution.js b/test/arguments/command-resolution.js index 627c1cce54..65ea8edb6e 100644 --- a/test/arguments/command-resolution.js +++ b/test/arguments/command-resolution.js @@ -1,4 +1,4 @@ -import {cp, mkdir} from 'node:fs/promises'; +import {cp, mkdir, unlink} from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import test from 'ava'; @@ -166,30 +166,114 @@ test('Does not mutate arguments nor options', testNoMutation, {}); test('Does not mutate arguments nor options with a shell', testNoMutation, {shell: true}); if (isWindows) { + const nodeOnlyOptions = { + extendEnv: false, + env: { + Path: path.dirname(process.execPath), + PathExt: '.EXE', + }, + }; + // A bare command name without an extension is resolved using `PATHEXT`. test('Resolves command extension using PATHEXT (sync)', t => { const {stdout} = execaSync('hello'); t.is(stdout, 'Hello World'); }); - test('Runs a .com executable selected by PATHEXT', async t => { + test('Uses PATHEXT extension order for direct executables', async t => { const binaryDirectory = path.join(FIXTURES_DIRECTORY, 'node_modules', '.bin'); await mkdir(binaryDirectory, {recursive: true}); - await cp(process.execPath, path.join(binaryDirectory, 'node-com.com')); + const command = 'node-extension-order'; + const executablePath = path.join(binaryDirectory, `${command}.exe`); + const comPath = path.join(binaryDirectory, `${command}.com`); + await Promise.all([ + cp(process.execPath, executablePath), + cp(process.execPath, comPath), + ]); + t.teardown(async () => { + await Promise.all([unlink(executablePath), unlink(comPath)]); + }); const options = { preferLocal: true, localDir: FIXTURES_DIRECTORY, extendEnv: false, env: { Path: path.dirname(process.execPath), - PathExt: '.COM', + PathExt: '.EXE;.COM', + }, + }; + const nodeExpression = 'JSON.stringify({executablePath: process.execPath, argv0: process.argv0})'; + const {stdout} = await execa(command, ['--print', nodeExpression], options); + const {executablePath: actualExecutablePath, argv0} = JSON.parse(stdout); + t.is(actualExecutablePath.toLowerCase(), executablePath.toLowerCase()); + t.is(argv0, command); + + const {stdout: stdoutSync} = execaSync(command, ['--print', nodeExpression], options); + const {executablePath: actualExecutablePathSync, argv0: argv0Sync} = JSON.parse(stdoutSync); + t.is(actualExecutablePathSync.toLowerCase(), executablePath.toLowerCase()); + t.is(argv0Sync, command); + }); + + test.serial('Respects NoDefaultCurrentDirectoryInExePath', async t => { + const binaryDirectory = path.join(FIXTURES_DIRECTORY, 'node_modules', '.bin'); + await mkdir(binaryDirectory, {recursive: true}); + const command = 'node-current-directory'; + const currentDirectoryExecutable = path.join(FIXTURES_DIRECTORY, `${command}.exe`); + const pathExecutable = path.join(binaryDirectory, `${command}.exe`); + await Promise.all([ + cp(process.execPath, currentDirectoryExecutable), + cp(process.execPath, pathExecutable), + ]); + + const environmentName = 'NoDefaultCurrentDirectoryInExePath'; + const originalValue = process.env[environmentName]; + process.env[environmentName] = '1'; + t.teardown(async () => { + if (originalValue === undefined) { + delete process.env[environmentName]; + } else { + process.env[environmentName] = originalValue; + } + + await Promise.all([unlink(currentDirectoryExecutable), unlink(pathExecutable)]); + }); + + const options = { + cwd: FIXTURES_DIRECTORY, + extendEnv: false, + env: { + Path: binaryDirectory, + PathExt: '.EXE', }, }; - const {stdout} = await execa('node-com', ['--version'], options); - t.is(stdout, process.version); + const {stdout} = await execa(command, ['--print', 'process.execPath'], options); + t.is(stdout.toLowerCase(), pathExecutable.toLowerCase()); + + const {stdout: stdoutSync} = execaSync(command, ['--print', 'process.execPath'], options); + t.is(stdoutSync.toLowerCase(), pathExecutable.toLowerCase()); + }); - const {stdout: stdoutSync} = execaSync('node-com', ['--version'], options); - t.is(stdoutSync, process.version); + test('Does not search PATH for drive-relative commands', async t => { + const binaryDirectory = path.join(FIXTURES_DIRECTORY, 'node_modules', '.bin'); + await mkdir(binaryDirectory, {recursive: true}); + const commandName = 'node-drive-relative'; + const pathExecutable = path.join(binaryDirectory, `${commandName}.exe`); + await cp(process.execPath, pathExecutable); + t.teardown(async () => { + await unlink(pathExecutable); + }); + const drive = path.parse(FIXTURES_DIRECTORY).root.slice(0, 2); + const command = `${drive}${commandName}`; + const options = { + cwd: FIXTURES_DIRECTORY, + extendEnv: false, + env: { + Path: binaryDirectory, + PathExt: '.EXE', + }, + }; + await t.throwsAsync(execa(command, [], options)); + t.throws(() => execaSync(command, [], options)); }); // A `.cmd` file needs `cmd.exe`, so its forward-slash path must be normalized to @@ -223,31 +307,17 @@ if (isWindows) { test('Double-escapes explicit batch files excluded from PATHEXT', async t => { const commandArgument = '"& whoami &"'; - const options = { - extendEnv: false, - env: { - Path: path.dirname(process.execPath), - PathExt: '.EXE', - }, - }; const command = path.join(FIXTURES_DIRECTORY, 'echo-shim.cmd'); - const {stdout} = await execa(command, [commandArgument], options); + const {stdout} = await execa(command, [commandArgument], nodeOnlyOptions); t.is(stdout, commandArgument); - const {stdout: stdoutSync} = execaSync(command, [commandArgument], options); + const {stdout: stdoutSync} = execaSync(command, [commandArgument], nodeOnlyOptions); t.is(stdoutSync, commandArgument); }); test('Runs an explicit shebang script excluded from PATHEXT', async t => { const command = path.join(FIXTURES_DIRECTORY, 'echo.js'); - const options = { - extendEnv: false, - env: { - Path: path.dirname(process.execPath), - PathExt: '.EXE', - }, - }; - await testResolvesCommand(t, command, options); + await testResolvesCommand(t, command, nodeOnlyOptions); }); test.serial('Double-escapes metacharacters for preferLocal cmd-shims', async t => { From 51f60afeb9d6d857573fd248baa044c396bb4a0d Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 14 Jul 2026 17:53:41 +0200 Subject: [PATCH 4/7] Use resolved Windows batch file paths --- lib/arguments/command-file.js | 2 +- test/arguments/command-resolution.js | 55 +++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/lib/arguments/command-file.js b/lib/arguments/command-file.js index 51275ce59f..1616eb05e8 100644 --- a/lib/arguments/command-file.js +++ b/lib/arguments/command-file.js @@ -49,7 +49,7 @@ const escapeWindowsCommand = parsed => { const isDoubleEscape = resolvedFile !== undefined && batchFileRegExp.test(resolvedFile); // POSIX separators must become Windows ones (`foo/bar` -> `foo\bar`), otherwise resolution always fails with ENOENT - const escapedFile = escapeMetaChars(path.normalize(parsed.file)); + const escapedFile = escapeMetaChars(path.normalize(resolvedFile ?? parsed.file)); const escapedArguments = parsed.commandArguments.map(argument => escapeArgument(argument, isDoubleEscape)); const commandLine = `"${[escapedFile, ...escapedArguments].join(' ')}"`; diff --git a/test/arguments/command-resolution.js b/test/arguments/command-resolution.js index 65ea8edb6e..545bf0d8dd 100644 --- a/test/arguments/command-resolution.js +++ b/test/arguments/command-resolution.js @@ -1,4 +1,9 @@ -import {cp, mkdir, unlink} from 'node:fs/promises'; +import { + cp, + mkdir, + unlink, + writeFile, +} from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import test from 'ava'; @@ -227,7 +232,7 @@ if (isWindows) { const environmentName = 'NoDefaultCurrentDirectoryInExePath'; const originalValue = process.env[environmentName]; - process.env[environmentName] = '1'; + delete process.env[environmentName]; t.teardown(async () => { if (originalValue === undefined) { delete process.env[environmentName]; @@ -246,6 +251,13 @@ if (isWindows) { PathExt: '.EXE', }, }; + const {stdout: currentDirectoryStdout} = await execa(command, ['--print', 'process.execPath'], options); + t.is(currentDirectoryStdout.toLowerCase(), currentDirectoryExecutable.toLowerCase()); + + const {stdout: currentDirectoryStdoutSync} = execaSync(command, ['--print', 'process.execPath'], options); + t.is(currentDirectoryStdoutSync.toLowerCase(), currentDirectoryExecutable.toLowerCase()); + + process.env[environmentName] = '1'; const {stdout} = await execa(command, ['--print', 'process.execPath'], options); t.is(stdout.toLowerCase(), pathExecutable.toLowerCase()); @@ -253,6 +265,45 @@ if (isWindows) { t.is(stdoutSync.toLowerCase(), pathExecutable.toLowerCase()); }); + test.serial('Uses the resolved batch file with NoDefaultCurrentDirectoryInExePath', async t => { + const binaryDirectory = path.join(FIXTURES_DIRECTORY, 'node_modules', '.bin'); + await mkdir(binaryDirectory, {recursive: true}); + const command = 'batch-current-directory'; + const currentDirectoryBatchFile = path.join(FIXTURES_DIRECTORY, `${command}.cmd`); + const pathBatchFile = path.join(binaryDirectory, `${command}.cmd`); + await Promise.all([ + writeFile(currentDirectoryBatchFile, '@echo current directory'), + writeFile(pathBatchFile, '@echo PATH'), + ]); + + const environmentName = 'NoDefaultCurrentDirectoryInExePath'; + const originalValue = process.env[environmentName]; + process.env[environmentName] = '1'; + t.teardown(async () => { + if (originalValue === undefined) { + delete process.env[environmentName]; + } else { + process.env[environmentName] = originalValue; + } + + await Promise.all([unlink(currentDirectoryBatchFile), unlink(pathBatchFile)]); + }); + + const options = { + cwd: FIXTURES_DIRECTORY, + extendEnv: false, + env: { + Path: binaryDirectory, + PathExt: '.CMD', + }, + }; + const {stdout} = await execa(command, options); + t.is(stdout, 'PATH'); + + const {stdout: stdoutSync} = execaSync(command, options); + t.is(stdoutSync, 'PATH'); + }); + test('Does not search PATH for drive-relative commands', async t => { const binaryDirectory = path.join(FIXTURES_DIRECTORY, 'node_modules', '.bin'); await mkdir(binaryDirectory, {recursive: true}); From ebeb6274d7deb25cc05ab12b0cb476d87e97020d Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 14 Jul 2026 18:11:58 +0200 Subject: [PATCH 5/7] Respect child current-directory search opt-out --- lib/arguments/command-file.js | 4 ++-- test/arguments/command-resolution.js | 13 +++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/arguments/command-file.js b/lib/arguments/command-file.js index 1616eb05e8..eb2700e480 100644 --- a/lib/arguments/command-file.js +++ b/lib/arguments/command-file.js @@ -95,12 +95,12 @@ const resolvePath = parsed => { pathExt, }; - return shouldSearchCurrentDirectory() ? whichCommandSync(parsed.file, resolveOptions) : resolvePathDirectories(parsed.file, resolveOptions); + return shouldSearchCurrentDirectory(environment) ? whichCommandSync(parsed.file, resolveOptions) : resolvePathDirectories(parsed.file, resolveOptions); }; const hasWindowsPathSeparator = file => file.includes('/') || file.includes('\\') || file.includes(':'); -const shouldSearchCurrentDirectory = () => getWindowsEnvironmentValue(process.env, 'NODEFAULTCURRENTDIRECTORYINEXEPATH') === undefined; +const shouldSearchCurrentDirectory = environment => getWindowsEnvironmentValue(process.env, 'NODEFAULTCURRENTDIRECTORYINEXEPATH') === undefined && getWindowsEnvironmentValue(environment, 'NODEFAULTCURRENTDIRECTORYINEXEPATH') === undefined; const resolvePathDirectories = (file, {cwd, path: searchPath, pathExt}) => { for (const directory of searchPath.split(path.delimiter)) { diff --git a/test/arguments/command-resolution.js b/test/arguments/command-resolution.js index 545bf0d8dd..b5ca6cd64e 100644 --- a/test/arguments/command-resolution.js +++ b/test/arguments/command-resolution.js @@ -265,7 +265,7 @@ if (isWindows) { t.is(stdoutSync.toLowerCase(), pathExecutable.toLowerCase()); }); - test.serial('Uses the resolved batch file with NoDefaultCurrentDirectoryInExePath', async t => { + test.serial('Uses the resolved batch file when current-directory search is disabled', async t => { const binaryDirectory = path.join(FIXTURES_DIRECTORY, 'node_modules', '.bin'); await mkdir(binaryDirectory, {recursive: true}); const command = 'batch-current-directory'; @@ -278,7 +278,7 @@ if (isWindows) { const environmentName = 'NoDefaultCurrentDirectoryInExePath'; const originalValue = process.env[environmentName]; - process.env[environmentName] = '1'; + delete process.env[environmentName]; t.teardown(async () => { if (originalValue === undefined) { delete process.env[environmentName]; @@ -295,6 +295,7 @@ if (isWindows) { env: { Path: binaryDirectory, PathExt: '.CMD', + [environmentName]: '1', }, }; const {stdout} = await execa(command, options); @@ -302,6 +303,14 @@ if (isWindows) { const {stdout: stdoutSync} = execaSync(command, options); t.is(stdoutSync, 'PATH'); + + delete options.env[environmentName]; + process.env[environmentName] = '1'; + const {stdout: parentEnvironmentStdout} = await execa(command, options); + t.is(parentEnvironmentStdout, 'PATH'); + + const {stdout: parentEnvironmentStdoutSync} = execaSync(command, options); + t.is(parentEnvironmentStdoutSync, 'PATH'); }); test('Does not search PATH for drive-relative commands', async t => { From c9cb4928f09ceac72f44b8ef47551f2d094be437 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 14 Jul 2026 21:21:44 +0200 Subject: [PATCH 6/7] Test empty current-directory search opt-out --- test/arguments/command-resolution.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/arguments/command-resolution.js b/test/arguments/command-resolution.js index b5ca6cd64e..2ef041f222 100644 --- a/test/arguments/command-resolution.js +++ b/test/arguments/command-resolution.js @@ -295,7 +295,7 @@ if (isWindows) { env: { Path: binaryDirectory, PathExt: '.CMD', - [environmentName]: '1', + [environmentName]: '', }, }; const {stdout} = await execa(command, options); @@ -305,7 +305,7 @@ if (isWindows) { t.is(stdoutSync, 'PATH'); delete options.env[environmentName]; - process.env[environmentName] = '1'; + process.env[environmentName] = ''; const {stdout: parentEnvironmentStdout} = await execa(command, options); t.is(parentEnvironmentStdout, 'PATH'); From 47ac984e64c061965d5ad6ef9ab626ab31eb655c Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 31 Jul 2026 03:55:34 +0200 Subject: [PATCH 7/7] Fix tests --- lib/arguments/options.js | 5 ++++- test/terminate/timeout.js | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/arguments/options.js b/lib/arguments/options.js index 240bfd2f24..c9f176a288 100644 --- a/lib/arguments/options.js +++ b/lib/arguments/options.js @@ -14,6 +14,8 @@ import {normalizeCwd} from './cwd.js'; import {normalizeFileUrl} from './file-url.js'; import {normalizeFdSpecificOptions} from './specific.js'; +const cmdExeRegExp = /^cmd(?:\.exe)?$/i; + // Normalize the options object, and sometimes also the file paths and arguments. // Applies default values, validate allowed options, normalize them. export const normalizeOptions = (filePath, rawArguments, rawOptions) => { @@ -37,7 +39,8 @@ export const normalizeOptions = (filePath, rawArguments, rawOptions) => { options.forceKillAfterDelay = normalizeForceKillAfterDelay(options.forceKillAfterDelay); options.lines = options.lines.map((lines, fdNumber) => lines && !BINARY_ENCODINGS.has(options.encoding) && options.buffer[fdNumber]); - if (process.platform === 'win32' && path.basename(file, '.exe') === 'cmd') { + // The file is now an absolute path resolved via `PATHEXT`, so its extension might be uppercase (`cmd.EXE`) + if (process.platform === 'win32' && cmdExeRegExp.test(path.basename(file))) { // #116 commandArguments.unshift('/q'); } diff --git a/test/terminate/timeout.js b/test/terminate/timeout.js index ef7706fc59..e73b756bb0 100644 --- a/test/terminate/timeout.js +++ b/test/terminate/timeout.js @@ -21,7 +21,8 @@ test('timeout kills the subprocess if it times out, in sync mode', async t => { t.true(isTerminated); t.is(signal, 'SIGTERM'); t.true(timedOut); - t.is(originalMessage, 'spawnSync node ETIMEDOUT'); + // On Windows, the command is spawned using the absolute path resolved via `PATHEXT`, so it appears in Node.js' own error message + t.regex(originalMessage, /^spawnSync .*node(?:\.[A-Za-z]+)? ETIMEDOUT$/); t.is(shortMessage, `Command timed out after 1 milliseconds: node forever.js\n${originalMessage}`); t.is(message, shortMessage); });