diff --git a/plugins/codex-security/scripts/launch_codex_security_mcp.cmd b/plugins/codex-security/scripts/launch_codex_security_mcp.cmd index 289bc49e0..2a897e9ea 100644 --- a/plugins/codex-security/scripts/launch_codex_security_mcp.cmd +++ b/plugins/codex-security/scripts/launch_codex_security_mcp.cmd @@ -20,8 +20,10 @@ if defined CODEX_BROWSER_USE_NODE_PATH if exist "%CODEX_BROWSER_USE_NODE_PATH%" if defined CODEX_ELECTRON_RESOURCES_PATH if exist "%CODEX_ELECTRON_RESOURCES_PATH%\cua_node\bin\node.exe" ("%CODEX_ELECTRON_RESOURCES_PATH%\cua_node\bin\node.exe" "%CODEX_SECURITY_MCP_SCRIPT%" %* & exit) if defined CODEX_CLI_PATH for %%I in ("%CODEX_CLI_PATH%") do if exist "%%~dpIcua_node\bin\node.exe" ("%%~dpIcua_node\bin\node.exe" "%CODEX_SECURITY_MCP_SCRIPT%" %* & exit) -where node >nul 2>&1 -if not errorlevel 1 (node "%CODEX_SECURITY_MCP_SCRIPT%" %* & exit) +rem Resolve Node from PATH once and launch that exact executable. +set "CODEX_SECURITY_MCP_SEARCH_PATH=%PATH:"=%" +for %%I in (node.exe) do set "CODEX_SECURITY_MCP_NODE=%%~$CODEX_SECURITY_MCP_SEARCH_PATH:I" +if defined CODEX_SECURITY_MCP_NODE ("%CODEX_SECURITY_MCP_NODE%" "%CODEX_SECURITY_MCP_SCRIPT%" %* & exit) echo Codex Security could not find a Node runtime. Reinstall or update Codex, or set CODEX_MCP_NODE_PATH to an executable Node runtime. 1>&2 exit /b 127 diff --git a/sdk/typescript/tests-ts/mcp-launcher.test.ts b/sdk/typescript/tests-ts/mcp-launcher.test.ts index 047c5e2ed..b0f20956f 100644 --- a/sdk/typescript/tests-ts/mcp-launcher.test.ts +++ b/sdk/typescript/tests-ts/mcp-launcher.test.ts @@ -1,6 +1,8 @@ import { spawnSync } from "node:child_process"; import { chmod, + copyFile, + mkdir, mkdtemp, readFile, realpath, @@ -8,10 +10,110 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, parse } from "node:path"; import { expect, test } from "bun:test"; import { PLUGIN_ROOT } from "./plugin-root.js"; +test.skipIf(process.platform !== "win32")( + "launches the PATH Node executable independently of command extensions and the caller directory", + async () => { + const node = Bun.which("node"); + if (node === null) + throw new Error("Node is required for the launcher test."); + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-launch-")), + ); + try { + const caller = join(root, "caller"); + const runtime = join(root, "Node runtime"); + const scripts = join(root, "plugin", "scripts"); + const server = join(root, "plugin", "mcp", "server.mjs"); + await Promise.all( + [caller, runtime, scripts, join(root, "plugin", "mcp")].map( + (directory) => mkdir(directory, { recursive: true }), + ), + ); + const launcher = join(scripts, "launch_codex_security_mcp.cmd"); + await copyFile( + join(PLUGIN_ROOT, "scripts", "launch_codex_security_mcp.cmd"), + launcher, + ); + await copyFile(node, join(runtime, "node.exe")); + await writeFile( + server, + `console.log(JSON.stringify({ executable: process.execPath, cwd: process.cwd(), args: process.argv.slice(2) }));\nprocess.exitCode = 23;\n`, + ); + const marker = join(root, "command-used"); + for (const directory of [caller, runtime]) { + for (const name of ["node.cmd", "where.cmd"]) { + await writeFile( + join(directory, name), + `@echo off\n> "${marker}" echo used\nexit /b 0\n`, + ); + } + } + // Native candidates in the caller directory must not participate either. + await copyFile(node, join(caller, "node.exe")); + await copyFile(node, join(caller, "where.exe")); + for (const path of [ + runtime, + `"${runtime}"`, + `;.;;relative-bin;${runtime};`, + ]) { + const result = spawnSync( + join(process.env["SystemRoot"]!, "System32", "cmd.exe"), + ["/d", "/s", "/c", `""${launcher}" --stdio "argument with spaces""`], + { + cwd: caller, + env: { + SystemRoot: process.env["SystemRoot"], + PATH: path, + PATHEXT: ".CMD;.EXE;.BAT;.COM", + }, + encoding: "utf8", + windowsHide: true, + windowsVerbatimArguments: true, + }, + ); + expect( + result.status, + `PATH=${path}\n${result.stderr || result.error?.message || ""}`, + ).toBe(23); + expect(JSON.parse(result.stdout)).toEqual({ + executable: join(runtime, "node.exe"), + cwd: parse(launcher).root, + args: ["--stdio", "argument with spaces"], + }); + await expect(readFile(marker)).rejects.toMatchObject({ + code: "ENOENT", + }); + } + const missing = spawnSync( + join(process.env["SystemRoot"]!, "System32", "cmd.exe"), + ["/d", "/s", "/c", `""${launcher}" --stdio"`], + { + cwd: caller, + env: { + SystemRoot: process.env["SystemRoot"], + PATH: ";.;relative-bin;", + }, + encoding: "utf8", + windowsHide: true, + windowsVerbatimArguments: true, + }, + ); + expect(missing.status, missing.stderr || missing.error?.message).toBe( + 127, + ); + expect(missing.stdout).toBe(""); + expect(missing.stderr).toContain("could not find a Node runtime"); + await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, +); + test("starts the packaged MCP server with managed Node and an empty PATH", async () => { const node = Bun.which("node"); if (node === null)