From a27a1f1038b1a2326a0fa8ca535c501d5c858c7a Mon Sep 17 00:00:00 2001 From: memoryforensics1 Date: Sun, 5 Jul 2026 09:23:43 +0300 Subject: [PATCH] Add remote hypervisor support (ESXi/vCenter/ws-shared) VM and guest operations can now target a VM on a remote hypervisor instead of only the local VMware Workstation: - New Vm config fields: HostType, HostUrl, HostUsername, HostPassword - VmwareManager builds the vmrun auth prefix (-T/-h/-u/-p) from config instead of hardcoding "-T ws"; validates host config fail-fast and keeps the old target intact if a runtime switch is invalid - vm_set_target accepts optional host parameters to switch between local and remote hypervisors at runtime - ESXi/vCenter: omit the gui/nogui start argument they reject - Redact -p/-gp/-vp password values in vmrun debug logging - Fix stale test: umd_frida requires guest ops, not an attached Frida session (action="list" is documented to work without attaching) - Add VmwareManagerHostConfigTests covering host config validation and runtime target switching Co-Authored-By: Claude Fable 5 --- README.md | 8 +- .../Configuration/ServerConfig.cs | 21 +++ src/WinDbgMCP.Server/Tools/VmTools.cs | 18 ++- src/WinDbgMCP.Server/Vmware/VmwareManager.cs | 127 ++++++++++++---- src/WinDbgMCP.Server/appsettings.example.json | 4 + src/WinDbgMCP.Tests/StateCoordinatorTests.cs | 9 +- .../VmwareManagerHostConfigTests.cs | 141 ++++++++++++++++++ 7 files changed, 295 insertions(+), 33 deletions(-) create mode 100644 src/WinDbgMCP.Tests/VmwareManagerHostConfigTests.cs 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); + } +}