diff --git a/README.md b/README.md
index 15a7855..22b8f93 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,10 @@ Copy `src/WinDbgMCP.Server/appsettings.example.json` to `appsettings.json` and e
"Vm": {
"VmxPath": "C:\\path\\to\\your\\vm.vmx",
"VmrunPath": "C:\\Program Files (x86)\\VMware\\VMware Workstation\\vmrun.exe",
+ "HostType": "ws",
+ "HostUrl": "",
+ "HostUsername": "",
+ "HostPassword": "",
"VmPassword": "",
"GuestUsername": "YourUser",
"GuestPassword": "YourPass"
@@ -84,6 +88,8 @@ Copy `src/WinDbgMCP.Server/appsettings.example.json` to `appsettings.json` and e
}
```
+**Remote hypervisors (ESXi / vCenter / shared Workstation):** VM and guest operations can target a VM on another machine. Set `HostType` to `esx`, `vc`, or `ws-shared`, point `HostUrl` at the hypervisor API (e.g. `https://esxi-host/sdk`), and provide `HostUsername`/`HostPassword`. For esx/vc, `VmxPath` is a datastore path like `[datastore1] win10/win10.vmx`. vmrun still runs locally, so VMware Workstation (or VIX) must be installed on this machine. All of this can also be switched at runtime via `vm_set_target`. Note that kernel debugging is independent of this: the KDNET target must be able to reach this host over UDP regardless of where the VM runs.
+
### 3. Add to Your MCP Client
@@ -138,7 +144,7 @@ dotnet run --project src/WinDbgMCP.Server/WinDbgMCP.Server.csproj
| `vm_snapshot_restore` | Restore a named snapshot (debug sessions are cleanly torn down and can reconnect after) |
| `vm_snapshot_list` | List available snapshots |
| `vm_screenshot` | Capture VM display as PNG |
-| `vm_set_target` | Switch the active VM target at runtime (VMX path + credentials) |
+| `vm_set_target` | Switch the active VM target at runtime (VMX path + credentials, optionally a remote ESXi/vCenter host) |
### Kernel Debug Tools (7)
| Tool | Description |
diff --git a/src/WinDbgMCP.Server/Configuration/ServerConfig.cs b/src/WinDbgMCP.Server/Configuration/ServerConfig.cs
index aff013b..32352a2 100644
--- a/src/WinDbgMCP.Server/Configuration/ServerConfig.cs
+++ b/src/WinDbgMCP.Server/Configuration/ServerConfig.cs
@@ -17,6 +17,27 @@ public sealed class VmConfig
{
public string VmxPath { get; set; } = string.Empty;
public string VmrunPath { get; set; } = @"C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe";
+
+ ///
+ /// vmrun host type (-T flag): "ws" (local Workstation, default), "esx" (ESXi),
+ /// "vc" (vCenter), "ws-shared" (shared Workstation), "fusion", "player".
+ ///
+ public string HostType { get; set; } = "ws";
+
+ ///
+ /// Remote hypervisor URL (-h flag), e.g. "https://esxi-host/sdk".
+ /// Leave empty for local Workstation. For esx/vc, VmxPath must be a
+ /// datastore path like "[datastore1] win10/win10.vmx".
+ ///
+ public string HostUrl { get; set; } = string.Empty;
+
+ ///
+ /// Hypervisor login (-u / -p flags). Required when HostUrl is set.
+ /// Note: vmrun only accepts these on the command line, so the password
+ /// is visible in the local process list while a vmrun command runs.
+ ///
+ public string HostUsername { get; set; } = string.Empty;
+ public string HostPassword { get; set; } = string.Empty;
///
/// VM encryption password (for encrypted VMs). Used as -vp flag in vmrun.
///
diff --git a/src/WinDbgMCP.Server/Tools/VmTools.cs b/src/WinDbgMCP.Server/Tools/VmTools.cs
index 02a6dac..6e95084 100644
--- a/src/WinDbgMCP.Server/Tools/VmTools.cs
+++ b/src/WinDbgMCP.Server/Tools/VmTools.cs
@@ -209,15 +209,21 @@ public static async Task VmSnapshotRestore(
"Switch the active VM target at runtime. " +
"All VM, guest, and snapshot operations will target the new VM after this call. " +
"If the kernel debugger is connected, it is cleanly disconnected first. " +
+ "Supports remote hypervisors: pass hostType/hostUrl/host credentials to target " +
+ "a VM on ESXi, vCenter, or shared Workstation instead of the local machine. " +
"Note: kd_connect uses its own connection string — this only affects guest/VM operations.")]
public static async Task VmSetTarget(
StateCoordinator state,
VmwareManager vmware,
DbgEngManager dbgEng,
- [Description("Absolute path to the .vmx file of the target VM")] string vmxPath,
+ [Description("Path to the .vmx file. For esx/vc hosts use a datastore path like \"[datastore1] win10/win10.vmx\"")] string vmxPath,
[Description("Guest OS username")] string guestUsername,
[Description("Guest OS password")] string guestPassword,
[Description("VM encryption password (leave empty if VM is not encrypted)")] string vmPassword = "",
+ [Description("Hypervisor type: ws (local Workstation), esx, vc, ws-shared, fusion, player. Omit to keep current.")] string? hostType = null,
+ [Description("Remote hypervisor URL, e.g. https://esxi-host/sdk. Pass empty string to switch back to local. Omit to keep current.")] string? hostUrl = null,
+ [Description("Hypervisor login username (required when hostUrl is set). Omit to keep current.")] string? hostUsername = null,
+ [Description("Hypervisor login password. Omit to keep current.")] string? hostPassword = null,
CancellationToken ct = default)
{
var precheck = await state.ValidatePreconditionsAsync("vm_set_target");
@@ -233,7 +239,15 @@ public static async Task VmSetTarget(
}
// Switch the target
- vmware.UpdateTarget(vmxPath, guestUsername, guestPassword, vmPassword);
+ try
+ {
+ vmware.UpdateTarget(vmxPath, guestUsername, guestPassword, vmPassword,
+ hostType, hostUrl, hostUsername, hostPassword);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return $"vm_set_target failed: {ex.Message}";
+ }
// Reset all state — power state of the new VM is unknown until we check
var powerState = await vmware.GetPowerStateAsync(ct);
diff --git a/src/WinDbgMCP.Server/Vmware/VmwareManager.cs b/src/WinDbgMCP.Server/Vmware/VmwareManager.cs
index 4730800..356cd79 100644
--- a/src/WinDbgMCP.Server/Vmware/VmwareManager.cs
+++ b/src/WinDbgMCP.Server/Vmware/VmwareManager.cs
@@ -20,6 +20,11 @@ public sealed class VmwareManager
private string _vmPassword;
private string _guestUser;
private string _guestPass;
+ private string _hostType;
+ private string _hostUrl;
+ private string _hostUser;
+ private string _hostPass;
+ private string _hostArgs;
private readonly TimeoutConfig _timeouts;
private readonly SecurityConfig _security;
private readonly ILogger _logger;
@@ -30,6 +35,9 @@ public sealed class VmwareManager
public string VmxPath => _vmxPath;
public string GuestUser => _guestUser;
+ public string HostType => _hostType;
+ public string HostUrl => _hostUrl;
+ public bool IsRemoteHost => !string.IsNullOrEmpty(_hostUrl);
public VmwareManager(ServerConfig config, ILogger logger)
{
@@ -38,11 +46,17 @@ public VmwareManager(ServerConfig config, ILogger logger)
_vmPassword = config.Vm.VmPassword;
_guestUser = config.Vm.GuestUsername;
_guestPass = config.Vm.GuestPassword;
+ _hostType = string.IsNullOrWhiteSpace(config.Vm.HostType) ? "ws" : config.Vm.HostType.Trim();
+ _hostUrl = config.Vm.HostUrl;
+ _hostUser = config.Vm.HostUsername;
+ _hostPass = config.Vm.HostPassword;
_timeouts = config.Timeouts;
_security = config.Security;
_logger = logger;
+ _hostArgs = BuildHostArgs();
- // Validate vmrun exists at startup
+ // Validate vmrun exists at startup (vmrun always runs locally,
+ // even when it targets a remote hypervisor)
if (!File.Exists(_vmrunPath))
{
throw new FileNotFoundException(
@@ -55,14 +69,65 @@ public VmwareManager(ServerConfig config, ILogger logger)
///
/// Switch the active VM target at runtime.
/// All subsequent VM and guest operations will target the new VM.
+ /// Host parameters are optional — pass null to keep the current hypervisor.
///
- public void UpdateTarget(string vmxPath, string guestUser, string guestPass, string vmPassword = "")
+ public void UpdateTarget(string vmxPath, string guestUser, string guestPass, string vmPassword = "",
+ string? hostType = null, string? hostUrl = null, string? hostUser = null, string? hostPass = null)
{
+ var newHostType = hostType == null ? _hostType
+ : string.IsNullOrWhiteSpace(hostType) ? "ws" : hostType.Trim();
+ var newHostUrl = hostUrl ?? _hostUrl;
+ var newHostUser = hostUser ?? _hostUser;
+ var newHostPass = hostPass ?? _hostPass;
+
+ // Validate before mutating so a bad host config leaves the old target intact
+ var newHostArgs = BuildHostArgs(newHostType, newHostUrl, newHostUser, newHostPass);
+
_vmxPath = vmxPath;
_guestUser = guestUser;
_guestPass = guestPass;
_vmPassword = vmPassword;
- _logger.LogInformation("VM target updated: {VmxPath} (user: {User})", vmxPath, guestUser);
+ _hostType = newHostType;
+ _hostUrl = newHostUrl;
+ _hostUser = newHostUser;
+ _hostPass = newHostPass;
+ _hostArgs = newHostArgs;
+ _logger.LogInformation("VM target updated: {VmxPath} (user: {User}, host: {HostType} {HostUrl})",
+ vmxPath, guestUser, _hostType, string.IsNullOrEmpty(_hostUrl) ? "local" : _hostUrl);
+ }
+
+ private string BuildHostArgs() => BuildHostArgs(_hostType, _hostUrl, _hostUser, _hostPass);
+
+ ///
+ /// Common vmrun authentication prefix: host type plus remote hypervisor
+ /// URL/credentials when configured.
+ ///
+ private static string BuildHostArgs(string hostType, string hostUrl, string hostUser, string hostPass)
+ {
+ var requiresUrl = hostType.Equals("esx", StringComparison.OrdinalIgnoreCase)
+ || hostType.Equals("vc", StringComparison.OrdinalIgnoreCase)
+ || hostType.Equals("ws-shared", StringComparison.OrdinalIgnoreCase);
+
+ if (string.IsNullOrEmpty(hostUrl))
+ {
+ if (requiresUrl)
+ throw new InvalidOperationException(
+ $"Vm.HostType is '{hostType}' but Vm.HostUrl is empty. " +
+ "Remote host types require a URL like https://esxi-host/sdk.");
+ return $"-T {hostType}";
+ }
+
+ if (!requiresUrl)
+ throw new InvalidOperationException(
+ $"Vm.HostUrl is set but Vm.HostType is '{hostType}', which is a local host type. " +
+ "Use HostType esx (ESXi), vc (vCenter), or ws-shared for remote hypervisors.");
+
+ if (string.IsNullOrEmpty(hostUser))
+ throw new InvalidOperationException(
+ "Vm.HostUrl is set but Vm.HostUsername is empty. " +
+ "Remote hypervisor connections require host credentials.");
+
+ return $"-T {hostType} -h \"{hostUrl}\" -u \"{hostUser}\" -p \"{hostPass}\"";
}
// ═══════════════════════════════════════════════════════════════
@@ -71,9 +136,12 @@ public void UpdateTarget(string vmxPath, string guestUser, string guestPass, str
public async Task StartAsync(bool headless = true, CancellationToken ct = default)
{
- var guiArg = headless ? "nogui" : "gui";
+ // ESXi/vCenter reject the gui/nogui argument — there is no host GUI
+ var isServerHost = _hostType.Equals("esx", StringComparison.OrdinalIgnoreCase)
+ || _hostType.Equals("vc", StringComparison.OrdinalIgnoreCase);
+ var guiArg = isServerHost ? "" : (headless ? "nogui" : "gui");
var result = await RunVmrunAsync(
- $"-T ws start \"{_vmxPath}\" {guiArg}",
+ $"{_hostArgs} start \"{_vmxPath}\" {guiArg}",
TimeSpan.FromSeconds(_timeouts.VmStartSeconds), ct);
if (result.Success)
@@ -88,7 +156,7 @@ public async Task StopAsync(bool hard = false, CancellationToken ct =
{
var mode = hard ? "hard" : "soft";
var result = await RunVmrunAsync(
- $"-T ws stop \"{_vmxPath}\" {mode}",
+ $"{_hostArgs} stop \"{_vmxPath}\" {mode}",
TimeSpan.FromSeconds(_timeouts.VmStopSeconds), ct);
if (result.Success)
@@ -102,7 +170,7 @@ public async Task StopAsync(bool hard = false, CancellationToken ct =
public async Task PauseAsync(CancellationToken ct = default)
{
var result = await RunVmrunAsync(
- $"-T ws pause \"{_vmxPath}\"",
+ $"{_hostArgs} pause \"{_vmxPath}\"",
TimeSpan.FromSeconds(_timeouts.VmPauseResumeSeconds), ct);
if (result.Success)
@@ -114,7 +182,7 @@ public async Task PauseAsync(CancellationToken ct = default)
public async Task UnpauseAsync(CancellationToken ct = default)
{
var result = await RunVmrunAsync(
- $"-T ws unpause \"{_vmxPath}\"",
+ $"{_hostArgs} unpause \"{_vmxPath}\"",
TimeSpan.FromSeconds(_timeouts.VmPauseResumeSeconds), ct);
if (result.Success)
@@ -127,7 +195,7 @@ public async Task ResetAsync(bool hard = false, CancellationToken ct =
{
var mode = hard ? "hard" : "soft";
var result = await RunVmrunAsync(
- $"-T ws reset \"{_vmxPath}\" {mode}",
+ $"{_hostArgs} reset \"{_vmxPath}\" {mode}",
TimeSpan.FromSeconds(_timeouts.VmStartSeconds), ct);
if (result.Success)
@@ -143,7 +211,7 @@ public async Task ResetAsync(bool hard = false, CancellationToken ct =
public async Task SnapshotCreateAsync(string name, CancellationToken ct = default)
{
var result = await RunVmrunAsync(
- $"-T ws snapshot \"{_vmxPath}\" \"{name}\"",
+ $"{_hostArgs} snapshot \"{_vmxPath}\" \"{name}\"",
TimeSpan.FromSeconds(_timeouts.VmSnapshotCreateSeconds), ct);
if (result.Success)
@@ -155,7 +223,7 @@ public async Task SnapshotCreateAsync(string name, CancellationToken c
public async Task SnapshotRestoreAsync(string name, CancellationToken ct = default)
{
var result = await RunVmrunAsync(
- $"-T ws revertToSnapshot \"{_vmxPath}\" \"{name}\"",
+ $"{_hostArgs} revertToSnapshot \"{_vmxPath}\" \"{name}\"",
TimeSpan.FromSeconds(_timeouts.VmSnapshotRestoreSeconds), ct);
if (result.Success)
@@ -188,7 +256,7 @@ public async Task SnapshotDeleteAsync(string name, CancellationToken c
}
var result = await RunVmrunAsync(
- $"-T ws deleteSnapshot \"{_vmxPath}\" \"{name}\"",
+ $"{_hostArgs} deleteSnapshot \"{_vmxPath}\" \"{name}\"",
TimeSpan.FromSeconds(_timeouts.VmSnapshotRestoreSeconds), ct);
if (result.Success)
@@ -200,7 +268,7 @@ public async Task SnapshotDeleteAsync(string name, CancellationToken c
public async Task SnapshotListAsync(CancellationToken ct = default)
{
var result = await RunVmrunAsync(
- $"-T ws listSnapshots \"{_vmxPath}\"",
+ $"{_hostArgs} listSnapshots \"{_vmxPath}\"",
TimeSpan.FromSeconds(_timeouts.VmToolsCheckSeconds), ct);
if (!result.Success)
@@ -230,7 +298,7 @@ public async Task GetPowerStateAsync(CancellationToken ct = defaul
{
// vmrun list returns all running VMs
var result = await RunVmrunAsync(
- "-T ws list",
+ $"{_hostArgs} list",
TimeSpan.FromSeconds(_timeouts.VmToolsCheckSeconds), ct);
if (!result.Success)
@@ -265,7 +333,7 @@ public async Task AreToolsRunningAsync(TimeSpan? timeout = null, Cancellat
try
{
var result = await RunVmrunAsync(
- $"-T ws checkToolsState \"{_vmxPath}\"",
+ $"{_hostArgs} checkToolsState \"{_vmxPath}\"",
timeout.Value, ct);
return result.Stdout.Trim().Equals("running", StringComparison.OrdinalIgnoreCase);
}
@@ -280,7 +348,7 @@ public async Task AreToolsRunningAsync(TimeSpan? timeout = null, Cancellat
try
{
var result = await RunVmrunAsync(
- $"-T ws getGuestIPAddress \"{_vmxPath}\"",
+ $"{_hostArgs} getGuestIPAddress \"{_vmxPath}\"",
TimeSpan.FromSeconds(_timeouts.VmGetIpSeconds), ct);
if (result.Success)
@@ -307,7 +375,7 @@ public async Task CaptureScreenAsync(string outputPath, CancellationTo
Directory.CreateDirectory(dir);
var result = await RunVmrunAsync(
- $"-T ws -gu \"{_guestUser}\" -gp \"{_guestPass}\" captureScreen \"{_vmxPath}\" \"{outputPath}\"",
+ $"{_hostArgs} -gu \"{_guestUser}\" -gp \"{_guestPass}\" captureScreen \"{_vmxPath}\" \"{outputPath}\"",
TimeSpan.FromSeconds(_timeouts.VmScreenshotSeconds), ct);
if (result.Success)
@@ -327,7 +395,7 @@ public async Task RunProgramInGuestAsync(
timeout ??= TimeSpan.FromSeconds(_timeouts.GuestCommandSeconds);
var interactiveArg = interactive ? "-interactive " : "";
return await RunVmrunAsync(
- $"-T ws -gu \"{_guestUser}\" -gp \"{_guestPass}\" " +
+ $"{_hostArgs} -gu \"{_guestUser}\" -gp \"{_guestPass}\" " +
$"runProgramInGuest \"{_vmxPath}\" {interactiveArg}\"{program}\" {arguments}",
timeout.Value, ct);
}
@@ -338,7 +406,7 @@ public async Task RunScriptInGuestAsync(
{
timeout ??= TimeSpan.FromSeconds(_timeouts.GuestCommandSeconds);
return await RunVmrunAsync(
- $"-T ws -gu \"{_guestUser}\" -gp \"{_guestPass}\" " +
+ $"{_hostArgs} -gu \"{_guestUser}\" -gp \"{_guestPass}\" " +
$"runScriptInGuest \"{_vmxPath}\" \"{interpreter}\" \"{scriptText}\"",
timeout.Value, ct);
}
@@ -347,7 +415,7 @@ public async Task CopyFileToGuestAsync(
string hostPath, string guestPath, CancellationToken ct = default)
{
return await RunVmrunAsync(
- $"-T ws -gu \"{_guestUser}\" -gp \"{_guestPass}\" " +
+ $"{_hostArgs} -gu \"{_guestUser}\" -gp \"{_guestPass}\" " +
$"copyFileFromHostToGuest \"{_vmxPath}\" \"{hostPath}\" \"{guestPath}\"",
TimeSpan.FromSeconds(_timeouts.GuestFileTransferSeconds), ct);
}
@@ -356,7 +424,7 @@ public async Task CopyFileFromGuestAsync(
string guestPath, string hostPath, CancellationToken ct = default)
{
return await RunVmrunAsync(
- $"-T ws -gu \"{_guestUser}\" -gp \"{_guestPass}\" " +
+ $"{_hostArgs} -gu \"{_guestUser}\" -gp \"{_guestPass}\" " +
$"copyFileFromGuestToHost \"{_vmxPath}\" \"{guestPath}\" \"{hostPath}\"",
TimeSpan.FromSeconds(_timeouts.GuestFileTransferSeconds), ct);
}
@@ -364,7 +432,7 @@ public async Task CopyFileFromGuestAsync(
public async Task ListProcessesInGuestAsync(CancellationToken ct = default)
{
return await RunVmrunAsync(
- $"-T ws -gu \"{_guestUser}\" -gp \"{_guestPass}\" " +
+ $"{_hostArgs} -gu \"{_guestUser}\" -gp \"{_guestPass}\" " +
$"listProcessesInGuest \"{_vmxPath}\"",
TimeSpan.FromSeconds(_timeouts.GuestListProcessesSeconds), ct);
}
@@ -372,7 +440,7 @@ public async Task ListProcessesInGuestAsync(CancellationToken ct
public async Task KillProcessInGuestAsync(uint pid, CancellationToken ct = default)
{
return await RunVmrunAsync(
- $"-T ws -gu \"{_guestUser}\" -gp \"{_guestPass}\" " +
+ $"{_hostArgs} -gu \"{_guestUser}\" -gp \"{_guestPass}\" " +
$"killProcessInGuest \"{_vmxPath}\" {pid}",
TimeSpan.FromSeconds(_timeouts.GuestKillProcessSeconds), ct);
}
@@ -380,7 +448,7 @@ public async Task KillProcessInGuestAsync(uint pid, CancellationT
public async Task FileExistsInGuestAsync(string guestPath, CancellationToken ct = default)
{
return await RunVmrunAsync(
- $"-T ws -gu \"{_guestUser}\" -gp \"{_guestPass}\" " +
+ $"{_hostArgs} -gu \"{_guestUser}\" -gp \"{_guestPass}\" " +
$"fileExistsInGuest \"{_vmxPath}\" \"{guestPath}\"",
TimeSpan.FromSeconds(_timeouts.VmToolsCheckSeconds), ct);
}
@@ -388,7 +456,7 @@ public async Task FileExistsInGuestAsync(string guestPath, Cancel
public async Task CreateDirectoryInGuestAsync(string guestPath, CancellationToken ct = default)
{
return await RunVmrunAsync(
- $"-T ws -gu \"{_guestUser}\" -gp \"{_guestPass}\" " +
+ $"{_hostArgs} -gu \"{_guestUser}\" -gp \"{_guestPass}\" " +
$"createDirectoryInGuest \"{_vmxPath}\" \"{guestPath}\"",
TimeSpan.FromSeconds(_timeouts.VmToolsCheckSeconds), ct);
}
@@ -425,6 +493,13 @@ internal async Task RunVmrunAsync(
}
}
+ ///
+ /// Mask the values of password flags (-p, -gp, -vp) so credentials
+ /// never land in logs. The real args still go to the vmrun process.
+ ///
+ private static string RedactPasswords(string args) =>
+ Regex.Replace(args, "(-(?:p|gp|vp)) \"[^\"]*\"", "$1 \"***\"");
+
private async Task RunVmrunCoreAsync(
string args, TimeSpan timeout, CancellationToken ct)
{
@@ -435,7 +510,7 @@ private async Task RunVmrunCoreAsync(
if (!string.IsNullOrEmpty(_vmPassword))
args = $"-vp \"{_vmPassword}\" {args}";
- _logger.LogDebug("vmrun {Args}", args);
+ _logger.LogDebug("vmrun {Args}", RedactPasswords(args));
var psi = new ProcessStartInfo
{
diff --git a/src/WinDbgMCP.Server/appsettings.example.json b/src/WinDbgMCP.Server/appsettings.example.json
index 1b2b5b3..a585a97 100644
--- a/src/WinDbgMCP.Server/appsettings.example.json
+++ b/src/WinDbgMCP.Server/appsettings.example.json
@@ -2,6 +2,10 @@
"Vm": {
"VmxPath": "C:\\path\\to\\your\\vm.vmx",
"VmrunPath": "C:\\Program Files (x86)\\VMware\\VMware Workstation\\vmrun.exe",
+ "HostType": "ws",
+ "HostUrl": "",
+ "HostUsername": "",
+ "HostPassword": "",
"VmPassword": "",
"GuestUsername": "YourUser",
"GuestPassword": "YourPass",
diff --git a/src/WinDbgMCP.Tests/StateCoordinatorTests.cs b/src/WinDbgMCP.Tests/StateCoordinatorTests.cs
index d26b149..a5fafc7 100644
--- a/src/WinDbgMCP.Tests/StateCoordinatorTests.cs
+++ b/src/WinDbgMCP.Tests/StateCoordinatorTests.cs
@@ -470,12 +470,13 @@ public async Task UmdFrida_RequiresFridaAttached()
}
[Fact]
- public async Task UmdFrida_FailsWhenNotAttached()
+ public async Task UmdFrida_AllowedWithoutAttach()
{
+ // umd_frida only requires guest ops, not an attached session:
+ // action="list" is documented to work without attaching, and each
+ // eval/inject spawns a fresh frida process anyway.
SetVmRunning();
- var result = await _coordinator.ValidatePreconditionsAsync("umd_frida");
- Assert.NotNull(result);
- Assert.Contains("umd_frida_attach", result!.Message);
+ Assert.Null(await _coordinator.ValidatePreconditionsAsync("umd_frida"));
}
[Fact]
diff --git a/src/WinDbgMCP.Tests/VmwareManagerHostConfigTests.cs b/src/WinDbgMCP.Tests/VmwareManagerHostConfigTests.cs
new file mode 100644
index 0000000..d54a8db
--- /dev/null
+++ b/src/WinDbgMCP.Tests/VmwareManagerHostConfigTests.cs
@@ -0,0 +1,141 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using WinDbgMCP.Server.Configuration;
+using WinDbgMCP.Server.Vmware;
+
+namespace WinDbgMCP.Tests;
+
+///
+/// Host/remote-hypervisor configuration behavior of VmwareManager.
+/// Uses the test assembly itself as a stand-in vmrun path so the
+/// constructor's File.Exists check passes without VMware installed.
+///
+public class VmwareManagerHostConfigTests
+{
+ private static readonly string FakeVmrunPath =
+ typeof(VmwareManagerHostConfigTests).Assembly.Location;
+
+ private static ServerConfig MakeConfig(
+ string hostType = "ws", string hostUrl = "",
+ string hostUser = "", string hostPass = "")
+ {
+ return new ServerConfig
+ {
+ Vm = new VmConfig
+ {
+ VmrunPath = FakeVmrunPath,
+ VmxPath = @"C:\vms\test\test.vmx",
+ GuestUsername = "guest",
+ GuestPassword = "pass",
+ HostType = hostType,
+ HostUrl = hostUrl,
+ HostUsername = hostUser,
+ HostPassword = hostPass
+ }
+ };
+ }
+
+ private static VmwareManager Make(ServerConfig config) =>
+ new(config, NullLogger.Instance);
+
+ [Fact]
+ public void DefaultConfig_IsLocalWorkstation()
+ {
+ var vmware = Make(MakeConfig());
+ Assert.Equal("ws", vmware.HostType);
+ Assert.False(vmware.IsRemoteHost);
+ }
+
+ [Fact]
+ public void EmptyHostType_DefaultsToWs()
+ {
+ var vmware = Make(MakeConfig(hostType: ""));
+ Assert.Equal("ws", vmware.HostType);
+ }
+
+ [Fact]
+ public void RemoteHostType_WithoutUrl_Throws()
+ {
+ var ex = Assert.Throws(
+ () => Make(MakeConfig(hostType: "esx")));
+ Assert.Contains("HostUrl", ex.Message);
+ }
+
+ [Fact]
+ public void HostUrl_WithoutUsername_Throws()
+ {
+ var ex = Assert.Throws(
+ () => Make(MakeConfig(hostType: "esx", hostUrl: "https://esxi/sdk")));
+ Assert.Contains("HostUsername", ex.Message);
+ }
+
+ [Fact]
+ public void HostUrl_WithLocalHostType_Throws()
+ {
+ var ex = Assert.Throws(
+ () => Make(MakeConfig(hostType: "ws", hostUrl: "https://esxi/sdk", hostUser: "root")));
+ Assert.Contains("local host type", ex.Message);
+ }
+
+ [Fact]
+ public void ValidRemoteConfig_IsRemoteHost()
+ {
+ var vmware = Make(MakeConfig(
+ hostType: "esx", hostUrl: "https://esxi/sdk", hostUser: "root", hostPass: "secret"));
+ Assert.True(vmware.IsRemoteHost);
+ Assert.Equal("esx", vmware.HostType);
+ Assert.Equal("https://esxi/sdk", vmware.HostUrl);
+ }
+
+ [Fact]
+ public void UpdateTarget_WithoutHostParams_KeepsCurrentHost()
+ {
+ var vmware = Make(MakeConfig(
+ hostType: "esx", hostUrl: "https://esxi/sdk", hostUser: "root"));
+
+ vmware.UpdateTarget("[ds1] other/other.vmx", "user2", "pass2");
+
+ Assert.Equal("[ds1] other/other.vmx", vmware.VmxPath);
+ Assert.Equal("esx", vmware.HostType);
+ Assert.Equal("https://esxi/sdk", vmware.HostUrl);
+ }
+
+ [Fact]
+ public void UpdateTarget_CanSwitchLocalToRemote()
+ {
+ var vmware = Make(MakeConfig());
+
+ vmware.UpdateTarget("[ds1] vm/vm.vmx", "user", "pass",
+ hostType: "esx", hostUrl: "https://esxi/sdk", hostUser: "root", hostPass: "secret");
+
+ Assert.True(vmware.IsRemoteHost);
+ Assert.Equal("esx", vmware.HostType);
+ }
+
+ [Fact]
+ public void UpdateTarget_CanSwitchRemoteBackToLocal()
+ {
+ var vmware = Make(MakeConfig(
+ hostType: "esx", hostUrl: "https://esxi/sdk", hostUser: "root"));
+
+ vmware.UpdateTarget(@"C:\vms\local\local.vmx", "user", "pass",
+ hostType: "ws", hostUrl: "");
+
+ Assert.False(vmware.IsRemoteHost);
+ Assert.Equal("ws", vmware.HostType);
+ }
+
+ [Fact]
+ public void UpdateTarget_InvalidHostConfig_ThrowsAndKeepsOldTarget()
+ {
+ var vmware = Make(MakeConfig());
+ var originalVmx = vmware.VmxPath;
+
+ Assert.Throws(() =>
+ vmware.UpdateTarget("[ds1] vm/vm.vmx", "user", "pass",
+ hostType: "esx")); // remote type but no URL
+
+ Assert.Equal(originalVmx, vmware.VmxPath);
+ Assert.Equal("ws", vmware.HostType);
+ Assert.False(vmware.IsRemoteHost);
+ }
+}