Skip to content

fix(client-windows): package msedgedriver and make the Edge handler actually use it - #670

Draft
nblair2 wants to merge 3 commits into
cmu-sei:masterfrom
nblair2:fix/windows-edge-webdriver
Draft

fix(client-windows): package msedgedriver and make the Edge handler actually use it#670
nblair2 wants to merge 3 commits into
cmu-sei:masterfrom
nblair2:fix/windows-edge-webdriver

Conversation

@nblair2

@nblair2 nblair2 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Problem

The Windows client has a BrowserEdge handler, but Edge cannot actually be driven. Three things are wrong, and all three have to be fixed for the handler to work:

  1. No driver is packaged. chromedriver.exe and geckodriver.exe reach the output directory because Selenium.WebDriver.ChromeDriver and Selenium.WebDriver.GeckoDriver each carry a build target that copies the driver into $(TargetDir). There is no equivalent for Edge, so msedgedriver.exe is never produced by the build.

  2. GetEdgeDriver throws away the service it just built. Handlers/BrowserEdge.cs builds an EdgeDriverService rooted at the application base directory and sets HideCommandPromptWindow on it, then calls new EdgeDriver(options) — which discards that service and constructs its own. Both the base-directory pin and the hidden console window are inert. BrowserChrome does this correctly (new ChromeDriver(service, options)).

    The practical consequence: the discarded service leaves DriverServicePath unset, so Selenium falls through to DriverFinder/Selenium Manager, which resolves the driver by downloading it. On a host with no outbound network access this fails even when msedgedriver.exe is sitting next to ghosts.exe. (Ghosts.Client.Universal's BrowserFirefox has a comment describing exactly this reasoning for pinning the service.)

  3. The Edge process names are file descriptions, not process names. ProcessNames.MSEdge is "Microsoft Edge" and ProcessNames.MSEdgeDriver is "Microsoft Edge WebDriver (32 bit)". Both are handed to KillProcessAndChildrenByName, which calls Process.GetProcessesByName, so neither ever matches and BrowserEdge's cleanup silently reaps nothing. msedge.exe and msedgedriver.exe accumulate for the life of the client. Compare Chrome => "chrome" / ChromeDriver => "chromedriver".

Change

Three commits, one concern each, all confined to Ghosts.Client.Windows:

  • build(client-windows) — add Selenium.WebDriver.MSEdgeDriver to Ghosts.Client.csproj. It is the same shape as the ChromeDriver/GeckoDriver packages already in use (its CopyMsEdgeDriverToBin target runs BeforeTargets="AfterBuild" and copies driver/win32/msedgedriver.exe into $(TargetDir)). Pinned explicitly, as the other two are.

    WebDriverPlatform is deliberately left unset. All three driver packages share that property and resolve it to win32 on Windows; the ChromeDriver package ships no win64 directory, so forcing it would break the existing chromedriver copy.

  • fix(client-windows) — pass the already-constructed service: new EdgeDriver(service, options). Selenium then loads the driver from the base directory and never invokes Selenium Manager. This also makes the existing HideCommandPromptWindow = true take effect.

  • fix(client-windows)MSEdge => "msedge", MSEdgeDriver => "msedgedriver".

Nothing outside the Edge path is touched, and no adjacent code is refactored.

Testing

Not runtime-tested. I don't have a Windows environment to build and exercise the .NET Framework client, so this is verified by inspection plus direct inspection of the NuGet package, not by running GHOSTS.

What I did confirm:

  • Selenium.WebDriver.MSEdgeDriver 149.0.4022.98 contains driver/win32/msedgedriver.exe (PE32, i386 — consistent with the (32 bit) name the old ProcessNames string used), driver/win64/msedgedriver.exe, and build/Selenium.WebDriver.MSEdgeDriver.targets.
  • That targets file defines CopyMsEdgeDriverToBin with BeforeTargets="AfterBuild" and Condition="'$(PublishMsEdgeDriver)' == 'false'", where PublishMsEdgeDriver defaults to false — i.e. a plain msbuild build copies the driver to $(TargetDir) without any extra property. DefinePropertiesMSEdgeDriver.targets resolves WebDriverPlatform to win32 when $(OS) == 'Windows_NT'.

A maintainer with a Windows build should confirm:

  • msedgedriver.exe appears in bin\x86\Release\ and bin\x64\Release\, and chromedriver.exe / geckodriver.exe are still present (regression check on the shared WebDriverPlatform property).
  • A timeline with "HandlerType": "BrowserEdge" starts Edge, with no console window flash.
  • With outbound network blocked and msedgedriver.exe next to ghosts.exe, Edge still starts and selenium-manager.exe never spawns.
  • After the handler finishes, no orphaned msedge.exe / msedgedriver.exe remain.

Note

Ghosts.Client.Universal's BrowserEdge has the same "no service" shape (new EdgeDriver(options)) and its csproj carries no Edge driver package either. I've left it alone to keep this PR to one concern, but it likely needs the same treatment.

🤖 Generated with Claude Code

nblair2 and others added 3 commits July 9, 2026 22:03
The Windows client ships chromedriver.exe and geckodriver.exe because
Selenium.WebDriver.ChromeDriver and Selenium.WebDriver.GeckoDriver each carry a
build target that copies the driver into $(TargetDir). Nothing supplied a driver
for the Edge handler, so BrowserEdge could never start a browser on a host that
had not obtained msedgedriver.exe by some other means.

Add Selenium.WebDriver.MSEdgeDriver, which is the same shape as the other two
(CopyMsEdgeDriverToBin runs BeforeTargets="AfterBuild" and drops
driver/win32/msedgedriver.exe next to ghosts.exe). Version is pinned explicitly,
matching how ChromeDriver and GeckoDriver are pinned.

WebDriverPlatform is deliberately left unset: all three driver packages share
that property and resolve it to win32 on Windows, and the ChromeDriver package
ships no win64 directory, so forcing it would break the chromedriver copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vice

GetEdgeDriver builds an EdgeDriverService rooted at the application base
directory and sets HideCommandPromptWindow on it, then calls
new EdgeDriver(options), which discards that service and constructs its own.
Both the base-directory pin and the hidden console window were therefore
inert.

Because the discarded service left DriverServicePath unset, Selenium fell
through to DriverFinder and Selenium Manager, which resolves the driver by
downloading it. That fails on hosts with no outbound network access, even
when msedgedriver.exe sits next to ghosts.exe.

Pass the service through, as BrowserChrome already does for
ChromeDriverService. Selenium then loads the driver from the base directory
and never reaches for the network.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ProcessNames.MSEdge and ProcessNames.MSEdgeDriver held the executables' file
descriptions ("Microsoft Edge", "Microsoft Edge WebDriver (32 bit)") rather
than their process names. Both values are passed to
KillProcessAndChildrenByName, which calls Process.GetProcessesByName, so they
never matched anything and BrowserEdge's cleanup silently reaped nothing.
Left alone, msedge.exe and msedgedriver.exe accumulate for the life of the
client.

Use "msedge" and "msedgedriver", consistent with Chrome => "chrome",
ChromeDriver => "chromedriver", Firefox => "firefox" and
GeckoDriver => "geckodriver".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sei-dupdyke

Copy link
Copy Markdown
Contributor

Does this need to be changed in the universal client as well?

@nblair2
nblair2 marked this pull request as draft July 10, 2026 12:13
@nblair2

nblair2 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Does this need to be changed in the universal client as well?

Yes, probably does. Good catch.

@nblair2

nblair2 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

I won't have time to test this until the end of the month but not pressing.

@rbreesems

Copy link
Copy Markdown
Contributor

In our deployments, we deploy Edge and Edgedriver separately as over time these versions change. Same for chromedriver, geckodriver. We don't try to package drivers like this up with Ghosts, they are bound to get out of date.

@nblair2

nblair2 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

In our deployments, we deploy Edge and Edgedriver separately as over time these versions change. Same for chromedriver, geckodriver. We don't try to package drivers like this up with Ghosts, they are bound to get out of date.

We currently have chromedriver pinned. Should we remove that?

I use this script to install and match driver versions (lightly edited to remove internal stuff):

# Install [CMU GHOSTS](https://github.com/cmu-sei/GHOSTS)
$ProgressPreference = 'SilentlyContinue'
$ErrorActionPreference = 'Stop'
$firefoxInstallerUrl = "https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=en-US"
$geckoDriverPath = "C:\Program Files (x86)\geckodriver\"
$tempPath = "C:\Windows\Temp\ghosts"
$arch = if ($ENV:PROCESSOR_ARCHITECTURE -match "AMD64|IA64|ARM64") { "x64" } else { "x86" }
$ghostsZipUrl = "https://github.com/cmu-sei/GHOSTS/releases/download/v9.0.0/ghosts-client-universal-win-$arch.zip"
$ghostsPath = "C:\ghosts"

Write-Host "Installing the CMU GHOSTS Client"
# Create a temp file to download things to
New-Item -Path $tempPath -ItemType Directory -Force

Write-Host "--Installing Firefox latest"
$firefoxInstallerPath = Join-Path -Path $tempPath -ChildPath "firefox-installer.exe"
Invoke-WebRequest -Uri $firefoxInstallerUrl -OutFile $firefoxInstallerPath
Start-Process -FilePath $firefoxInstallerPath -ArgumentList "/Silent" -Wait

Write-Host "--Downloading and extracting GHOSTS to $ghostsPath"
$ghostsZipPath = Join-Path -Path $tempPath -ChildPath "ghosts.zip"
curl.exe --request GET $ghostsZipUrl --output $ghostsZipPath
New-Item -Path $ghostsPath -ItemType Directory -Force
Expand-Archive -Path $ghostsZipPath -DestinationPath $ghostsPath -Force
if (-not (Test-Path "$ghostsPath\ghosts.exe") -or -not (Test-Path "$ghostsPath\geckodriver.exe")) {
    throw "GHOSTS extraction failed: ghosts.exe or geckodriver.exe missing from $ghostsPath"
}

Write-Host "--Moving geckodriver from ghosts folder to $geckoDriverPath"
New-Item -Path $geckoDriverPath -ItemType Directory -Force
Copy-Item -Path "$ghostsPath\geckodriver.exe" -Destination $geckoDriverPath
Write-Host "--Updating PATH to include geckodriver"
$currentPath = [System.Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::Machine)
$newPath = "$currentPath;$geckoDriverPath"
[System.Environment]::SetEnvironmentVariable("Path", $newPath, [System.EnvironmentVariableTarget]::Machine)
$updatedPath = [System.Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::Machine)
Write-Host "Updated PATH: $updatedPath"

Write-Host "--Registering AutoIt DLLs"
regsvr32.exe /S "$ghostsPath/AutoItX3.dll"
regsvr32.exe /S "$ghostsPath/AutoItX3_x64.dll"

# Ensure chromedriver.exe version matches installed chome.exe version
Write-Host "--Ensuring chromedriver matches installed Chrome version"
$chromePath = "C:\Program Files\Google\Chrome\Application\chrome.exe"
if (Test-Path $chromePath) {
    $chromeVersion = (Get-Item $chromePath).VersionInfo.ProductVersion
    Write-Host "Detected Chrome version: $chromeVersion"
    $chromeMajorVersion = $chromeVersion.Split(".")[0]
    $chromedriverUrl = "https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_$chromeMajorVersion"
    $chromedriverVersion = Invoke-WebRequest -Uri $chromedriverUrl -UseBasicParsing
    $chromedriverDownloadUrl = "https://storage.googleapis.com/chrome-for-testing-public/$chromedriverVersion/win64/chromedriver-win64.zip"
    $chromedriverZipPath = Join-Path -Path $tempPath -ChildPath "chromedriver.zip"
    Invoke-WebRequest -Uri $chromedriverDownloadUrl -OutFile $chromedriverZipPath
    Expand-Archive -Path $chromedriverZipPath -DestinationPath $tempPath -Force
    $extractedExe = Get-ChildItem -Path $tempPath -Filter "chromedriver.exe" -Recurse | Select-Object -First 1
    Copy-Item -Path $extractedExe.FullName -Destination "$ghostsPath\chromedriver.exe" -Force
    Write-Host "Chromedriver version $(& "$ghostsPath\chromedriver.exe" --version) installed to GHOSTS folder"
} else {
    Write-Host "Chrome is not installed. Skipping chromedriver installation."
}

# Clean up
Remove-Item -Path $tempPath -Recurse -Force
Write-Host "GHOSTS has been installed!"

A similar check for edgedriver would look something like this:

# Ensure msedgedriver.exe version matches installed msedge.exe version
Write-Host "--Ensuring msedgedriver matches installed Edge version"
# Edge installs under Program Files (x86) even on 64-bit Windows
$edgePath = "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
if (Test-Path $edgePath) {
    $edgeVersion = (Get-Item $edgePath).VersionInfo.ProductVersion
    Write-Host "Detected Edge version: $edgeVersion"
    $edgeMajorVersion = $edgeVersion.Split(".")[0]
    $edgedriverUrl = "https://msedgedriver.microsoft.com/LATEST_RELEASE_${edgeMajorVersion}_WINDOWS"
    # This endpoint answers in UTF-16LE with a BOM, so decode the raw bytes instead of
    # letting Invoke-WebRequest interpret them as text
    $edgedriverResponse = Invoke-WebRequest -Uri $edgedriverUrl -UseBasicParsing
    $edgedriverVersion = [System.Text.Encoding]::Unicode.GetString($edgedriverResponse.RawContentStream.ToArray()).Trim([char]0xFEFF).Trim()
    $edgedriverDownloadUrl = "https://msedgedriver.microsoft.com/$edgedriverVersion/edgedriver_win64.zip"
    $edgedriverZipPath = Join-Path -Path $tempPath -ChildPath "edgedriver.zip"
    Invoke-WebRequest -Uri $edgedriverDownloadUrl -OutFile $edgedriverZipPath
    Expand-Archive -Path $edgedriverZipPath -DestinationPath $tempPath -Force
    $extractedEdgeExe = Get-ChildItem -Path $tempPath -Filter "msedgedriver.exe" -Recurse | Select-Object -First 1
    Copy-Item -Path $extractedEdgeExe.FullName -Destination "$ghostsPath\msedgedriver.exe" -Force
    Write-Host "Msedgedriver version $(& "$ghostsPath\msedgedriver.exe" --version) installed to GHOSTS folder"
} else {
    Write-Host "Edge is not installed. Skipping msedgedriver installation."
}

@rbreesems

Copy link
Copy Markdown
Contributor

I believe it depends on how your methodology for deploying hosts works. In our methodology, we don't deploy a host template that has applications preinstalled. Instead, we deploy a base OS template, then deploy all applications like Chrome/Chrome driver, Firefox/Geckodriver, Edge/Edge driver, Ghosts. The versions of each are dependent upon the OS template being modified. We tend to validate a particular version for an OS (like a particular Chrome/Chrome driver) and try to use that same version on different OS flavors (Rocky 8, Rocky 9) if it works just to try to reduce the number of versions we have to keep track of. Our deployments do not have external internet access, so we don't have to worry about the OS auto-upgrading a Chrome version and getting out of date with the deployed Chrome driver.

I am currently vetting Ghosts 9 on Ubuntu 26, and right now fighting the battle of finding the right Chrome/Chromedriver to install during deployment.

Fortunately the same Ghosts 9 binary once built by dotnet10 works across a variety of different Linux OSes (Rocky 8/9, Ubuntu 20/22/24/26, Oracle 9, Kali Linux) but the browser apps for those OSes tend to be different versions. For Ghosts Classic Windows, it is the same story. The same Ghosts binary works for Win10/11 (and different release versions of those major versions), but the Edge/Edge driver we deploy on those are vetted for each version. (currently, we use the same Edge/Edge driver for both Win10/11, just picked one of the latest versions). We only deploy Edge on Windows hosts, even though it can work on Linux as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants