From c715fc401560cfe6e6155c4109d5f77256a499da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 21:08:57 +0000 Subject: [PATCH 001/158] feat(cli): nest service commands under 'service secure-desktop' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue described the secure-desktop host service as one possible privileged feature; nest its commands under `service secure-desktop` instead of flat `service install` so the name describes what the service is for and leaves the `service` namespace open for future helpers: uv run windows-mcp service secure-desktop install uv run windows-mcp service secure-desktop uninstall uv run windows-mcp service secure-desktop start / stop / status Updates the followup hint in `install` and the section header to match. No behaviour changes — only the CLI surface. --- src/windows_mcp/__main__.py | 76 +++++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/src/windows_mcp/__main__.py b/src/windows_mcp/__main__.py index 061d59a3..3012f1e5 100755 --- a/src/windows_mcp/__main__.py +++ b/src/windows_mcp/__main__.py @@ -746,7 +746,7 @@ def auth(transport: str, host: str, port: int, with_tls: bool, force: bool) -> N # --------------------------------------------------------------------------- -# `windows-mcp service` command group +# `windows-mcp service secure-desktop` command group # --------------------------------------------------------------------------- _SERVICE_NAME = "WindowsMCPHost" @@ -798,25 +798,43 @@ def _sc_state_name(state: int) -> str: @main.group() def service(): - """Manage the Windows MCP host service (LocalSystem, required for UAC access). + """Manage Windows MCP optional privileged services. + + Privileged services run as NT AUTHORITY\\SYSTEM and expose a local named + pipe to the user-mode broker. They are opt-in because they require + elevation to install. + + Sub-groups: + + secure-desktop Host service that lets the agent see and click UAC + consent prompts (Secure Desktop / Winlogon). + """ + + +@service.group("secure-desktop") +def service_secure_desktop(): + """Manage the Secure Desktop host service (handles UAC consent prompts). The host service runs as NT AUTHORITY\\SYSTEM and exposes a local named pipe - so the MCP broker can capture screenshots even while a UAC prompt is on screen. + so the MCP broker can capture screenshots even while a UAC prompt is on + screen. + + Installing the service also disables the "Switch to secure desktop" UAC + policy (PromptOnSecureDesktop=0) so that UAC prompts appear on the normal + Default desktop. This allows user-mode UIA and SendInput to reach the + Yes/No buttons directly, without needing cross-session tricks. The policy + is restored when the service is uninstalled. - Installing the service also disables the "Switch to secure desktop" UAC policy - (PromptOnSecureDesktop=0) so that UAC prompts appear on the normal Default - desktop. This allows user-mode UIA and SendInput to reach the Yes/No buttons - directly, without needing cross-session tricks. The policy is restored when - the service is uninstalled. + Must be installed once from an elevated (Administrator) prompt: - Must be installed once from an elevated (Administrator) prompt. + uv run windows-mcp service secure-desktop install """ -@service.command("install") +@service_secure_desktop.command("install") @click.option("--force", is_flag=True, help="Uninstall then reinstall if already present.") -def service_install(force: bool): - """Install and start the Windows MCP host service (requires elevation).""" +def service_secure_desktop_install(force: bool): + """Install and start the Secure Desktop host service (requires elevation).""" _require_win32() import win32serviceutil import win32service @@ -853,11 +871,6 @@ def service_install(force: bool): # against the system Python and cannot import windows_mcp, causing 1053. # Using sys.executable guarantees the exact interpreter that has the # package is what the SCM launches. - # - # Binary path format: "" -m windows_mcp.service.host - # When the SCM starts this with no extra args, host.py calls - # servicemanager.StartServiceCtrlDispatcher() to enter the service loop. - from windows_mcp.service.host import WindowsMCPHostService binary_path = f'"{sys.executable}" -m windows_mcp.service.host' hscm = None @@ -916,15 +929,14 @@ def service_install(force: bool): click.echo("\nThe host service is now running as NT AUTHORITY\\SYSTEM.") click.echo("It will restart automatically at each boot.") - click.echo("Run `windows-mcp service uninstall` to remove it.") + click.echo("Run `windows-mcp service secure-desktop uninstall` to remove it.") -@service.command("uninstall") -def service_uninstall(): - """Stop and remove the Windows MCP host service (requires elevation).""" +@service_secure_desktop.command("uninstall") +def service_secure_desktop_uninstall(): + """Stop and remove the Secure Desktop host service (requires elevation).""" _require_win32() import win32serviceutil - import win32service import pywintypes try: @@ -947,9 +959,9 @@ def service_uninstall(): click.echo(f"Warning: could not restore UAC policy: {exc}") -@service.command("start") -def service_start(): - """Start the Windows MCP host service.""" +@service_secure_desktop.command("start") +def service_secure_desktop_start(): + """Start the Secure Desktop host service.""" _require_win32() import win32serviceutil try: @@ -959,9 +971,9 @@ def service_start(): raise click.ClickException(f"Failed to start service: {exc}") -@service.command("stop") -def service_stop(): - """Stop the Windows MCP host service.""" +@service_secure_desktop.command("stop") +def service_secure_desktop_stop(): + """Stop the Secure Desktop host service.""" _require_win32() import win32serviceutil try: @@ -971,9 +983,9 @@ def service_stop(): raise click.ClickException(f"Failed to stop service: {exc}") -@service.command("status") -def service_status(): - """Show the current status of the Windows MCP host service.""" +@service_secure_desktop.command("status") +def service_secure_desktop_status(): + """Show the current status of the Secure Desktop host service.""" _require_win32() import win32serviceutil import win32service @@ -993,7 +1005,7 @@ def service_status(): client.invalidate_cache() if client.is_available(): desktop = client.desktop_name() - click.echo(f"Pipe : reachable") + click.echo("Pipe : reachable") click.echo(f"Desktop : {desktop}") else: click.echo("Pipe : not reachable (service may still be starting)") From fc4a3d6f11375ee8918b6501ca10124805eb5458 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 21:12:39 +0000 Subject: [PATCH 002/158] docs: update remaining 'service install' references to 'service secure-desktop install' Two source-level docstrings still referenced the old flat path. --- src/windows_mcp/desktop/service.py | 2 +- src/windows_mcp/service/host.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/windows_mcp/desktop/service.py b/src/windows_mcp/desktop/service.py index 8fd422df..e729b612 100755 --- a/src/windows_mcp/desktop/service.py +++ b/src/windows_mcp/desktop/service.py @@ -647,7 +647,7 @@ def click(self, loc: tuple[int, int] | list[int], button: str = "left", clicks: else: x, y = loc - # With PromptOnSecureDesktop disabled (set by `windows-mcp service install`), + # With PromptOnSecureDesktop disabled (set by `windows-mcp service secure-desktop install`), # UAC prompts appear on the Default desktop and uia.Click() reaches them via # SendInput — hardware-level input that bypasses UIPI. # The service route below is a fallback for the rare case where the policy diff --git a/src/windows_mcp/service/host.py b/src/windows_mcp/service/host.py index 34e00427..c2d708b3 100644 --- a/src/windows_mcp/service/host.py +++ b/src/windows_mcp/service/host.py @@ -3,7 +3,7 @@ This module serves two purposes: 1. **Service class** (``WindowsMCPHostService``) — a ``pywin32`` - ``ServiceFramework`` subclass installed via ``windows-mcp service install``. + ``ServiceFramework`` subclass installed via ``windows-mcp service secure-desktop install``. It starts a named pipe server that handles privileged desktop operations requested by the user-mode broker. From 5a1faadf6450010fd2698c55174e63a166bf9cc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 21:26:49 +0000 Subject: [PATCH 003/158] fix: import Center in desktop/service.py for UAC tree builder `_build_uac_tree_state` references `Center` but the class was never imported, so the UAC tree path crashes with NameError as soon as the service-routed UAC handling is triggered. This was introduced when `_build_uac_tree_state` was added but the import was missed. --- src/windows_mcp/desktop/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/windows_mcp/desktop/service.py b/src/windows_mcp/desktop/service.py index e729b612..572e3d27 100755 --- a/src/windows_mcp/desktop/service.py +++ b/src/windows_mcp/desktop/service.py @@ -10,7 +10,7 @@ is_window_on_current_desktop, ) from windows_mcp.desktop.views import DesktopState, Window, Browser, Status, Size -from windows_mcp.tree.views import BoundingBox, TreeElementNode, TreeState +from windows_mcp.tree.views import BoundingBox, Center, TreeElementNode, TreeState from concurrent.futures import ThreadPoolExecutor from PIL import ImageFont, ImageDraw, Image from windows_mcp.tree.service import Tree From 8a546c98b2574de72f26d13138d2e079c27299a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 22:09:14 +0000 Subject: [PATCH 004/158] feat(service): drop PromptOnSecureDesktop=0 install side-effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous attempt landed on disabling the "Switch to secure desktop" policy (PromptOnSecureDesktop=0) so UAC dialogs would render on the Default desktop where user-mode UIA could reach them. The issue body calls this out explicitly: "Disable UAC entirely — bad security posture, breaks anything that detects UAC level (Edge, Defender, MDM tools)." It is also a confession that the cross-desktop service path didn't work in practice. Removing the workaround forces us to make the proper path (Service running as LocalSystem, SetThreadDesktop to follow the input desktop, UIA against Winlogon) actually work. This commit only removes the registry mutation. Subsequent commits add the policy env var, the WaitForUACPrompt MCP tool, Type/Drag routing through the service, the pipe SID DACL, and the %ProgramFiles% install location that make the proper path safe to ship. --- src/windows_mcp/__main__.py | 52 ++++++------------------------ src/windows_mcp/desktop/service.py | 10 +++--- 2 files changed, 14 insertions(+), 48 deletions(-) diff --git a/src/windows_mcp/__main__.py b/src/windows_mcp/__main__.py index 3012f1e5..c30836f6 100755 --- a/src/windows_mcp/__main__.py +++ b/src/windows_mcp/__main__.py @@ -763,26 +763,6 @@ def _require_win32(): ) -_UAC_POLICY_KEY = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" -_UAC_POLICY_VALUE = "PromptOnSecureDesktop" - - -def _set_prompt_on_secure_desktop(enabled: bool) -> None: - """Set or clear the 'Switch to secure desktop' UAC policy. - - When disabled (enabled=False) UAC prompts appear on the normal Default - desktop where user-mode UIA and SendInput can reach them, instead of the - isolated Winlogon desktop. Requires elevation. - """ - import winreg - with winreg.OpenKey( - winreg.HKEY_LOCAL_MACHINE, - _UAC_POLICY_KEY, - access=winreg.KEY_SET_VALUE, - ) as key: - winreg.SetValueEx(key, _UAC_POLICY_VALUE, 0, winreg.REG_DWORD, 1 if enabled else 0) - - def _sc_state_name(state: int) -> str: import win32service return { @@ -816,14 +796,12 @@ def service_secure_desktop(): """Manage the Secure Desktop host service (handles UAC consent prompts). The host service runs as NT AUTHORITY\\SYSTEM and exposes a local named pipe - so the MCP broker can capture screenshots even while a UAC prompt is on - screen. + so the MCP broker can capture screenshots and route input across the + Winlogon (Secure Desktop) boundary that fires during UAC consent prompts. - Installing the service also disables the "Switch to secure desktop" UAC - policy (PromptOnSecureDesktop=0) so that UAC prompts appear on the normal - Default desktop. This allows user-mode UIA and SendInput to reach the - Yes/No buttons directly, without needing cross-session tricks. The policy - is restored when the service is uninstalled. + UAC remains fully enabled — the service does NOT weaken the Secure Desktop + policy. Whether the broker may auto-click a UAC prompt is governed by the + ``WINDOWS_MCP_SECURE_DESKTOP_POLICY`` env var (``block`` by default). Must be installed once from an elevated (Administrator) prompt: @@ -918,17 +896,12 @@ def service_secure_desktop_install(force: bool): else: raise click.ClickException(f"Failed to start service: {exc}") - # Disable "Switch to secure desktop" so UAC prompts appear on the normal - # Default desktop, where user-mode UIA and SendInput can reach them. - try: - _set_prompt_on_secure_desktop(False) - click.echo("UAC policy : PromptOnSecureDesktop disabled (UAC on Default desktop).") - except Exception as exc: - click.echo(f"Warning: could not update UAC policy: {exc}") - click.echo(" UAC dialogs may not be accessible to the agent.") - click.echo("\nThe host service is now running as NT AUTHORITY\\SYSTEM.") click.echo("It will restart automatically at each boot.") + click.echo( + "UAC consent policy : WINDOWS_MCP_SECURE_DESKTOP_POLICY=" + f"{os.environ.get('WINDOWS_MCP_SECURE_DESKTOP_POLICY', 'block')} (default: block)" + ) click.echo("Run `windows-mcp service secure-desktop uninstall` to remove it.") @@ -951,13 +924,6 @@ def service_secure_desktop_uninstall(): except pywintypes.error as exc: raise click.ClickException(f"Failed to remove service: {exc}") - # Restore the secure desktop UAC policy. - try: - _set_prompt_on_secure_desktop(True) - click.echo("UAC policy : PromptOnSecureDesktop restored.") - except Exception as exc: - click.echo(f"Warning: could not restore UAC policy: {exc}") - @service_secure_desktop.command("start") def service_secure_desktop_start(): diff --git a/src/windows_mcp/desktop/service.py b/src/windows_mcp/desktop/service.py index 572e3d27..700eb160 100755 --- a/src/windows_mcp/desktop/service.py +++ b/src/windows_mcp/desktop/service.py @@ -647,11 +647,11 @@ def click(self, loc: tuple[int, int] | list[int], button: str = "left", clicks: else: x, y = loc - # With PromptOnSecureDesktop disabled (set by `windows-mcp service secure-desktop install`), - # UAC prompts appear on the Default desktop and uia.Click() reaches them via - # SendInput — hardware-level input that bypasses UIPI. - # The service route below is a fallback for the rare case where the policy - # wasn't applied (secure desktop still active). + # UAC fires on the Winlogon (Secure Desktop) object, which the broker + # cannot reach. If the host service is installed, route the click + # through it — the service does the SetThreadDesktop dance and invokes + # the element via UIA on the input desktop. Policy enforcement (block / + # allow_with_match / allow_all) lives in the service, not here. from windows_mcp.desktop.screenshot import is_secure_desktop_active if button == "left" and is_secure_desktop_active(): from windows_mcp.service import get_host_client From ad7ce2b75ce48fbd7a4cb0d7d5012b98f937158a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 22:13:54 +0000 Subject: [PATCH 005/158] feat(service): add WINDOWS_MCP_SECURE_DESKTOP_POLICY + server-side enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Secure Desktop host service now refuses to auto-click UAC prompts unless an explicit policy permits it. Policy values (per issue #236): block (default) service shows the UAC dialog to the agent but refuses to invoke Yes/No. Human approves. allow_with_match auto-invoke only when the UAC dialog's "Verified publisher" substring-matches one of the persisted publisher allowlist entries. allow_all auto-invoke any UAC prompt. Opt-in. Only sensible inside sandboxed VMs. Implementation notes: - service/policy.py: SecureDesktopPolicy dataclass + registry persistence at HKLM\SOFTWARE\Windows-MCP\SecureDesktop. The service reads at request time so the broker cannot bypass enforcement. - service/secure_desktop.py: new uia_type_at, uia_drag_from_to, get_uac_publisher (best-effort English publisher extraction from the UAC dialog's UIA tree), wait_for_uac_prompt (blocks until the input desktop becomes Winlogon). - service/host.py: _enforce_policy() runs before any auto-input op when the input desktop is Winlogon. Read-only ops (screenshot, tree) are never gated — agents always need visibility. - service/pipe.py: client methods for the new pipe verbs. - __main__.py: `service secure-desktop install` accepts --policy and --allow-publisher; precedence is CLI > env > config.toml > "block". New `service secure-desktop set-policy` command changes policy without reinstalling. Uninstall now also clears the registry key. - infrastructure/config.py: SecureDesktopConfig section in TOML. --- src/windows_mcp/__main__.py | 87 ++++++++++- src/windows_mcp/infrastructure/__init__.py | 4 + src/windows_mcp/infrastructure/config.py | 45 ++++++ src/windows_mcp/service/host.py | 65 +++++++- src/windows_mcp/service/pipe.py | 24 +++ src/windows_mcp/service/policy.py | 173 +++++++++++++++++++++ src/windows_mcp/service/protocol.py | 7 + src/windows_mcp/service/secure_desktop.py | 171 +++++++++++++++++++- 8 files changed, 567 insertions(+), 9 deletions(-) create mode 100644 src/windows_mcp/service/policy.py diff --git a/src/windows_mcp/__main__.py b/src/windows_mcp/__main__.py index c30836f6..aa02caae 100755 --- a/src/windows_mcp/__main__.py +++ b/src/windows_mcp/__main__.py @@ -811,13 +811,45 @@ def service_secure_desktop(): @service_secure_desktop.command("install") @click.option("--force", is_flag=True, help="Uninstall then reinstall if already present.") -def service_secure_desktop_install(force: bool): +@click.option( + "--policy", + type=click.Choice(["block", "allow_with_match", "allow_all"]), + default=None, + help=( + "Persist a Secure Desktop consent policy on install. " + "If omitted, falls back to WINDOWS_MCP_SECURE_DESKTOP_POLICY, " + "then config.toml, then 'block'." + ), +) +@click.option( + "--allow-publisher", + "allow_publisher", + multiple=True, + help=( + "Publisher substring to allow under --policy=allow_with_match. " + "Repeat to add multiple. Comma-separated also works." + ), +) +def service_secure_desktop_install(force: bool, policy: str | None, allow_publisher: tuple[str, ...]): """Install and start the Secure Desktop host service (requires elevation).""" _require_win32() import win32serviceutil import win32service import pywintypes from windows_mcp.service.host import WindowsMCPHostService + from windows_mcp.service import policy as policy_mod + + # Resolve effective policy: CLI flag > env var > config.toml > default ("block"). + cfg = load_config(discover_config_path(None)) + cli_allowlist: list[str] = [] + for raw in allow_publisher: + cli_allowlist.extend(s.strip() for s in raw.split(",") if s.strip()) + effective_policy = policy_mod.resolve_install_time_policy( + cli_policy=policy, + cli_allowlist=cli_allowlist or None, + config_policy=cfg.secure_desktop.policy, + config_allowlist=cfg.secure_desktop.publishers_allowlist, + ) # Check whether the service already exists. already_installed = False @@ -896,12 +928,18 @@ def service_secure_desktop_install(force: bool): else: raise click.ClickException(f"Failed to start service: {exc}") + try: + policy_mod.write_to_registry(effective_policy) + click.echo(f"UAC consent policy : {effective_policy.policy}") + if effective_policy.publishers_allowlist: + click.echo(f" publishers allowlist: {effective_policy.publishers_allowlist}") + except Exception as exc: + click.echo(f"Warning: could not persist UAC policy: {exc}") + click.echo(" Service will refuse auto-clicks until policy is set.") + click.echo("\nThe host service is now running as NT AUTHORITY\\SYSTEM.") click.echo("It will restart automatically at each boot.") - click.echo( - "UAC consent policy : WINDOWS_MCP_SECURE_DESKTOP_POLICY=" - f"{os.environ.get('WINDOWS_MCP_SECURE_DESKTOP_POLICY', 'block')} (default: block)" - ) + click.echo("Run `windows-mcp service secure-desktop set-policy ` to change without reinstalling.") click.echo("Run `windows-mcp service secure-desktop uninstall` to remove it.") @@ -924,6 +962,45 @@ def service_secure_desktop_uninstall(): except pywintypes.error as exc: raise click.ClickException(f"Failed to remove service: {exc}") + try: + from windows_mcp.service import policy as policy_mod + policy_mod.delete_from_registry() + click.echo("UAC consent policy : cleared from registry.") + except Exception as exc: + click.echo(f"Warning: could not clear UAC policy registry key: {exc}") + + +@service_secure_desktop.command("set-policy") +@click.argument("policy_name", type=click.Choice(["block", "allow_with_match", "allow_all"])) +@click.option( + "--allow-publisher", + "allow_publisher", + multiple=True, + help="Publisher substring(s) for allow_with_match. Repeat or comma-separate.", +) +def service_secure_desktop_set_policy(policy_name: str, allow_publisher: tuple[str, ...]): + """Update the persisted Secure Desktop consent policy without reinstalling.""" + _require_win32() + from windows_mcp.service import policy as policy_mod + + allowlist: list[str] = [] + for raw in allow_publisher: + allowlist.extend(s.strip() for s in raw.split(",") if s.strip()) + new_policy = policy_mod.SecureDesktopPolicy( + policy=policy_name, publishers_allowlist=allowlist + ) + try: + policy_mod.write_to_registry(new_policy) + except PermissionError as exc: + raise click.ClickException( + f"Permission denied writing policy to HKLM: {exc}. Run as Administrator." + ) + except Exception as exc: + raise click.ClickException(f"Failed to write policy: {exc}") + click.echo(f"Policy updated → {policy_name}") + if allowlist: + click.echo(f" publishers allowlist: {allowlist}") + @service_secure_desktop.command("start") def service_secure_desktop_start(): diff --git a/src/windows_mcp/infrastructure/__init__.py b/src/windows_mcp/infrastructure/__init__.py index e8a0984d..242d3c00 100644 --- a/src/windows_mcp/infrastructure/__init__.py +++ b/src/windows_mcp/infrastructure/__init__.py @@ -12,6 +12,8 @@ ServerConfig, SecurityConfig, ToolsConfig, + SecureDesktopConfig, + SECURE_DESKTOP_POLICIES, CONFIG_DIR, CONFIG_FILE, discover_config_path, @@ -34,6 +36,8 @@ "ServerConfig", "SecurityConfig", "ToolsConfig", + "SecureDesktopConfig", + "SECURE_DESKTOP_POLICIES", "CONFIG_DIR", "CONFIG_FILE", "discover_config_path", diff --git a/src/windows_mcp/infrastructure/config.py b/src/windows_mcp/infrastructure/config.py index e4c65807..7960af3a 100644 --- a/src/windows_mcp/infrastructure/config.py +++ b/src/windows_mcp/infrastructure/config.py @@ -30,11 +30,33 @@ class ToolsConfig: exclude: list[str] = field(default_factory=list) +SECURE_DESKTOP_POLICIES = ("block", "allow_with_match", "allow_all") + + +@dataclass +class SecureDesktopConfig: + """Policy for how the LocalSystem host service may handle UAC consent prompts. + + ``policy`` values: + - ``block`` — service exposes the dialog to the agent but + REFUSES to auto-click Yes/No. Human approval + still required. (default) + - ``allow_with_match``— auto-click only if the requesting binary's + publisher (CommonName from the Authenticode + signature) matches one of ``publishers_allowlist``. + - ``allow_all`` — auto-click any UAC prompt. Only safe in sandboxed + VMs. Opt-in. + """ + policy: str = "block" + publishers_allowlist: list[str] = field(default_factory=list) + + @dataclass class WindowsMCPConfig: server: ServerConfig = field(default_factory=ServerConfig) security: SecurityConfig = field(default_factory=SecurityConfig) tools: ToolsConfig = field(default_factory=ToolsConfig) + secure_desktop: SecureDesktopConfig = field(default_factory=SecureDesktopConfig) source_path: Path | None = None @@ -117,6 +139,19 @@ def load_config(path: Path | None) -> WindowsMCPConfig: if "exclude" in tools: cfg.tools.exclude = _list_of_strings(tools["exclude"], "tools.exclude") + secure_desktop = data.get("secure_desktop", {}) + if "policy" in secure_desktop: + p = str(secure_desktop["policy"]) + if p not in SECURE_DESKTOP_POLICIES: + raise ValueError( + f"secure_desktop.policy must be one of {SECURE_DESKTOP_POLICIES}, got {p!r}" + ) + cfg.secure_desktop.policy = p + if "publishers_allowlist" in secure_desktop: + cfg.secure_desktop.publishers_allowlist = _list_of_strings( + secure_desktop["publishers_allowlist"], "secure_desktop.publishers_allowlist" + ) + cfg.source_path = path return cfg @@ -160,4 +195,14 @@ def write_config(cfg: WindowsMCPConfig, path: Path) -> None: items = ', '.join(f'"{t}"' for t in cfg.tools.exclude) lines += ['[tools]', f'exclude = [{items}]', ''] + sd_cfg, sd_def = cfg.secure_desktop, SecureDesktopConfig() + sd_lines: list[str] = [] + if sd_cfg.policy != sd_def.policy: + sd_lines.append(f'policy = "{sd_cfg.policy}"') + if sd_cfg.publishers_allowlist: + items = ', '.join(f'"{p}"' for p in sd_cfg.publishers_allowlist) + sd_lines.append(f'publishers_allowlist = [{items}]') + if sd_lines: + lines += ['[secure_desktop]'] + sd_lines + [''] + path.write_text('\n'.join(lines), encoding='utf-8') diff --git a/src/windows_mcp/service/host.py b/src/windows_mcp/service/host.py index c2d708b3..f41a318d 100644 --- a/src/windows_mcp/service/host.py +++ b/src/windows_mcp/service/host.py @@ -30,7 +30,7 @@ from typing import Any from .protocol import PIPE_NAME, PIPE_BUFFER_SIZE, Request, Response -from . import secure_desktop +from . import policy, secure_desktop logger = logging.getLogger(__name__) @@ -92,6 +92,28 @@ def _build_pipe_sa() -> Any: # Request dispatcher # --------------------------------------------------------------------------- +def _enforce_policy(operation: str) -> tuple[bool, str]: + """Return (allowed, reason) for an auto-input op on the current input desktop. + + Read-only ops (screenshot, tree walks) are not gated — agents always need + visibility into UAC. Only auto-clicks/types/drags on the Secure Desktop are + policy-gated, because those are the actions that bypass the human. + + Read-only ops on Default desktop are not gated either. We only enforce on + Winlogon because that is where consent prompts live. + """ + if secure_desktop.get_input_desktop_name().lower() != "winlogon": + return True, "input desktop is not Winlogon" + pol = policy.read_from_registry() + publisher = secure_desktop.get_uac_publisher() + allowed, reason = pol.allows_auto_click(publisher) + logger.info( + "policy check: op=%s desktop=Winlogon policy=%s publisher=%r → %s (%s)", + operation, pol.policy, publisher, allowed, reason, + ) + return allowed, reason + + def _dispatch(req: Request) -> Response: """Execute a single request and return a response.""" try: @@ -115,14 +137,55 @@ def _dispatch(req: Request) -> Response: tree = secure_desktop.uia_get_tree() return Response(id=req.id, result=tree) + case "get_uac_publisher": + pub = secure_desktop.get_uac_publisher() + return Response(id=req.id, result=pub) + + case "wait_for_uac_prompt": + timeout_ms = int(req.params.get("timeout_ms", 60_000)) + result = secure_desktop.wait_for_uac_prompt(timeout_ms=timeout_ms) + return Response(id=req.id, result=result) + + case "policy_state": + pol = policy.read_from_registry() + return Response(id=req.id, result={ + "policy": pol.policy, + "publishers_allowlist": pol.publishers_allowlist, + }) + case "uia_invoke": + allowed, reason = _enforce_policy("uia_invoke") + if not allowed: + return Response(id=req.id, error=f"policy denied: {reason}") ok = secure_desktop.uia_invoke_element(req.params["name"]) return Response(id=req.id, result=ok) case "uia_click_at": + allowed, reason = _enforce_policy("uia_click_at") + if not allowed: + return Response(id=req.id, error=f"policy denied: {reason}") ok = secure_desktop.uia_click_at(req.params["x"], req.params["y"]) return Response(id=req.id, result=ok) + case "uia_type_at": + allowed, reason = _enforce_policy("uia_type_at") + if not allowed: + return Response(id=req.id, error=f"policy denied: {reason}") + ok = secure_desktop.uia_type_at( + req.params["x"], req.params["y"], req.params["text"] + ) + return Response(id=req.id, result=ok) + + case "uia_drag_from_to": + allowed, reason = _enforce_policy("uia_drag_from_to") + if not allowed: + return Response(id=req.id, error=f"policy denied: {reason}") + ok = secure_desktop.uia_drag_from_to( + req.params["x1"], req.params["y1"], + req.params["x2"], req.params["y2"], + ) + return Response(id=req.id, result=ok) + case _: return Response(id=req.id, error=f"Unknown method: {req.method!r}") diff --git a/src/windows_mcp/service/pipe.py b/src/windows_mcp/service/pipe.py index a05e5108..b88f7b77 100644 --- a/src/windows_mcp/service/pipe.py +++ b/src/windows_mcp/service/pipe.py @@ -89,6 +89,30 @@ def uia_click_at(self, x: int, y: int) -> bool: """Invoke the element at screen coordinates (x, y) on the input desktop.""" return self._call("uia_click_at", {"x": x, "y": y}) + def uia_type_at(self, x: int, y: int, text: str) -> bool: + """Set the value of the editable element at (x, y) on the input desktop.""" + return self._call("uia_type_at", {"x": x, "y": y, "text": text}) + + def uia_drag_from_to(self, x1: int, y1: int, x2: int, y2: int) -> bool: + """Move the element at (x1, y1) onto (x2, y2) on the input desktop.""" + return self._call("uia_drag_from_to", {"x1": x1, "y1": y1, "x2": x2, "y2": y2}) + + def get_uac_publisher(self) -> str | None: + """Return the publisher string from the active UAC dialog, or None.""" + return self._call("get_uac_publisher", {}) + + def wait_for_uac_prompt(self, timeout_ms: int = 60_000) -> dict | None: + """Block until UAC fires on the input desktop, or until *timeout_ms* elapses. + + Returns a dict ``{"desktop": "Winlogon", "publisher": str|None, "tree": [...]}`` + on success, or ``None`` on timeout. + """ + return self._call("wait_for_uac_prompt", {"timeout_ms": timeout_ms}) + + def policy_state(self) -> dict: + """Return the persisted Secure-Desktop policy and allowlist.""" + return self._call("policy_state", {}) + # ------------------------------------------------------------------ # Internal # ------------------------------------------------------------------ diff --git a/src/windows_mcp/service/policy.py b/src/windows_mcp/service/policy.py new file mode 100644 index 00000000..8bd665da --- /dev/null +++ b/src/windows_mcp/service/policy.py @@ -0,0 +1,173 @@ +"""Secure-Desktop consent policy — read from HKLM, written on install/set-policy. + +Three policies (per issue #236): + +* ``block`` — service exposes the UAC dialog to the agent but + REFUSES to auto-click Yes/No. Default. +* ``allow_with_match`` — auto-click only if the requesting binary's publisher + (as it appears in the UAC dialog) substring-matches + one of ``publishers_allowlist``. +* ``allow_all`` — auto-click any UAC prompt. Only for sandboxed VMs. + +The policy is persisted in the registry so that the LocalSystem service can +read it without any inheritance from the broker's user environment. The +broker also reads it to pre-filter requests before they ever leave the user +session (defense in depth — even a tampered broker cannot bypass the SYSTEM +service's check). + +Registry layout:: + + HKLM\\SOFTWARE\\Windows-MCP\\SecureDesktop + Policy REG_SZ "block" | "allow_with_match" | "allow_all" + PublishersAllowlist REG_MULTI_SZ ["Microsoft Corporation", ...] +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + +POLICIES = ("block", "allow_with_match", "allow_all") +DEFAULT_POLICY = "block" + +ENV_POLICY = "WINDOWS_MCP_SECURE_DESKTOP_POLICY" +ENV_ALLOWLIST = "WINDOWS_MCP_SECURE_DESKTOP_ALLOWLIST" + +_REG_PATH = r"SOFTWARE\Windows-MCP\SecureDesktop" +_REG_POLICY = "Policy" +_REG_ALLOWLIST = "PublishersAllowlist" + + +@dataclass +class SecureDesktopPolicy: + policy: str = DEFAULT_POLICY + publishers_allowlist: list[str] = field(default_factory=list) + + def allows_auto_click(self, publisher: str | None) -> tuple[bool, str]: + """Return ``(allowed, reason)`` for an auto-click attempt on the Secure Desktop. + + ``publisher`` is the "Verified publisher" string from the UAC dialog + (or ``None`` if it could not be determined). + """ + if self.policy == "allow_all": + return True, "policy=allow_all" + if self.policy == "block": + return False, "policy=block" + # allow_with_match + if publisher is None: + return False, "publisher unknown; allow_with_match requires a match" + for needle in self.publishers_allowlist: + if needle and needle.lower() in publisher.lower(): + return True, f"publisher {publisher!r} matched allowlist entry {needle!r}" + return False, f"publisher {publisher!r} not in allowlist" + + +def _validate_policy(value: str) -> str: + v = value.strip().lower() + if v not in POLICIES: + raise ValueError( + f"Invalid policy {value!r}; must be one of {POLICIES}" + ) + return v + + +def from_env() -> SecureDesktopPolicy | None: + """Build a policy from environment variables, or return None if unset.""" + raw = os.environ.get(ENV_POLICY) + if not raw: + return None + policy = _validate_policy(raw) + raw_list = os.environ.get(ENV_ALLOWLIST, "") + allowlist = [s.strip() for s in raw_list.split(",") if s.strip()] + return SecureDesktopPolicy(policy=policy, publishers_allowlist=allowlist) + + +def read_from_registry() -> SecureDesktopPolicy: + """Read the persisted policy. Returns the default policy on any failure.""" + try: + import winreg + except ImportError: + return SecureDesktopPolicy() + try: + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, _REG_PATH, access=winreg.KEY_READ + ) as key: + try: + policy_raw, _ = winreg.QueryValueEx(key, _REG_POLICY) + policy = _validate_policy(str(policy_raw)) + except FileNotFoundError: + policy = DEFAULT_POLICY + try: + allowlist_raw, _ = winreg.QueryValueEx(key, _REG_ALLOWLIST) + allowlist = [s for s in (allowlist_raw or []) if s] + except FileNotFoundError: + allowlist = [] + except FileNotFoundError: + return SecureDesktopPolicy() + except OSError as exc: + logger.warning("Reading policy from registry failed: %s", exc) + return SecureDesktopPolicy() + return SecureDesktopPolicy(policy=policy, publishers_allowlist=allowlist) + + +def write_to_registry(policy: SecureDesktopPolicy) -> None: + """Persist *policy* to HKLM. Requires elevation.""" + import winreg + _validate_policy(policy.policy) + with winreg.CreateKeyEx( + winreg.HKEY_LOCAL_MACHINE, _REG_PATH, access=winreg.KEY_SET_VALUE + ) as key: + winreg.SetValueEx(key, _REG_POLICY, 0, winreg.REG_SZ, policy.policy) + winreg.SetValueEx( + key, _REG_ALLOWLIST, 0, winreg.REG_MULTI_SZ, list(policy.publishers_allowlist) + ) + logger.info( + "Wrote secure-desktop policy=%s allowlist=%s", policy.policy, policy.publishers_allowlist + ) + + +def delete_from_registry() -> None: + """Remove the persisted policy. Used on service uninstall.""" + try: + import winreg + except ImportError: + return + try: + winreg.DeleteKey(winreg.HKEY_LOCAL_MACHINE, _REG_PATH) + except FileNotFoundError: + pass + except OSError as exc: + logger.warning("Could not delete policy registry key: %s", exc) + + +def resolve_install_time_policy( + cli_policy: str | None, + cli_allowlist: list[str] | None, + config_policy: str | None, + config_allowlist: list[str] | None, +) -> SecureDesktopPolicy: + """Merge CLI flag, env var, TOML config, and default — in that precedence order.""" + env = from_env() + + if cli_policy is not None: + policy = _validate_policy(cli_policy) + elif env is not None: + policy = env.policy + elif config_policy is not None: + policy = _validate_policy(config_policy) + else: + policy = DEFAULT_POLICY + + if cli_allowlist: + allowlist = list(cli_allowlist) + elif env is not None and env.publishers_allowlist: + allowlist = env.publishers_allowlist + elif config_allowlist: + allowlist = list(config_allowlist) + else: + allowlist = [] + + return SecureDesktopPolicy(policy=policy, publishers_allowlist=allowlist) diff --git a/src/windows_mcp/service/protocol.py b/src/windows_mcp/service/protocol.py index e2046eb6..f15527c2 100644 --- a/src/windows_mcp/service/protocol.py +++ b/src/windows_mcp/service/protocol.py @@ -9,6 +9,13 @@ desktop_name → str ("Default" | "Winlogon") screenshot → base64-encoded PNG bytes (full virtual screen) uia_windows → list[str] of top-level window titles on input desktop +uia_tree → list[dict] of top-level windows serialized as UIA nodes +uia_invoke → bool — find named element and invoke it +uia_click_at → bool — invoke element at (x, y); policy-gated when desktop=Winlogon +uia_type_at → bool — SetValue on editable element at (x, y); policy-gated +uia_drag_from_to→ bool — move element from (x1, y1) to (x2, y2); policy-gated +get_uac_publisher → str | None — extract publisher string from active UAC dialog +wait_for_uac_prompt → dict | None — block until UAC fires (or timeout) """ from __future__ import annotations diff --git a/src/windows_mcp/service/secure_desktop.py b/src/windows_mcp/service/secure_desktop.py index 53ef590f..757831ae 100644 --- a/src/windows_mcp/service/secure_desktop.py +++ b/src/windows_mcp/service/secure_desktop.py @@ -26,7 +26,9 @@ import ctypes.wintypes import io import logging +import re import threading +import time from contextlib import contextmanager from typing import Any @@ -315,15 +317,16 @@ def _work() -> bool: return False +class _POINT(ctypes.Structure): + _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)] + + def uia_click_at(x: int, y: int) -> bool: """Invoke the element at (x, y) on the input desktop via UIA ElementFromPoint. Callers can pass coordinates straight from the screenshot. Runs on a fresh thread so COM binds to the correct (Winlogon) desktop. """ - class _POINT(ctypes.Structure): - _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)] - def _work() -> bool: with _input_desktop(): iuia, uia_core = _create_uia() @@ -345,3 +348,165 @@ def _work() -> bool: except Exception as exc: logger.error("uia_click_at(%d,%d) failed: %s", x, y, exc) return False + + +# Additional UIA constants (for ValuePattern, used by Type) +_UIA_ValuePatternId = 10002 + + +def uia_type_at(x: int, y: int, text: str) -> bool: + """Set the value of the editable element at (x, y) on the input desktop. + + Uses the IUIAutomationValuePattern.SetValue method — works from Session 0 + without any input injection, so it crosses the Winlogon boundary safely. + """ + def _work() -> bool: + with _input_desktop(): + iuia, uia_core = _create_uia() + element = iuia.ElementFromPoint(_POINT(x, y)) + if element is None: + logger.warning("uia_type_at(%d,%d): no element found", x, y) + return False + pattern = element.GetCurrentPattern(_UIA_ValuePatternId) + if pattern is None: + logger.warning("uia_type_at(%d,%d): no ValuePattern", x, y) + return False + value = pattern.QueryInterface(uia_core.IUIAutomationValuePattern) + value.SetValue(text) + logger.info("uia_type_at(%d,%d): set value on %r", x, y, element.CurrentName) + return True + + try: + return _run_on_fresh_thread(_work) or False + except Exception as exc: + logger.error("uia_type_at(%d,%d) failed: %s", x, y, exc) + return False + + +def uia_drag_from_to(x1: int, y1: int, x2: int, y2: int) -> bool: + """Drag the element at (x1, y1) onto (x2, y2) using UIA DragPattern when present. + + Cross-desktop drag with native Win32 input is unreliable because mouse_event + cannot be retargeted across Session 0's desktop boundary. This implementation + relies on the source element supporting the legacy IAccessible "DoDefaultAction" + drag or a UIA Transform/Move pattern; it is best-effort and intentionally + narrower than the in-process drag the broker performs on the Default desktop. + Most UAC consent dialogs do not need drag, so this is here for completeness. + """ + _UIA_TransformPatternId = 10016 + def _work() -> bool: + with _input_desktop(): + iuia, uia_core = _create_uia() + src = iuia.ElementFromPoint(_POINT(x1, y1)) + if src is None: + return False + try: + pattern = src.GetCurrentPattern(_UIA_TransformPatternId) + if pattern is None: + return False + transform = pattern.QueryInterface(uia_core.IUIAutomationTransformPattern) + transform.Move(x2, y2) + logger.info("uia_drag_from_to: moved %r to (%d,%d)", src.CurrentName, x2, y2) + return True + except Exception: + return False + + try: + return _run_on_fresh_thread(_work) or False + except Exception as exc: + logger.error("uia_drag_from_to(%d,%d->%d,%d) failed: %s", x1, y1, x2, y2, exc) + return False + + +# --------------------------------------------------------------------------- +# UAC dialog inspection +# --------------------------------------------------------------------------- + +# Patterns the Windows UAC dialog uses for its "verified publisher" line. +# These are localised on non-English Windows; if no pattern matches we return None +# and the allow_with_match policy refuses on caller side. +_PUBLISHER_PATTERNS = [ + re.compile(r"Verified publisher:\s*(.+)", re.IGNORECASE), + re.compile(r"Program name:\s*(.+)", re.IGNORECASE), + re.compile(r"Publisher:\s*(.+)", re.IGNORECASE), +] + + +def get_uac_publisher() -> str | None: + """Inspect the active UAC consent dialog and return its publisher string. + + Returns ``None`` if no UAC dialog is currently displayed, if its layout does + not match the expected English pattern, or if reading the UIA tree fails. + """ + def _work() -> str | None: + with _input_desktop(): + iuia, _ = _create_uia() + root = iuia.GetRootElement() + walker = iuia.RawViewWalker + collected: list[str] = [] + + def _collect(elem: Any, depth: int = 0) -> None: + if depth > 8: + return + try: + name = elem.CurrentName or "" + if name: + collected.append(name) + except Exception: + return + try: + child = walker.GetFirstChildElement(elem) + while child: + _collect(child, depth + 1) + try: + child = walker.GetNextSiblingElement(child) + except Exception: + break + except Exception: + pass + + child = walker.GetFirstChildElement(root) + while child: + _collect(child) + try: + child = walker.GetNextSiblingElement(child) + except Exception: + break + + text = "\n".join(collected) + for pat in _PUBLISHER_PATTERNS: + match = pat.search(text) + if match: + return match.group(1).strip() + return None + + try: + return _run_on_fresh_thread(_work) + except Exception as exc: + logger.warning("get_uac_publisher failed: %s", exc) + return None + + +# --------------------------------------------------------------------------- +# WaitForUACPrompt +# --------------------------------------------------------------------------- + + +def wait_for_uac_prompt(timeout_ms: int = 60_000, poll_ms: int = 250) -> dict | None: + """Block until the Secure Desktop becomes the input desktop, then return the dialog. + + Returns a dict with the UIA tree of the consent dialog plus the extracted + publisher, or ``None`` if the timeout expires without UAC firing. + """ + deadline = time.monotonic() + (timeout_ms / 1000.0) + while time.monotonic() < deadline: + if get_input_desktop_name().lower() == "winlogon": + tree = uia_get_tree() + publisher = get_uac_publisher() + return { + "desktop": "Winlogon", + "publisher": publisher, + "tree": tree, + } + time.sleep(poll_ms / 1000.0) + return None From 53f65938a97da6380166c2f541df33bced12090b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 22:14:31 +0000 Subject: [PATCH 006/158] feat(tools): add WaitForUACPrompt MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-facing half of the secure-desktop story. The tool blocks for up to timeout_ms (default 60 s) until the input desktop becomes Winlogon — i.e. a UAC consent dialog fires — then returns: desktop "Winlogon" publisher "Verified publisher" string from the UAC dialog, or None when the layout doesn't match the English regex. tree full UIA tree of the consent dialog so the agent can read the program name, location, and Yes/No buttons. policy current Secure-Desktop consent policy + allowlist, so the agent can pre-decide whether an auto-click will succeed. When the host service is not installed, returns a structured error explaining how to install it rather than silently blocking — the broker cannot reach the Secure Desktop on its own. --- src/windows_mcp/tools/__init__.py | 2 + src/windows_mcp/tools/uac.py | 87 +++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/windows_mcp/tools/uac.py diff --git a/src/windows_mcp/tools/__init__.py b/src/windows_mcp/tools/__init__.py index 4a55b46d..c99a0634 100644 --- a/src/windows_mcp/tools/__init__.py +++ b/src/windows_mcp/tools/__init__.py @@ -12,6 +12,7 @@ scrape, shell, snapshot, + uac, ) _MODULES = [ @@ -26,6 +27,7 @@ process, notification, registry, + uac, ] diff --git a/src/windows_mcp/tools/uac.py b/src/windows_mcp/tools/uac.py new file mode 100644 index 00000000..dbb2c1e2 --- /dev/null +++ b/src/windows_mcp/tools/uac.py @@ -0,0 +1,87 @@ +"""UAC handling tool — WaitForUACPrompt. + +Lets the agent block until a UAC consent prompt fires on the Secure Desktop, +returning the dialog's UIA tree and (best-effort) verified publisher so the +agent can decide whether to approve it. + +Requires the LocalSystem host service to be installed +(``uv run windows-mcp service secure-desktop install``). Without the service, +this tool returns an explanatory error rather than silently blocking — the +broker cannot see the Secure Desktop on its own. +""" + +from __future__ import annotations + +from typing import Annotated + +from fastmcp import Context +from mcp.types import ToolAnnotations +from pydantic import Field + +from windows_mcp.infrastructure import with_analytics + + +def register(mcp, *, get_desktop, get_analytics): + @mcp.tool( + name="WaitForUACPrompt", + description=( + "Block until a UAC consent prompt appears on the Secure Desktop, then " + "return the dialog as a UIA tree plus the verified publisher (if " + "detectable). Use this when you have just triggered an operation that " + "is expected to require elevation. Requires the Windows-MCP host " + "service to be installed." + ), + annotations=ToolAnnotations( + title="WaitForUACPrompt", + readOnlyHint=True, + destructiveHint=False, + idempotentHint=False, + openWorldHint=False, + ), + ) + @with_analytics(get_analytics(), "WaitForUACPrompt-Tool") + def wait_for_uac_prompt( + timeout_ms: Annotated[ + int, + Field( + description=( + "Maximum time to wait, in milliseconds, before giving up. " + "Defaults to 60000 (60 seconds)." + ), + ge=100, + le=600_000, + ), + ] = 60_000, + ctx: Context = None, + ) -> dict: + from windows_mcp.service import get_host_client + + client = get_host_client() + if not client.is_available(): + return { + "ok": False, + "error": ( + "Windows-MCP Secure Desktop host service is not installed or not " + "running. Install it with: " + "`uv run windows-mcp service secure-desktop install` " + "(requires Administrator)." + ), + } + try: + result = client.wait_for_uac_prompt(timeout_ms=timeout_ms) + except Exception as exc: + return {"ok": False, "error": f"Host service call failed: {exc}"} + if result is None: + return {"ok": True, "fired": False, "reason": "timeout"} + try: + pol = client.policy_state() + except Exception: + pol = None + return { + "ok": True, + "fired": True, + "desktop": result.get("desktop"), + "publisher": result.get("publisher"), + "tree": result.get("tree"), + "policy": pol, + } From 61ff807a867d79cf9de4d3256beacd49a463acc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 22:15:03 +0000 Subject: [PATCH 007/158] feat(desktop): route Type and Drag through service on Secure Desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors what Click already does. When the input desktop is Winlogon (UAC active), the broker delegates to the LocalSystem host service because keyboard/mouse injection from the user session is dropped by UIPI on the Secure Desktop. - Type uses IUIAutomationValuePattern.SetValue (single atomic write, so caret_position/clear/press_enter are ignored on this path — agents should re-screenshot to verify). - Drag uses IUIAutomationTransformPattern.Move (best-effort, only works when the source supports the transform pattern). UAC dialogs rarely need drag, so this is here for completeness. Both calls go through the policy-gated dispatcher in the host service, so block / allow_with_match / allow_all are honoured. --- src/windows_mcp/desktop/service.py | 32 +++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/windows_mcp/desktop/service.py b/src/windows_mcp/desktop/service.py index 700eb160..fd50fb34 100755 --- a/src/windows_mcp/desktop/service.py +++ b/src/windows_mcp/desktop/service.py @@ -688,6 +688,22 @@ def type( press_enter: bool | str = False, ): x, y = loc + + # When UAC is on screen, the broker's SendKeys goes to its own desktop, + # not Winlogon. Route through the LocalSystem service which can call + # IUIAutomationValuePattern.SetValue on the Secure Desktop element. + # SetValue is a single atomic write, so caret_position / clear / + # press_enter are ignored on the secure-desktop path — the agent + # should re-screenshot afterward if it needs to verify state. + from windows_mcp.desktop.screenshot import is_secure_desktop_active + if is_secure_desktop_active(): + from windows_mcp.service import get_host_client + try: + get_host_client().uia_type_at(x, y, text) + except Exception as exc: + logger.warning("UAC type via service failed: %s", exc) + return + uia.Click(x, y) if caret_position == "start": uia.SendKeys("{Home}", waitTime=0.05) @@ -738,11 +754,25 @@ def scroll( return 'Invalid type. Use "horizontal" or "vertical".' return None - def drag(self, loc: tuple[int, int]|list[int]): + def drag(self, loc: tuple[int, int] | list[int]): if isinstance(loc, list): x, y = loc[0], loc[1] else: x, y = loc + + # On the Secure Desktop, mouse_event-style drag is dropped by UIPI. + # Route through the service, which uses IUIAutomationTransformPattern.Move + # — best-effort and only works if the source element supports it. + from windows_mcp.desktop.screenshot import is_secure_desktop_active + if is_secure_desktop_active(): + from windows_mcp.service import get_host_client + try: + cx, cy = uia.GetCursorPos() + get_host_client().uia_drag_from_to(cx, cy, x, y) + except Exception as exc: + logger.warning("UAC drag via service failed: %s", exc) + return + sleep(0.5) cx, cy = uia.GetCursorPos() uia.DragDrop(cx, cy, x, y, moveSpeed=1) From 582605a8a895b8d1578963eb41852ad04eb87a5b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 22:29:06 +0000 Subject: [PATCH 008/158] feat(service): replace NULL pipe DACL with SYSTEM + console-user ACL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous attempt left a NULL DACL on the named pipe, admitting in a code comment that "the tighter SYSTEM+user DACL can be added later once the basic pipe works." That left the service open to any local process, contradicting the issue's requirement that the pipe "require a SID match against the interactive console user." This commit builds a proper SECURITY_DESCRIPTOR per pipe instance: - SYSTEM SID (the service itself) always granted FILE_ALL_ACCESS. - Active console user SID, resolved via WTSGetActiveConsoleSessionId → WTSQueryUserToken → GetTokenInformation(TokenUser), granted FILE_ALL_ACCESS. - If no user is logged on yet (boot before login), falls back to SYSTEM + BUILTIN\Administrators so a local admin can still test. - On any failure to build the ACL, falls through to an *empty* DACL (deny-everyone-except-owner) rather than a NULL DACL — failures are loud, not silent. Caveat: the DACL on a given pipe instance is fixed at creation. If the console user changes mid-service-lifetime (e.g. logout/login of a different account), the broker may briefly hit access-denied until the service rotates to a new pipe instance. Recreating per client (which the loop already does) makes this a single-attempt issue in practice. --- src/windows_mcp/service/host.py | 114 +++++++++++++++++++++++++++++--- 1 file changed, 104 insertions(+), 10 deletions(-) diff --git a/src/windows_mcp/service/host.py b/src/windows_mcp/service/host.py index f41a318d..f8c287bf 100644 --- a/src/windows_mcp/service/host.py +++ b/src/windows_mcp/service/host.py @@ -64,28 +64,122 @@ def _setup_file_logging() -> None: # Pipe security # --------------------------------------------------------------------------- +# Pipe-specific access flags. FILE_ALL_ACCESS = 0x1F01FF — full read/write on +# the pipe handle. We grant this to the two principals we trust: +# - SYSTEM: the service itself +# - Active console user SID: the broker process running in their session +_FILE_ALL_ACCESS = 0x1F01FF + +# Identifiers for the well-known SIDs we need. +# win32security.WinLocalSystemSid → S-1-5-18 (NT AUTHORITY\SYSTEM) +# win32security.WinBuiltinAdministratorsSid → S-1-5-32-544 (BUILTIN\Administrators) +_SID_TYPE_SYSTEM = "WinLocalSystemSid" +_SID_TYPE_ADMINS = "WinBuiltinAdministratorsSid" + + +def _console_user_sid() -> Any | None: + """Return the SID of the user logged on to the physical console, or None. + + Pattern: + WTSGetActiveConsoleSessionId → WTSQueryUserToken → GetTokenInformation + with TokenUser → SID. + + Returns None on services like CI where no interactive session exists yet. + """ + if not _WIN32_AVAILABLE: + return None + try: + import win32ts + import win32api + session_id = win32ts.WTSGetActiveConsoleSessionId() + # 0xFFFFFFFF (~0) means no active session. + if session_id is None or session_id == 0xFFFFFFFF: + logger.info("No active console session yet") + return None + token = win32ts.WTSQueryUserToken(session_id) + try: + token_user = win32security.GetTokenInformation( + token, win32security.TokenUser + ) + # GetTokenInformation(TokenUser) returns a tuple (SID, attrs). + sid = token_user[0] + logger.info( + "Console user SID for session %d: %s", + session_id, win32security.ConvertSidToStringSid(sid), + ) + return sid + finally: + win32api.CloseHandle(token) + except Exception as exc: + logger.warning("Could not resolve console user SID: %s", exc) + return None + + def _build_pipe_sa() -> Any: - """Return a SECURITY_ATTRIBUTES with a NULL DACL (allows all local access). + """Return a SECURITY_ATTRIBUTES that allows only SYSTEM + the console user. - A NULL DACL is intentional here: the pipe is local-only (no network - listener), so allowing any local process to connect is fine. The tighter - SYSTEM+user DACL can be added later once the basic pipe works; for now, - complexity in the DACL was causing CreateNamedPipe to fail silently. + Falls back to SYSTEM + BUILTIN\\Administrators if no console user is + logged in yet (typical at boot, before any login). Never falls back to + a NULL DACL — that was the previous attempt's mistake. - Note: SECURITY_ATTRIBUTES lives in pywintypes, not win32security. + Raising would prevent the service from starting; instead, on failure we + return a SECURITY_ATTRIBUTES with a *deny-all* DACL so the pipe is created + but unreachable, making the failure obvious in logs rather than silent. """ if not _WIN32_AVAILABLE: return None + + import pywintypes + try: - import pywintypes + # SYSTEM SID — always allowed; the service runs as SYSTEM. + sid_system = win32security.CreateWellKnownSid( + getattr(win32security, _SID_TYPE_SYSTEM), None + ) + + # Console user SID (if anyone is logged in); else fall back to Admins. + sid_user = _console_user_sid() + if sid_user is None: + sid_user = win32security.CreateWellKnownSid( + getattr(win32security, _SID_TYPE_ADMINS), None + ) + logger.info("Pipe DACL fallback: SYSTEM + BUILTIN\\Administrators") + else: + logger.info( + "Pipe DACL: SYSTEM + console user %s", + win32security.ConvertSidToStringSid(sid_user), + ) + + dacl = win32security.ACL() + dacl.AddAccessAllowedAce( + win32security.ACL_REVISION, _FILE_ALL_ACCESS, sid_system + ) + dacl.AddAccessAllowedAce( + win32security.ACL_REVISION, _FILE_ALL_ACCESS, sid_user + ) + sd = win32security.SECURITY_DESCRIPTOR() - sd.SetSecurityDescriptorDacl(True, None, False) # NULL DACL = everyone + sd.SetSecurityDescriptorDacl(True, dacl, False) + sd.SetSecurityDescriptorOwner(sid_system, False) + sa = pywintypes.SECURITY_ATTRIBUTES() sa.SECURITY_DESCRIPTOR = sd return sa + except Exception as exc: - logger.warning("Could not build pipe SA, falling back to None: %s", exc) - return None + # Defensive: empty DACL = deny everyone except the SD owner. The pipe + # will still be created, but clients won't be able to connect — and + # the exception is logged loudly so the failure mode is discoverable. + logger.exception("Failed to build restrictive pipe DACL: %s", exc) + try: + empty_dacl = win32security.ACL() + sd = win32security.SECURITY_DESCRIPTOR() + sd.SetSecurityDescriptorDacl(True, empty_dacl, False) + sa = pywintypes.SECURITY_ATTRIBUTES() + sa.SECURITY_DESCRIPTOR = sd + return sa + except Exception: + return None # --------------------------------------------------------------------------- From a385ce27227d77e3f080c7526df649c25046a116 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 22:30:59 +0000 Subject: [PATCH 009/158] feat(service): refuse install when binary path is user-writable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the issue requirement that "the install location must be ACL'd correctly … anyone who can write to the service binary path now has SYSTEM," the install command now checks that both: - sys.executable - windows_mcp.__file__ live under a default admin-only prefix (%ProgramFiles%, %ProgramFiles(x86)%, %SystemRoot%). If either is in a user-writable path (typical for uv tool installs, per-user pip installs, or venvs in %LOCALAPPDATA%), the install is refused with instructions to install Python+windows-mcp system-wide. This is a heuristic, not a true ACL check — but it covers the common case and produces an actionable error. For disposable VMs / development, --allow-user-binary-path opts out of the check with a warning. The VM test will use this flag. Rejected alternative: copying the venv into %ProgramFiles%. That works for plain venvs but breaks for uv tool installs (which use redirector caches), and the copy adds substantial install-time complexity. The check-and-refuse approach delivers the security guarantee with a fraction of the surface area. --- src/windows_mcp/__main__.py | 90 ++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/src/windows_mcp/__main__.py b/src/windows_mcp/__main__.py index aa02caae..7ea40f58 100755 --- a/src/windows_mcp/__main__.py +++ b/src/windows_mcp/__main__.py @@ -763,6 +763,70 @@ def _require_win32(): ) +def _admin_only_prefixes() -> list[str]: + """Paths under which Windows defaults to admin-only write access.""" + return [ + os.environ.get("ProgramFiles", r"C:\Program Files"), + os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), + os.environ.get("SystemRoot", r"C:\Windows"), + ] + + +def _path_is_admin_only(path: str) -> bool: + """Return True if *path* lives under a default admin-only prefix. + + This is a *heuristic*, not a permission check — but it covers 99% of + real installs. Users on truly custom layouts can override with + --allow-user-binary-path. + """ + norm = os.path.normcase(os.path.normpath(path)) + for prefix in _admin_only_prefixes(): + if not prefix: + continue + prefix_norm = os.path.normcase(os.path.normpath(prefix)) + if norm.startswith(prefix_norm + os.sep) or norm == prefix_norm: + return True + return False + + +def _verify_install_paths_are_admin_only() -> None: + """Raise ClickException if the Python interpreter or windows_mcp package live + in a user-writable location. + + The Windows SCM will launch the binary path as SYSTEM. If any component + of that path is under user-writable storage (a uv tool cache, a venv in + %LOCALAPPDATA%, a per-user pip install), then any process running as the + user can replace files there and gain SYSTEM the next time the service + starts. Refuse the install rather than register an unsafe service. + """ + import windows_mcp + + py_exe = sys.executable + pkg_path = os.path.dirname(os.path.abspath(windows_mcp.__file__)) + + unsafe: list[str] = [] + if not _path_is_admin_only(py_exe): + unsafe.append(f" Python interpreter : {py_exe}") + if not _path_is_admin_only(pkg_path): + unsafe.append(f" windows_mcp package: {pkg_path}") + + if not unsafe: + return + + raise click.ClickException( + "Refusing to install the LocalSystem service: the binary path lives in a\n" + "user-writable location. Anyone who can write to that path will obtain\n" + "SYSTEM the next time the service starts.\n\n" + + "\n".join(unsafe) + + "\n\n" + "Install Python system-wide (e.g. `winget install Python.Python.3.13`,\n" + "which lands under %ProgramFiles%) and then `pip install windows-mcp`\n" + "into that system Python. Re-run this command.\n\n" + "If you accept the risk (e.g. testing inside a disposable VM), pass\n" + "--allow-user-binary-path." + ) + + def _sc_state_name(state: int) -> str: import win32service return { @@ -830,7 +894,22 @@ def service_secure_desktop(): "Repeat to add multiple. Comma-separated also works." ), ) -def service_secure_desktop_install(force: bool, policy: str | None, allow_publisher: tuple[str, ...]): +@click.option( + "--allow-user-binary-path", + is_flag=True, + default=False, + help=( + "Allow installing even if Python or windows_mcp live in a user-writable " + "location. Unsafe outside a disposable VM — any local process running as " + "the user can replace the binary and gain SYSTEM at next service start." + ), +) +def service_secure_desktop_install( + force: bool, + policy: str | None, + allow_publisher: tuple[str, ...], + allow_user_binary_path: bool, +): """Install and start the Secure Desktop host service (requires elevation).""" _require_win32() import win32serviceutil @@ -839,6 +918,15 @@ def service_secure_desktop_install(force: bool, policy: str | None, allow_publis from windows_mcp.service.host import WindowsMCPHostService from windows_mcp.service import policy as policy_mod + if not allow_user_binary_path: + _verify_install_paths_are_admin_only() + else: + click.echo( + "WARNING: --allow-user-binary-path was passed. The service binary " + "path may be user-writable, which is a privilege-escalation risk. " + "Use only in disposable VMs." + ) + # Resolve effective policy: CLI flag > env var > config.toml > default ("block"). cfg = load_config(discover_config_path(None)) cli_allowlist: list[str] = [] From 735a92eab53ab6241569ec92cd369f751f24e13f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 22:33:16 +0000 Subject: [PATCH 010/158] test(vm-e2e): in-VM MCP-client harness driving WaitForUACPrompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end test of the LocalSystem secure-desktop story. Driven via vncdotool from Linux to kick off run_all.ps1 inside the Windows VM, then results.json comes back over the SMB share. Pieces: - bringup.sh Linux orchestrator. Waits for the desktop to be visible on VNC :5900, opens powershell via Win+R, pastes the command line that runs run_all.ps1 from the share, then waits for results.json to appear at the corresponding Linux path on the bind-mount. - run_all.ps1 Windows-side orchestrator. Bootstraps Python/uv, stages the repo locally (uv won't sync to a UNC path), installs the service with --allow-user-binary-path (it's a disposable VM), verifies the service is RUNNING, then hands off to mcp_client.py. - mcp_client.py Real MCP client using the `mcp` SDK. Spawns `windows-mcp serve --transport stdio` as a child and talks to it. Lists tools, calls WaitForUACPrompt while a side-process triggers a UAC dialog via `Start-Process -Verb RunAs`, asserts the tool returns a non-empty UIA tree. Writes a JSON report. This is path A (in-VM driver, stdio transport). Path B (Linux-side HTTP client) comes next — same harness but with --http flag. --- tests/manual/vm_e2e/README.md | 44 +++++++ tests/manual/vm_e2e/bringup.sh | 66 ++++++++++ tests/manual/vm_e2e/mcp_client.py | 202 ++++++++++++++++++++++++++++++ tests/manual/vm_e2e/run_all.ps1 | 134 ++++++++++++++++++++ 4 files changed, 446 insertions(+) create mode 100644 tests/manual/vm_e2e/README.md create mode 100755 tests/manual/vm_e2e/bringup.sh create mode 100644 tests/manual/vm_e2e/mcp_client.py create mode 100644 tests/manual/vm_e2e/run_all.ps1 diff --git a/tests/manual/vm_e2e/README.md b/tests/manual/vm_e2e/README.md new file mode 100644 index 00000000..3ee2cc19 --- /dev/null +++ b/tests/manual/vm_e2e/README.md @@ -0,0 +1,44 @@ +# Windows-MCP — VM end-to-end test harness + +Tests the secure-desktop host service against a real UAC prompt inside a +Windows VM. Two driver paths are supported: + +## Path A — in-VM driver (default) + +The MCP client runs *inside* the Windows VM and talks to the MCP server +over the stdio transport — the same shape Claude Desktop uses. Results +are written to a JSON file in the bind-mounted share so the Linux side +can read them without any port mapping. + + Linux Windows VM + ───── ────────── + /home/.../tests/ ◄──SMB──► \\host.lan\Data\…\tests\ + └─ vm_e2e/ └─ run_all.ps1 + └─ results.json ◄──── writes ◄──── mcp_client.py ──stdio──► windows-mcp serve + +## Path B — Linux-side driver + +The MCP server inside the VM is served over streamable-http. Container +port 8000 is forwarded to the Linux host (needs container restart with +`-p 8000:8000`). The Python MCP client runs on Linux. Same assertions +but exercises HTTP transport too. + +## Bring-up sequence + +Run from Linux: + + bash tests/manual/vm_e2e/bringup.sh + +That installs Python+uv+windows-mcp inside the VM, registers the service +with `--allow-user-binary-path` (since the VM is disposable), and runs +`run_all.ps1`. After completion, `results.json` will exist at +`tests/manual/vm_e2e/results.json` on the Linux side. + +## Tests covered + +1. Service install succeeds, service is RUNNING. +2. Service auto-starts after a Windows reboot (no manual start needed). +3. MCP `WaitForUACPrompt` blocks, returns a dialog after we trigger UAC. +4. Policy=`block` → service refuses auto-click on Winlogon. +5. Policy=`allow_all` → service performs the auto-click. +6. Service uninstall removes the registry policy and the service entry. diff --git a/tests/manual/vm_e2e/bringup.sh b/tests/manual/vm_e2e/bringup.sh new file mode 100755 index 00000000..29eae182 --- /dev/null +++ b/tests/manual/vm_e2e/bringup.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Kick off the in-VM test suite. +# +# bash tests/manual/vm_e2e/bringup.sh +# +# Requires the dockur/windows container to be running and an OOBE-complete +# Windows VM accessible on VNC port 5900 with samba share at \\host.lan\Data. +# +# After the script returns, read tests/manual/vm_e2e/results.json on the host +# side to see what passed/failed. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +RESULTS="$REPO_ROOT/tests/manual/vm_e2e/results.json" +LOG_DIR="$REPO_ROOT/tests/manual/vm_e2e" + +vnc_send_keys() { + local text="$1" + # vncdotool's typetext expects spaces to be literal; doublequotes inside + # the text need escaping. We're sending a single PowerShell one-liner so + # let's just pass it through carefully. + vncdotool -s 127.0.0.1::5900 typewrite "$text" +} + +echo "==> waiting for Windows desktop to be available on VNC :5900" +# Heuristic: when the desktop is up, the VNC frame size jumps past ~50 KB +# (Windows desktop has more visual content than the install/OOBE pages). +for _ in $(seq 1 90); do + out="/tmp/vnc-bringup.png" + if vncdotool -s 127.0.0.1::5900 capture "$out" >/dev/null 2>&1; then + sz=$(stat -c %s "$out" 2>/dev/null || echo 0) + if [ "$sz" -gt 60000 ]; then + echo " desktop visible ($sz bytes)" + break + fi + fi + sleep 10 +done + +echo "==> opening PowerShell via Win+R" +vncdotool -s 127.0.0.1::5900 key win-r +sleep 1 +vncdotool -s 127.0.0.1::5900 typewrite "powershell" +vncdotool -s 127.0.0.1::5900 key enter +sleep 3 + +echo "==> launching run_all.ps1 from the share" +PS_CMD='powershell -ExecutionPolicy Bypass -File \\host.lan\Data\Windows-MCP\tests\manual\vm_e2e\run_all.ps1' +vncdotool -s 127.0.0.1::5900 typewrite "$PS_CMD" +vncdotool -s 127.0.0.1::5900 key enter + +echo "==> waiting for $RESULTS to appear" +rm -f "$RESULTS" +for _ in $(seq 1 60); do + if [ -f "$RESULTS" ]; then + echo " results.json received" + cat "$RESULTS" + exit 0 + fi + sleep 30 +done + +echo "ERROR: results.json never appeared after 30 minutes" >&2 +echo "Check tests/manual/vm_e2e/run_all.log on the share for details" >&2 +exit 1 diff --git a/tests/manual/vm_e2e/mcp_client.py b/tests/manual/vm_e2e/mcp_client.py new file mode 100644 index 00000000..fdd59d27 --- /dev/null +++ b/tests/manual/vm_e2e/mcp_client.py @@ -0,0 +1,202 @@ +"""In-VM MCP test client. + +Connects to a `windows-mcp serve` process (stdio transport) and exercises the +secure-desktop tool surface end-to-end. Writes a JSON results file the host +side can read off the bind-mount share. + +Usage (run from inside the Windows VM): + + python mcp_client.py --results C:\\path\\to\\results.json [--http URL] + +When --http is given, talks to a remote server over streamable-http instead +of spawning the local stdio server. That mode is for the Linux-side driver +in path B. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import subprocess +import sys +import time +from dataclasses import asdict, dataclass, field +from typing import Any + +try: + from mcp.client.session import ClientSession + from mcp.client.stdio import StdioServerParameters, stdio_client + from mcp.client.streamable_http import streamablehttp_client +except ImportError as exc: + raise SystemExit( + f"mcp client SDK not importable: {exc}\n" + "Inside the venv used to install windows-mcp, run: pip install mcp" + ) + + +@dataclass +class TestResult: + name: str + passed: bool + detail: str = "" + duration_s: float = 0.0 + + +@dataclass +class Report: + started_at: str = "" + finished_at: str = "" + transport: str = "" + results: list[TestResult] = field(default_factory=list) + summary: dict[str, int] = field(default_factory=dict) + + +async def run_tool(session: ClientSession, name: str, args: dict | None = None) -> Any: + args = args or {} + result = await session.call_tool(name, args) + return result + + +async def assert_service_running(session: ClientSession, report: Report) -> None: + start = time.monotonic() + # We assert via a tool that exists in the broker — the broker is what + # owns the pipe client. The Snapshot tool will pull a screenshot through + # the service when secure desktop is active; here we just hit any tool + # so we know MCP plumbing works. + try: + tools = await session.list_tools() + names = [t.name for t in tools.tools] + ok = "WaitForUACPrompt" in names + detail = f"tools: {sorted(names)[:10]}…" + except Exception as exc: + ok, detail = False, f"list_tools failed: {exc}" + report.results.append(TestResult( + "list_tools includes WaitForUACPrompt", ok, detail, time.monotonic() - start, + )) + + +async def assert_wait_for_uac_returns_dialog( + session: ClientSession, report: Report +) -> None: + """Trigger UAC, expect WaitForUACPrompt to return the dialog.""" + start = time.monotonic() + # Spawn an elevation prompt asynchronously so it arrives while we wait. + trigger = subprocess.Popen( + [ + "powershell.exe", "-NoLogo", "-NoProfile", "-Command", + "Start-Sleep -Milliseconds 1500; " + "Start-Process -FilePath cmd.exe -Verb RunAs -WindowStyle Hidden", + ], + ) + try: + result = await run_tool( + session, "WaitForUACPrompt", {"timeout_ms": 30_000} + ) + # FastMCP returns a structured response; pull the first text content + payload = _extract_payload(result) + ok = bool(payload.get("ok")) and payload.get("fired") is True + if ok: + tree = payload.get("tree") or [] + ok = bool(tree) + detail = ( + f"publisher={payload.get('publisher')!r} " + f"top_windows={len(tree)} " + f"policy={payload.get('policy')}" + ) + else: + detail = f"payload: {json.dumps(payload)[:300]}" + except Exception as exc: + ok, detail = False, f"call failed: {exc}" + finally: + try: + trigger.wait(timeout=5) + except Exception: + trigger.kill() + report.results.append(TestResult( + "WaitForUACPrompt returns dialog after UAC fires", ok, detail, + time.monotonic() - start, + )) + + +def _extract_payload(call_result: Any) -> dict: + """Pull the JSON payload out of an MCP call_tool result.""" + content = getattr(call_result, "content", None) or [] + for item in content: + text = getattr(item, "text", None) + if text is None: + continue + try: + return json.loads(text) + except json.JSONDecodeError: + return {"_raw": text} + return {} + + +async def run_async(args: argparse.Namespace) -> Report: + report = Report( + started_at=_now(), + transport=("http" if args.http else "stdio"), + ) + if args.http: + async with streamablehttp_client(args.http) as (read, write, _info): + async with ClientSession(read, write) as session: + await session.initialize() + await _run_suite(session, report) + else: + params = StdioServerParameters( + command=args.python or sys.executable, + args=["-m", "windows_mcp", "serve", "--transport", "stdio"], + env=os.environ.copy(), + ) + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + await _run_suite(session, report) + report.finished_at = _now() + report.summary = { + "total": len(report.results), + "passed": sum(1 for r in report.results if r.passed), + "failed": sum(1 for r in report.results if not r.passed), + } + return report + + +async def _run_suite(session: ClientSession, report: Report) -> None: + await assert_service_running(session, report) + await assert_wait_for_uac_returns_dialog(session, report) + + +def _now() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%S") + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--results", required=True, help="Write JSON report here.") + ap.add_argument("--http", help="Talk to a remote server at this URL instead of stdio.") + ap.add_argument("--python", help="Override the python.exe used for the stdio server.") + args = ap.parse_args() + + try: + report = asyncio.run(run_async(args)) + except Exception as exc: + report = Report( + started_at=_now(), finished_at=_now(), transport="(failed to start)", + results=[TestResult("driver bootstrap", False, repr(exc), 0.0)], + summary={"total": 1, "passed": 0, "failed": 1}, + ) + + with open(args.results, "w", encoding="utf-8") as fh: + json.dump({ + **asdict(report), + "results": [asdict(r) for r in report.results], + }, fh, indent=2, default=str) + + print(json.dumps(report.summary, indent=2)) + return 0 if report.summary.get("failed", 1) == 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/manual/vm_e2e/run_all.ps1 b/tests/manual/vm_e2e/run_all.ps1 new file mode 100644 index 00000000..9c2924a8 --- /dev/null +++ b/tests/manual/vm_e2e/run_all.ps1 @@ -0,0 +1,134 @@ +# In-VM test orchestrator (Path A). +# +# Kicked off from the host via vncdotool keystroke. Picks up at first-boot, +# installs Python+uv if needed, registers the secure-desktop service, then +# runs the MCP client tests and writes results.json back to the share. + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$Repo = "\\host.lan\Data\Windows-MCP" +$LocalRepo = "C:\windows-mcp" +$ResultsDir = Join-Path $Repo "tests\manual\vm_e2e" +$ResultsJson = Join-Path $ResultsDir "results.json" +$Log = Join-Path $ResultsDir "run_all.log" + +function Log($msg) { + $ts = (Get-Date).ToString("HH:mm:ss") + Add-Content -Path $Log -Value "[$ts] $msg" + Write-Host "[$ts] $msg" +} + +# ----------------------------------------------------------------------------- +# 1) Bootstrap Python + uv if not already on PATH +# ----------------------------------------------------------------------------- +function Ensure-Python { + if (Get-Command python -ErrorAction SilentlyContinue) { + Log "python present: $(python --version)" + return + } + Log "Installing Python via winget…" + winget install --id Python.Python.3.13 --source winget --silent ` + --accept-source-agreements --accept-package-agreements | Out-Null + $env:Path = "$env:LOCALAPPDATA\Programs\Python\Python313\;$env:Path" +} + +function Ensure-Uv { + if (Get-Command uv -ErrorAction SilentlyContinue) { + Log "uv present: $(uv --version)" + return + } + Log "Installing uv…" + powershell -ExecutionPolicy Bypass -c "irm https://astral.sh/uv/install.ps1 | iex" + $env:Path = "$env:USERPROFILE\.local\bin;$env:Path" +} + +# ----------------------------------------------------------------------------- +# 2) Stage the repo locally so uv sync can write its venv on a normal drive +# (uv refuses to write into UNC shares). +# ----------------------------------------------------------------------------- +function Stage-Repo { + if (Test-Path $LocalRepo) { + Log "Refreshing $LocalRepo" + Remove-Item -Recurse -Force "$LocalRepo\*" -ErrorAction SilentlyContinue + } else { + Log "Creating $LocalRepo" + New-Item -ItemType Directory -Path $LocalRepo | Out-Null + } + robocopy $Repo $LocalRepo /MIR /XD .git .venv tests\manual\vm_e2e\.work | Out-Null +} + +# ----------------------------------------------------------------------------- +# 3) uv sync + install the host service +# ----------------------------------------------------------------------------- +function Setup-Project { + Push-Location $LocalRepo + try { + Log "uv sync" + uv sync 2>&1 | Tee-Object -FilePath (Join-Path $ResultsDir "uv_sync.log") + Log "Installing the host service (allow-user-binary-path because this is a VM)…" + uv run windows-mcp service secure-desktop install ` + --policy allow_all --allow-user-binary-path --force 2>&1 | + Tee-Object -FilePath (Join-Path $ResultsDir "install.log") + } finally { + Pop-Location + } +} + +# ----------------------------------------------------------------------------- +# 4) Verify the service is RUNNING, then run the MCP client. +# ----------------------------------------------------------------------------- +function Verify-Service { + $svc = Get-Service WindowsMCPHost -ErrorAction SilentlyContinue + if ($null -eq $svc) { + throw "Service WindowsMCPHost not registered" + } + if ($svc.Status -ne "Running") { + throw "Service WindowsMCPHost not running: $($svc.Status)" + } + Log "Service WindowsMCPHost is Running" +} + +function Run-MCP-Tests { + Push-Location $LocalRepo + try { + Log "Running mcp_client.py (stdio transport, in-VM)…" + uv run python tests\manual\vm_e2e\mcp_client.py --results $ResultsJson 2>&1 | + Tee-Object -FilePath (Join-Path $ResultsDir "mcp_client.log") + } finally { + Pop-Location + } +} + +# ----------------------------------------------------------------------------- +# Main +# ----------------------------------------------------------------------------- +if (-not (Test-Path $ResultsDir)) { + New-Item -ItemType Directory -Path $ResultsDir | Out-Null +} +Set-Content -Path $Log -Value "run_all.ps1 started $(Get-Date -Format o)" + +try { + Ensure-Python + Ensure-Uv + Stage-Repo + Setup-Project + Verify-Service + Run-MCP-Tests + Log "DONE" +} catch { + Log "FAILED: $($_.Exception.Message)" + @{ + started_at = (Get-Date).ToString("o") + finished_at = (Get-Date).ToString("o") + transport = "(bootstrap-failed)" + results = @(@{ + name = "run_all.ps1 bootstrap" + passed = $false + detail = $_.Exception.Message + duration_s = 0 + }) + summary = @{ total = 1; passed = 0; failed = 1 } + } | ConvertTo-Json -Depth 5 | Set-Content -Path $ResultsJson + exit 1 +} From e617ad162cc179614d417e9f58a7ce7ac04fbcef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 22:34:03 +0000 Subject: [PATCH 011/158] =?UTF-8?q?test(vm-e2e):=20add=20path=20B=20?= =?UTF-8?q?=E2=80=94=20Linux-side=20HTTP=20MCP=20client=20driver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same mcp_client.py, called with --http, against the MCP server running over streamable-http inside the VM. Validates the full network path: Linux → docker host port → container → socat forward → VM → MCP server. Documents the one-time setup (container restart with -p 8000:8000, socat forward inside the container) and reads WINDOWS_MCP_URL to let the user point it elsewhere if needed. --- tests/manual/vm_e2e/path_b_linux.sh | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100755 tests/manual/vm_e2e/path_b_linux.sh diff --git a/tests/manual/vm_e2e/path_b_linux.sh b/tests/manual/vm_e2e/path_b_linux.sh new file mode 100755 index 00000000..446ec4d9 --- /dev/null +++ b/tests/manual/vm_e2e/path_b_linux.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Path B — Linux-side MCP client driver. +# +# Runs the same mcp_client.py against the running MCP server inside the +# Windows VM, but over streamable-http transport so the protocol travels +# Linux → host:8000 → container → VM. This validates that the MCP server +# is reachable over a real network transport (the shape Claude Desktop +# uses when the server is remote). +# +# Prerequisites (one-time host setup): +# +# 1. Container must be running with port 8000 forwarded: +# docker run … -p 8000:8000 … +# To re-add to a running container without losing the disk image, +# docker stop winvm; docker rm winvm (keeps /tmp/winvm-storage); +# then `docker run` again with the same -v /tmp/winvm-storage:/storage +# plus -p 8000:8000. +# +# 2. Inside the container, forward container:8000 to the Windows VM's +# IP (dockur typically assigns 20.20.20.21): +# docker exec -d winvm sh -c 'apt-get -qq install -y socat 2>/dev/null; +# socat TCP-LISTEN:8000,fork,reuseaddr TCP:20.20.20.21:8000' +# +# 3. Inside the Windows VM, the MCP server must be running: +# windows-mcp serve --transport streamable-http --host 0.0.0.0 \ +# --port 8000 --allow-insecure-remote +# (run_all.ps1's path-B mode does this automatically.) + +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +RESULTS="$REPO_ROOT/tests/manual/vm_e2e/results-path-b.json" +URL="${WINDOWS_MCP_URL:-http://localhost:8000/mcp/}" + +echo "==> ensuring local mcp client SDK is installed" +pip install --quiet --break-system-packages mcp >/dev/null + +echo "==> probing $URL" +if ! curl -sf --max-time 5 -o /dev/null "$URL" 2>/dev/null; then + echo " (probe returned non-200, but the MCP server might require a session; continuing)" +fi + +echo "==> running mcp_client.py against $URL" +python3 "$REPO_ROOT/tests/manual/vm_e2e/mcp_client.py" \ + --results "$RESULTS" \ + --http "$URL" + +echo +echo "==> path B results:" +cat "$RESULTS" From c8b8f7267dd06b74da6e2af2b407259c0be5f7d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 02:11:58 +0000 Subject: [PATCH 012/158] fix(vm-e2e): correct vncdotool command name + reroute via Start menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vncdotool's command is 'type', not 'typewrite' (which silently dropped all keystrokes). Also switched from Win+R to the Start-menu search, which is more deterministic — Win+R + Enter on an empty box was randomly launching netsh because of Windows' recent-commands fallback. --- tests/manual/vm_e2e/bringup.sh | 46 +++++++++++++++++----------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/manual/vm_e2e/bringup.sh b/tests/manual/vm_e2e/bringup.sh index 29eae182..76b5f5dd 100755 --- a/tests/manual/vm_e2e/bringup.sh +++ b/tests/manual/vm_e2e/bringup.sh @@ -13,24 +13,17 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" RESULTS="$REPO_ROOT/tests/manual/vm_e2e/results.json" -LOG_DIR="$REPO_ROOT/tests/manual/vm_e2e" +VNC="127.0.0.1::5900" -vnc_send_keys() { - local text="$1" - # vncdotool's typetext expects spaces to be literal; doublequotes inside - # the text need escaping. We're sending a single PowerShell one-liner so - # let's just pass it through carefully. - vncdotool -s 127.0.0.1::5900 typewrite "$text" -} +vnc() { vncdotool --delay=80 -s "$VNC" "$@"; } -echo "==> waiting for Windows desktop to be available on VNC :5900" -# Heuristic: when the desktop is up, the VNC frame size jumps past ~50 KB -# (Windows desktop has more visual content than the install/OOBE pages). +echo "==> waiting for Windows desktop on VNC :5900" +# Heuristic: when the desktop is up, the VNC frame jumps past ~150 KB +# (Windows desktop background renders far more than the install splash). for _ in $(seq 1 90); do - out="/tmp/vnc-bringup.png" - if vncdotool -s 127.0.0.1::5900 capture "$out" >/dev/null 2>&1; then - sz=$(stat -c %s "$out" 2>/dev/null || echo 0) - if [ "$sz" -gt 60000 ]; then + if vnc capture /tmp/vnc-bringup.png >/dev/null 2>&1; then + sz=$(stat -c %s /tmp/vnc-bringup.png 2>/dev/null || echo 0) + if [ "$sz" -gt 150000 ]; then echo " desktop visible ($sz bytes)" break fi @@ -38,20 +31,27 @@ for _ in $(seq 1 90); do sleep 10 done -echo "==> opening PowerShell via Win+R" -vncdotool -s 127.0.0.1::5900 key win-r +echo "==> opening Start menu and searching for powershell" +# Win+R is fragile when no app has focus (it can autocomplete to other exes). +# The Start-menu search is far more deterministic: tap Win, type, Enter. +vnc key super sleep 1 -vncdotool -s 127.0.0.1::5900 typewrite "powershell" -vncdotool -s 127.0.0.1::5900 key enter -sleep 3 +vnc type "powershell" +sleep 2 # let the search index resolve +vnc key enter +sleep 5 # PowerShell takes a beat to materialise echo "==> launching run_all.ps1 from the share" +# Single-line PowerShell launcher. ExecutionPolicy Bypass for the child only. PS_CMD='powershell -ExecutionPolicy Bypass -File \\host.lan\Data\Windows-MCP\tests\manual\vm_e2e\run_all.ps1' -vncdotool -s 127.0.0.1::5900 typewrite "$PS_CMD" -vncdotool -s 127.0.0.1::5900 key enter +vnc type "$PS_CMD" +sleep 1 +vnc key enter echo "==> waiting for $RESULTS to appear" rm -f "$RESULTS" +# 60 iterations * 30 s = 30 min wait. Windows install of Python+uv inside +# the VM takes ~10 min by itself under TCG. for _ in $(seq 1 60); do if [ -f "$RESULTS" ]; then echo " results.json received" @@ -62,5 +62,5 @@ for _ in $(seq 1 60); do done echo "ERROR: results.json never appeared after 30 minutes" >&2 -echo "Check tests/manual/vm_e2e/run_all.log on the share for details" >&2 +echo "Check $REPO_ROOT/tests/manual/vm_e2e/run_all.log on the share for details" >&2 exit 1 From 6bdc0de93f94645aea1549139109434074214363 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 12:42:27 +0000 Subject: [PATCH 013/158] test(vm-e2e): add kickoff.bat launcher under tests/manual/vm_e2e Wraps the long PowerShell -ExecutionPolicy Bypass -File command in a single short batch file so the Win+R driver only has to type one short UNC path. --- tests/manual/vm_e2e/kickoff.bat | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 tests/manual/vm_e2e/kickoff.bat diff --git a/tests/manual/vm_e2e/kickoff.bat b/tests/manual/vm_e2e/kickoff.bat new file mode 100644 index 00000000..3b2a100a --- /dev/null +++ b/tests/manual/vm_e2e/kickoff.bat @@ -0,0 +1,4 @@ +@echo off +REM Kickoff for the in-VM test harness. Launched via Win+R as a UNC path so +REM the typing surface is short and unambiguous: \\host.lan\Data\Windows-MCP\kickoff.bat +start "" powershell -ExecutionPolicy Bypass -File \\host.lan\Data\Windows-MCP\tests\manual\vm_e2e\run_all.ps1 From e6fc35cc7a3dd70e021b68a6efca97776492c88f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 12:52:48 +0000 Subject: [PATCH 014/158] test(vm-e2e): expand suite to actually click Yes + verify policy enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version only asserted that WaitForUACPrompt returned a tree. That doesn't prove the feature works — the agent must be able to act on the dialog. New per-assertion checks: list_tools includes WaitForUACPrompt WaitForUACPrompt returns dialog after UAC fires policy is UAC tree contains invokable Yes button (allow_all) Click(Yes) dismisses UAC (block) Click(Yes) is refused with policy denied run_all.ps1 now runs the suite twice — once with policy=allow_all (must click Yes successfully) and once with policy=block (must be refused). Phase results merged into results.json. --- tests/manual/vm_e2e/mcp_client.py | 279 +++++++++++++++++++++++------- tests/manual/vm_e2e/run_all.ps1 | 42 ++++- 2 files changed, 252 insertions(+), 69 deletions(-) diff --git a/tests/manual/vm_e2e/mcp_client.py b/tests/manual/vm_e2e/mcp_client.py index fdd59d27..fa0eb835 100644 --- a/tests/manual/vm_e2e/mcp_client.py +++ b/tests/manual/vm_e2e/mcp_client.py @@ -11,6 +11,17 @@ When --http is given, talks to a remote server over streamable-http instead of spawning the local stdio server. That mode is for the Linux-side driver in path B. + +The suite asserts, **per assertion** (no "all green if any pass"): + + 1. list_tools includes WaitForUACPrompt. + 2. WaitForUACPrompt blocks then returns a non-empty UIA tree after we + trigger UAC via `Start-Process -Verb RunAs`. + 3. The returned tree contains a "Yes" button with valid coordinates. + 4. Click(loc=[Yes.x, Yes.y]) under policy=allow_all dismisses UAC + (a follow-up WaitForUACPrompt with a short timeout returns fired=False). + 5. Under policy=block, Click is REFUSED with a "policy denied" error, + and the dialog stays on screen. """ from __future__ import annotations @@ -53,85 +64,222 @@ class Report: summary: dict[str, int] = field(default_factory=dict) -async def run_tool(session: ClientSession, name: str, args: dict | None = None) -> Any: +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +async def call(session: ClientSession, name: str, args: dict | None = None) -> dict: + """Call an MCP tool and return the parsed JSON payload.""" args = args or {} - result = await session.call_tool(name, args) - return result + raw = await session.call_tool(name, args) + return _extract_payload(raw) -async def assert_service_running(session: ClientSession, report: Report) -> None: - start = time.monotonic() - # We assert via a tool that exists in the broker — the broker is what - # owns the pipe client. The Snapshot tool will pull a screenshot through - # the service when secure desktop is active; here we just hit any tool - # so we know MCP plumbing works. - try: - tools = await session.list_tools() - names = [t.name for t in tools.tools] - ok = "WaitForUACPrompt" in names - detail = f"tools: {sorted(names)[:10]}…" - except Exception as exc: - ok, detail = False, f"list_tools failed: {exc}" - report.results.append(TestResult( - "list_tools includes WaitForUACPrompt", ok, detail, time.monotonic() - start, - )) +def _extract_payload(call_result: Any) -> dict: + """Pull the JSON payload out of an MCP call_tool result.""" + content = getattr(call_result, "content", None) or [] + for item in content: + text = getattr(item, "text", None) + if text is None: + continue + try: + return json.loads(text) + except json.JSONDecodeError: + return {"_raw": text} + return {} -async def assert_wait_for_uac_returns_dialog( - session: ClientSession, report: Report -) -> None: - """Trigger UAC, expect WaitForUACPrompt to return the dialog.""" - start = time.monotonic() - # Spawn an elevation prompt asynchronously so it arrives while we wait. - trigger = subprocess.Popen( +def _find_named_invokable(tree: list[dict], name: str) -> dict | None: + """DFS through a WaitForUACPrompt tree looking for `name` with can_invoke=True.""" + target = name.strip().lower() + stack = list(tree) + while stack: + node = stack.pop() + if not isinstance(node, dict): + continue + nname = (node.get("name") or "").strip().lower() + if nname == target and node.get("can_invoke"): + return node + for child in node.get("children") or []: + stack.append(child) + return None + + +def _trigger_uac() -> subprocess.Popen: + """Fire a real UAC prompt asynchronously. Returns the Popen handle.""" + return subprocess.Popen( [ "powershell.exe", "-NoLogo", "-NoProfile", "-Command", "Start-Sleep -Milliseconds 1500; " "Start-Process -FilePath cmd.exe -Verb RunAs -WindowStyle Hidden", ], ) + + +def _record(report: Report, name: str, ok: bool, detail: str, t0: float) -> None: + report.results.append(TestResult( + name=name, passed=ok, detail=detail, duration_s=time.monotonic() - t0, + )) + + +# --------------------------------------------------------------------------- +# assertions +# --------------------------------------------------------------------------- + +async def assert_list_tools(session: ClientSession, report: Report) -> None: + t0 = time.monotonic() try: - result = await run_tool( - session, "WaitForUACPrompt", {"timeout_ms": 30_000} - ) - # FastMCP returns a structured response; pull the first text content - payload = _extract_payload(result) - ok = bool(payload.get("ok")) and payload.get("fired") is True - if ok: - tree = payload.get("tree") or [] - ok = bool(tree) - detail = ( - f"publisher={payload.get('publisher')!r} " - f"top_windows={len(tree)} " - f"policy={payload.get('policy')}" - ) - else: - detail = f"payload: {json.dumps(payload)[:300]}" + tools = await session.list_tools() + names = sorted(t.name for t in tools.tools) + ok = "WaitForUACPrompt" in names + detail = f"{len(names)} tools registered; first: {names[:8]}…" + except Exception as exc: + ok, detail = False, f"list_tools failed: {exc}" + _record(report, "list_tools includes WaitForUACPrompt", ok, detail, t0) + + +async def assert_wait_for_uac_returns_dialog( + session: ClientSession, report: Report, *, expect_policy: str +) -> dict | None: + """Trigger UAC, expect WaitForUACPrompt to return a non-empty tree. + + Returns the payload so the next assertion can find the Yes button. + """ + t0 = time.monotonic() + trigger = _trigger_uac() + payload: dict = {} + try: + payload = await call(session, "WaitForUACPrompt", {"timeout_ms": 30_000}) except Exception as exc: - ok, detail = False, f"call failed: {exc}" + _record(report, "WaitForUACPrompt returns dialog", False, f"call failed: {exc}", t0) + return None finally: try: - trigger.wait(timeout=5) + trigger.wait(timeout=10) except Exception: trigger.kill() - report.results.append(TestResult( - "WaitForUACPrompt returns dialog after UAC fires", ok, detail, - time.monotonic() - start, - )) + fired = bool(payload.get("ok")) and payload.get("fired") is True + tree = payload.get("tree") or [] + ok = fired and bool(tree) + detail = ( + f"fired={fired} top_windows={len(tree)} " + f"publisher={payload.get('publisher')!r} policy={payload.get('policy')}" + ) + _record(report, "WaitForUACPrompt returns dialog", ok, detail, t0) + + # Bonus assertion: policy reported matches what we set up. + pol = (payload.get("policy") or {}).get("policy") + _record( + report, + f"policy is {expect_policy}", + pol == expect_policy, + f"got {pol!r}", + t0, + ) -def _extract_payload(call_result: Any) -> dict: - """Pull the JSON payload out of an MCP call_tool result.""" - content = getattr(call_result, "content", None) or [] - for item in content: - text = getattr(item, "text", None) - if text is None: - continue + return payload if ok else None + + +async def assert_yes_button_present( + payload: dict, report: Report +) -> dict | None: + t0 = time.monotonic() + yes = _find_named_invokable(payload.get("tree") or [], "Yes") + if yes is None: + _record(report, "UAC tree contains invokable Yes button", False, + "no element named 'Yes' with can_invoke=True", t0) + return None + cx, cy = yes.get("center", {}).get("x"), yes.get("center", {}).get("y") + ok = isinstance(cx, int) and isinstance(cy, int) + _record( + report, "UAC tree contains invokable Yes button", + ok, f"Yes at ({cx},{cy}) bbox={yes.get('bbox')}", t0, + ) + return yes if ok else None + + +async def assert_click_dismisses_uac( + session: ClientSession, report: Report, yes_node: dict +) -> None: + """policy=allow_all branch: click Yes, verify UAC is gone.""" + t0 = time.monotonic() + cx = yes_node["center"]["x"] + cy = yes_node["center"]["y"] + try: + click_result = await call(session, "Click", {"loc": [cx, cy]}) + except Exception as exc: + _record(report, "Click(Yes) under allow_all dismisses UAC", + False, f"Click call failed: {exc}", t0) + return + + # Now verify UAC is no longer the input desktop. Issue a short-timeout + # WaitForUACPrompt — if it times out, secure desktop is no longer active. + await asyncio.sleep(2) + follow = await call(session, "WaitForUACPrompt", {"timeout_ms": 2_000}) + dismissed = follow.get("ok") is True and follow.get("fired") is False + _record( + report, "Click(Yes) under allow_all dismisses UAC", + dismissed, + f"click_result={json.dumps(click_result)[:200]} follow={json.dumps(follow)[:200]}", + t0, + ) + + +async def assert_block_policy_refuses_click( + session: ClientSession, report: Report, yes_node: dict +) -> None: + """policy=block branch: clicking should be refused by the service.""" + t0 = time.monotonic() + cx = yes_node["center"]["x"] + cy = yes_node["center"]["y"] + try: + result = await call(session, "Click", {"loc": [cx, cy]}) + except Exception as exc: + # MCP errors surface as exceptions; that's also a valid "refused" signal. + _record(report, "Click(Yes) under block is refused", + "policy" in str(exc).lower() or "denied" in str(exc).lower(), + f"exc={exc}", t0) + return + + raw = json.dumps(result).lower() + refused = "policy" in raw and ("denied" in raw or "block" in raw or "refus" in raw) + _record( + report, "Click(Yes) under block is refused", + refused, + f"result={json.dumps(result)[:300]}", + t0, + ) + + +# --------------------------------------------------------------------------- +# orchestration +# --------------------------------------------------------------------------- + +async def _run_suite(session: ClientSession, report: Report, mode: str) -> None: + """`mode` is the policy phase the run_all script set up before invoking us.""" + await assert_list_tools(session, report) + + payload = await assert_wait_for_uac_returns_dialog( + session, report, expect_policy=mode, + ) + if payload is None: + return + + yes_node = await assert_yes_button_present(payload, report) + if yes_node is None: + return + + if mode == "allow_all": + await assert_click_dismisses_uac(session, report, yes_node) + elif mode == "block": + await assert_block_policy_refuses_click(session, report, yes_node) + # Make sure UAC is dismissed for the next phase (Cancel = right-arrow + Enter? simpler: + # send Esc via the broker's Shortcut tool, which goes through the service path). try: - return json.loads(text) - except json.JSONDecodeError: - return {"_raw": text} - return {} + await call(session, "Shortcut", {"shortcut": "Escape"}) + except Exception: + pass async def run_async(args: argparse.Namespace) -> Report: @@ -143,7 +291,7 @@ async def run_async(args: argparse.Namespace) -> Report: async with streamablehttp_client(args.http) as (read, write, _info): async with ClientSession(read, write) as session: await session.initialize() - await _run_suite(session, report) + await _run_suite(session, report, args.mode) else: params = StdioServerParameters( command=args.python or sys.executable, @@ -153,7 +301,7 @@ async def run_async(args: argparse.Namespace) -> Report: async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() - await _run_suite(session, report) + await _run_suite(session, report, args.mode) report.finished_at = _now() report.summary = { "total": len(report.results), @@ -163,11 +311,6 @@ async def run_async(args: argparse.Namespace) -> Report: return report -async def _run_suite(session: ClientSession, report: Report) -> None: - await assert_service_running(session, report) - await assert_wait_for_uac_returns_dialog(session, report) - - def _now() -> str: return time.strftime("%Y-%m-%dT%H:%M:%S") @@ -177,6 +320,10 @@ def main() -> int: ap.add_argument("--results", required=True, help="Write JSON report here.") ap.add_argument("--http", help="Talk to a remote server at this URL instead of stdio.") ap.add_argument("--python", help="Override the python.exe used for the stdio server.") + ap.add_argument( + "--mode", choices=["allow_all", "block"], default="allow_all", + help="Which policy phase the surrounding script set up before invoking us.", + ) args = ap.parse_args() try: diff --git a/tests/manual/vm_e2e/run_all.ps1 b/tests/manual/vm_e2e/run_all.ps1 index 9c2924a8..139eb6de 100644 --- a/tests/manual/vm_e2e/run_all.ps1 +++ b/tests/manual/vm_e2e/run_all.ps1 @@ -92,9 +92,45 @@ function Verify-Service { function Run-MCP-Tests { Push-Location $LocalRepo try { - Log "Running mcp_client.py (stdio transport, in-VM)…" - uv run python tests\manual\vm_e2e\mcp_client.py --results $ResultsJson 2>&1 | - Tee-Object -FilePath (Join-Path $ResultsDir "mcp_client.log") + # ----- phase 1: allow_all (clicks Yes, asserts UAC dismissed) ----- + Log "Setting policy=allow_all" + uv run windows-mcp service secure-desktop set-policy allow_all 2>&1 | + Tee-Object -FilePath (Join-Path $ResultsDir "set-policy-allow_all.log") | Out-Null + Log "Running mcp_client.py --mode allow_all" + $allowJson = Join-Path $ResultsDir "results-allow_all.json" + uv run python tests\manual\vm_e2e\mcp_client.py ` + --results $allowJson --mode allow_all 2>&1 | + Tee-Object -FilePath (Join-Path $ResultsDir "mcp_client-allow_all.log") + + # ----- phase 2: block (asserts click is refused) ----- + Log "Setting policy=block" + uv run windows-mcp service secure-desktop set-policy block 2>&1 | + Tee-Object -FilePath (Join-Path $ResultsDir "set-policy-block.log") | Out-Null + Log "Running mcp_client.py --mode block" + $blockJson = Join-Path $ResultsDir "results-block.json" + uv run python tests\manual\vm_e2e\mcp_client.py ` + --results $blockJson --mode block 2>&1 | + Tee-Object -FilePath (Join-Path $ResultsDir "mcp_client-block.log") + + # ----- combined report ------------------------------------------------ + $allow = Get-Content $allowJson -Raw | ConvertFrom-Json + $block = Get-Content $blockJson -Raw | ConvertFrom-Json + $combined = [pscustomobject]@{ + started_at = $allow.started_at + finished_at = $block.finished_at + transport = $allow.transport + phases = @{ + allow_all = $allow + block = $block + } + summary = @{ + total = ($allow.summary.total + $block.summary.total) + passed = ($allow.summary.passed + $block.summary.passed) + failed = ($allow.summary.failed + $block.summary.failed) + } + } + $combined | ConvertTo-Json -Depth 8 | Set-Content -Path $ResultsJson + Log "Combined results.json written: total=$($combined.summary.total) passed=$($combined.summary.passed) failed=$($combined.summary.failed)" } finally { Pop-Location } From abbb7f4f3452a1f08ac41365940dcb2a1d206580 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 13:27:16 +0000 Subject: [PATCH 015/158] test(vm-e2e): mirror kickoff.bat as run-tests.bat (no underscores in path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same content as kickoff.bat. The reason for the duplicate name is that vncdotool's shift-minus rendered as plain dash on the Win11 VM keyboard layout — typing 'vm_e2e' came out as 'vm-e2e' and the path failed. This file is for the path-without-underscores invocation if we end up copying it to a no-underscore location. --- tests/manual/vm_e2e/run-tests.bat | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 tests/manual/vm_e2e/run-tests.bat diff --git a/tests/manual/vm_e2e/run-tests.bat b/tests/manual/vm_e2e/run-tests.bat new file mode 100644 index 00000000..3b2a100a --- /dev/null +++ b/tests/manual/vm_e2e/run-tests.bat @@ -0,0 +1,4 @@ +@echo off +REM Kickoff for the in-VM test harness. Launched via Win+R as a UNC path so +REM the typing surface is short and unambiguous: \\host.lan\Data\Windows-MCP\kickoff.bat +start "" powershell -ExecutionPolicy Bypass -File \\host.lan\Data\Windows-MCP\tests\manual\vm_e2e\run_all.ps1 From e73467a2d4cca9fd09a6976f78ae331a783af607 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 13:50:51 +0000 Subject: [PATCH 016/158] fix(vm-e2e): make bootstrap robust to MS Store python stub + subshell PATH Two failure modes seen in the first VM run: 1. `Get-Command python` returned the Microsoft Store stub (Win11 ships an App Execution Alias of that name that just opens the Store). `python --version` then printed nothing and the script continued thinking python was installed. Replaced with Test-RealPython which only counts as installed if --version actually returns a Python X.Y string. 2. `uv` install ran in a child PowerShell via -c, so any $env:Path mutations the installer made never reached the parent session. Switched to in-process Invoke-Expression of the install script, then probe a known list of install locations and prepend the right one to PATH. Both functions now throw on failure instead of silently continuing, so results.json reports the real cause. --- tests/manual/vm_e2e/results.json | 18 ++++++++++ tests/manual/vm_e2e/run_all.ps1 | 60 +++++++++++++++++++++++++++----- 2 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 tests/manual/vm_e2e/results.json diff --git a/tests/manual/vm_e2e/results.json b/tests/manual/vm_e2e/results.json new file mode 100644 index 00000000..74f1aaca --- /dev/null +++ b/tests/manual/vm_e2e/results.json @@ -0,0 +1,18 @@ +{ + "finished_at": "2026-05-14T13:49:22.9260887-07:00", + "transport": "(bootstrap-failed)", + "summary": { + "failed": 1, + "total": 1, + "passed": 0 + }, + "results": [ + { + "detail": "The term \u0027uv\u0027 is not recognized as the name of a cmdlet, function, script file, or operable program. Check the spelling of the name, or if a path was included, verify that the path is correct and try again.", + "duration_s": 0, + "name": "run_all.ps1 bootstrap", + "passed": false + } + ], + "started_at": "2026-05-14T13:49:22.9174868-07:00" +} diff --git a/tests/manual/vm_e2e/run_all.ps1 b/tests/manual/vm_e2e/run_all.ps1 index 139eb6de..6f2b0434 100644 --- a/tests/manual/vm_e2e/run_all.ps1 +++ b/tests/manual/vm_e2e/run_all.ps1 @@ -20,17 +20,46 @@ function Log($msg) { } # ----------------------------------------------------------------------------- -# 1) Bootstrap Python + uv if not already on PATH +# 1) Bootstrap Python + uv. Don't trust `Get-Command python` — Win11 ships a +# Microsoft Store *stub* of that name that opens the Store and does nothing. +# Always check for a real interpreter (or install one) before proceeding. # ----------------------------------------------------------------------------- +function Test-RealPython { + # The Store stub returns immediately with no output. A real interpreter + # prints its version. Capture stdout/stderr explicitly. + $cmd = Get-Command python -ErrorAction SilentlyContinue + if ($null -eq $cmd) { return $null } + try { + $ver = (& python --version 2>&1) | Out-String + if ($ver -match 'Python (\d+\.\d+)') { return $cmd.Source } + } catch { } + return $null +} + function Ensure-Python { - if (Get-Command python -ErrorAction SilentlyContinue) { - Log "python present: $(python --version)" + $real = Test-RealPython + if ($real) { + Log "python present: $real" return } Log "Installing Python via winget…" winget install --id Python.Python.3.13 --source winget --silent ` - --accept-source-agreements --accept-package-agreements | Out-Null - $env:Path = "$env:LOCALAPPDATA\Programs\Python\Python313\;$env:Path" + --accept-source-agreements --accept-package-agreements 2>&1 | + Out-File -FilePath (Join-Path $ResultsDir "winget-python.log") -Append + + # winget puts user-scope Python under %LOCALAPPDATA%\Programs\Python\… + foreach ($candidate in @( + "$env:LOCALAPPDATA\Programs\Python\Python313", + "$env:ProgramFiles\Python313", + "$env:LOCALAPPDATA\Programs\Python\Python312" + )) { + if (Test-Path "$candidate\python.exe") { + $env:Path = "$candidate;$candidate\Scripts;$env:Path" + Log "python after install: $candidate\python.exe" + return + } + } + throw "Python installed via winget but python.exe not found in any expected path." } function Ensure-Uv { @@ -38,9 +67,24 @@ function Ensure-Uv { Log "uv present: $(uv --version)" return } - Log "Installing uv…" - powershell -ExecutionPolicy Bypass -c "irm https://astral.sh/uv/install.ps1 | iex" - $env:Path = "$env:USERPROFILE\.local\bin;$env:Path" + Log "Installing uv (in-process, no subshell)…" + # `irm | iex` in the SAME process so any env changes the installer makes + # persist into our session. + Invoke-Expression (Invoke-RestMethod -Uri https://astral.sh/uv/install.ps1) + + # Astral's installer drops uv.exe at $env:USERPROFILE\.local\bin per docs. + foreach ($candidate in @( + "$env:USERPROFILE\.local\bin", + "$env:LOCALAPPDATA\uv\bin", + "$env:LOCALAPPDATA\Programs\uv" + )) { + if (Test-Path "$candidate\uv.exe") { + $env:Path = "$candidate;$env:Path" + Log "uv after install: $candidate\uv.exe" + return + } + } + throw "uv install ran but uv.exe was not found in any expected path." } # ----------------------------------------------------------------------------- From b0dad8447fc6e329a9744c1d95ef19e247ccc0fd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 13:51:03 +0000 Subject: [PATCH 017/158] chore(vm-e2e): gitignore per-run artifacts (results.json, *.log) --- tests/manual/vm_e2e/.gitignore | 4 ++++ tests/manual/vm_e2e/results.json | 18 ------------------ 2 files changed, 4 insertions(+), 18 deletions(-) create mode 100644 tests/manual/vm_e2e/.gitignore delete mode 100644 tests/manual/vm_e2e/results.json diff --git a/tests/manual/vm_e2e/.gitignore b/tests/manual/vm_e2e/.gitignore new file mode 100644 index 00000000..cbf3e52e --- /dev/null +++ b/tests/manual/vm_e2e/.gitignore @@ -0,0 +1,4 @@ +# Per-run artifacts written by the harness on the share +results.json +results-*.json +*.log diff --git a/tests/manual/vm_e2e/results.json b/tests/manual/vm_e2e/results.json deleted file mode 100644 index 74f1aaca..00000000 --- a/tests/manual/vm_e2e/results.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "finished_at": "2026-05-14T13:49:22.9260887-07:00", - "transport": "(bootstrap-failed)", - "summary": { - "failed": 1, - "total": 1, - "passed": 0 - }, - "results": [ - { - "detail": "The term \u0027uv\u0027 is not recognized as the name of a cmdlet, function, script file, or operable program. Check the spelling of the name, or if a path was included, verify that the path is correct and try again.", - "duration_s": 0, - "name": "run_all.ps1 bootstrap", - "passed": false - } - ], - "started_at": "2026-05-14T13:49:22.9174868-07:00" -} From a09b13ff406c5e81552530d253473079313742ef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 13:52:05 +0000 Subject: [PATCH 018/158] fix(vm-e2e): force TLS 1.2 before downloading uv install script PowerShell 5.1 (default in Win11) negotiates TLS 1.0/1.1 unless told otherwise. Astral's CDN (and most modern HTTPS endpoints) reject that and irm fails with 'Could not establish trust relationship'. Set SecurityProtocol explicitly to include Tls12 before the irm call. --- tests/manual/vm_e2e/run_all.ps1 | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/manual/vm_e2e/run_all.ps1 b/tests/manual/vm_e2e/run_all.ps1 index 6f2b0434..27bd1e14 100644 --- a/tests/manual/vm_e2e/run_all.ps1 +++ b/tests/manual/vm_e2e/run_all.ps1 @@ -68,6 +68,11 @@ function Ensure-Uv { return } Log "Installing uv (in-process, no subshell)…" + # Win11 PowerShell 5.1 defaults to TLS 1.0/1.1 for outbound HTTPS, which + # Astral's CDN rejects with "Could not establish trust relationship". Force + # TLS 1.2 before downloading the install script. + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor ` + [Net.SecurityProtocolType]::Tls11 -bor [Net.SecurityProtocolType]::Tls # `irm | iex` in the SAME process so any env changes the installer makes # persist into our session. Invoke-Expression (Invoke-RestMethod -Uri https://astral.sh/uv/install.ps1) From 315450126f3bdbcd0694fa6a80161c098f0479fd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 13:57:32 +0000 Subject: [PATCH 019/158] fix(vm-e2e): write run_all.log locally then mirror to share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saw transient 'Could not find file' from Set-Content/Add-Content against a UNC log path on Win11 — likely SMB-side caching or a quirk of the LanmanRedirector after the file was deleted host-side. Writing locally to %TEMP% and copying to the share on every line is robust and only marginally noisier. --- tests/manual/vm_e2e/run_all.ps1 | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/manual/vm_e2e/run_all.ps1 b/tests/manual/vm_e2e/run_all.ps1 index 27bd1e14..c0d9937e 100644 --- a/tests/manual/vm_e2e/run_all.ps1 +++ b/tests/manual/vm_e2e/run_all.ps1 @@ -11,12 +11,19 @@ $Repo = "\\host.lan\Data\Windows-MCP" $LocalRepo = "C:\windows-mcp" $ResultsDir = Join-Path $Repo "tests\manual\vm_e2e" $ResultsJson = Join-Path $ResultsDir "results.json" -$Log = Join-Path $ResultsDir "run_all.log" + +# Write the log LOCALLY during execution; copy to the share at the end. +# Set-Content/Add-Content directly to a UNC path is flaky on Win11 (saw +# transient FileNotFoundException on a fresh path) — local writes are not. +$LocalLog = "$env:TEMP\windows-mcp-run_all.log" +$ShareLog = Join-Path $ResultsDir "run_all.log" function Log($msg) { $ts = (Get-Date).ToString("HH:mm:ss") - Add-Content -Path $Log -Value "[$ts] $msg" + Add-Content -Path $LocalLog -Value "[$ts] $msg" Write-Host "[$ts] $msg" + # Best-effort live mirror to the share. Failure to mirror does not stop the run. + try { Copy-Item -Force $LocalLog $ShareLog -ErrorAction Stop } catch { } } # ----------------------------------------------------------------------------- @@ -191,7 +198,8 @@ function Run-MCP-Tests { if (-not (Test-Path $ResultsDir)) { New-Item -ItemType Directory -Path $ResultsDir | Out-Null } -Set-Content -Path $Log -Value "run_all.ps1 started $(Get-Date -Format o)" +Set-Content -Path $LocalLog -Value "run_all.ps1 started $(Get-Date -Format o)" +try { Copy-Item -Force $LocalLog $ShareLog -ErrorAction Stop } catch { } try { Ensure-Python From 11b2ea4e1818a71a26363654dc1bff95ac7377af Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 14:01:20 +0000 Subject: [PATCH 020/158] fix(vm-e2e): drop winget+Python; let uv install/manage its own Python winget on a fresh dockur Win11 fails with 'Data required by the source is missing' (broken first-run source bootstrap). The 'python' on PATH is the Microsoft Store stub that does nothing. Skip both. Install uv first (self-contained binary from Astral CDN, TLS 1.2 forced), then 'uv python install 3.13' downloads and pins a real CPython under uv's own cache. uv sync after that uses the managed interpreter. Zero Windows-side Python machinery. --- tests/manual/vm_e2e/run_all.ps1 | 63 +++++++++++---------------------- 1 file changed, 21 insertions(+), 42 deletions(-) diff --git a/tests/manual/vm_e2e/run_all.ps1 b/tests/manual/vm_e2e/run_all.ps1 index c0d9937e..ef8a2eb4 100644 --- a/tests/manual/vm_e2e/run_all.ps1 +++ b/tests/manual/vm_e2e/run_all.ps1 @@ -27,48 +27,14 @@ function Log($msg) { } # ----------------------------------------------------------------------------- -# 1) Bootstrap Python + uv. Don't trust `Get-Command python` — Win11 ships a -# Microsoft Store *stub* of that name that opens the Store and does nothing. -# Always check for a real interpreter (or install one) before proceeding. +# 1) Bootstrap. Skip Windows-managed Python entirely — winget on a fresh +# dockur Win11 has a broken source ("Data required by the source is +# missing") and the python on PATH is an MS Store stub. +# +# Instead, install uv (self-contained binary, ~15 MB) directly from +# Astral's CDN, then let uv install and manage its own Python via +# `uv python install 3.13`. Zero Windows-side Python machinery needed. # ----------------------------------------------------------------------------- -function Test-RealPython { - # The Store stub returns immediately with no output. A real interpreter - # prints its version. Capture stdout/stderr explicitly. - $cmd = Get-Command python -ErrorAction SilentlyContinue - if ($null -eq $cmd) { return $null } - try { - $ver = (& python --version 2>&1) | Out-String - if ($ver -match 'Python (\d+\.\d+)') { return $cmd.Source } - } catch { } - return $null -} - -function Ensure-Python { - $real = Test-RealPython - if ($real) { - Log "python present: $real" - return - } - Log "Installing Python via winget…" - winget install --id Python.Python.3.13 --source winget --silent ` - --accept-source-agreements --accept-package-agreements 2>&1 | - Out-File -FilePath (Join-Path $ResultsDir "winget-python.log") -Append - - # winget puts user-scope Python under %LOCALAPPDATA%\Programs\Python\… - foreach ($candidate in @( - "$env:LOCALAPPDATA\Programs\Python\Python313", - "$env:ProgramFiles\Python313", - "$env:LOCALAPPDATA\Programs\Python\Python312" - )) { - if (Test-Path "$candidate\python.exe") { - $env:Path = "$candidate;$candidate\Scripts;$env:Path" - Log "python after install: $candidate\python.exe" - return - } - } - throw "Python installed via winget but python.exe not found in any expected path." -} - function Ensure-Uv { if (Get-Command uv -ErrorAction SilentlyContinue) { Log "uv present: $(uv --version)" @@ -99,6 +65,18 @@ function Ensure-Uv { throw "uv install ran but uv.exe was not found in any expected path." } +function Ensure-Python { + # uv ships a Python distribution manager. `uv python install 3.13` downloads + # and pins a real CPython under uv's own cache — no Microsoft Store, no + # winget, no PATH editing in Windows. + Log "Installing Python 3.13 via uv (uv-managed)…" + & uv python install 3.13 2>&1 | Out-File -FilePath (Join-Path $ResultsDir "uv-python.log") -Append + if ($LASTEXITCODE -ne 0) { + throw "uv python install 3.13 failed (exit $LASTEXITCODE)." + } + Log "uv-managed Python ready." +} + # ----------------------------------------------------------------------------- # 2) Stage the repo locally so uv sync can write its venv on a normal drive # (uv refuses to write into UNC shares). @@ -202,8 +180,9 @@ Set-Content -Path $LocalLog -Value "run_all.ps1 started $(Get-Date -Format o)" try { Copy-Item -Force $LocalLog $ShareLog -ErrorAction Stop } catch { } try { - Ensure-Python + # uv first — Ensure-Python now uses uv to install Python. Ensure-Uv + Ensure-Python Stage-Repo Setup-Project Verify-Service From bacb51a2fa9006e841ed518ebbf1592cb9c48a5f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 14:04:01 +0000 Subject: [PATCH 021/158] fix(vm-e2e): accept all certs in test VM (sandbox MITM proxy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux sandbox running dockur intercepts outbound TLS with a custom CA the Windows VM doesn't trust. Real Win11 boxes won't see this — but inside our VM, the same fix curl needed (mount host CA bundle) isn't available, so we punt and accept all certs FOR THIS RUN_ALL.PS1 only. The script runs in a disposable test VM, and the loaded payload is the uv install script from a known-good Astral URL we already trust. Production users running this on real Windows don't hit this code path because production callers don't run run_all.ps1 — it's purely the test harness. --- tests/manual/vm_e2e/run_all.ps1 | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/manual/vm_e2e/run_all.ps1 b/tests/manual/vm_e2e/run_all.ps1 index ef8a2eb4..854263f7 100644 --- a/tests/manual/vm_e2e/run_all.ps1 +++ b/tests/manual/vm_e2e/run_all.ps1 @@ -41,11 +41,14 @@ function Ensure-Uv { return } Log "Installing uv (in-process, no subshell)…" - # Win11 PowerShell 5.1 defaults to TLS 1.0/1.1 for outbound HTTPS, which - # Astral's CDN rejects with "Could not establish trust relationship". Force - # TLS 1.2 before downloading the install script. + # Win11 PowerShell 5.1 defaults to TLS 1.0/1.1 for outbound HTTPS. Force 1.2. [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor ` [Net.SecurityProtocolType]::Tls11 -bor [Net.SecurityProtocolType]::Tls + # If the sandbox network MITMs TLS (cloud test envs commonly do), the VM + # has no way to validate the intercept CA — we'd hit "Could not establish + # trust relationship". This is a disposable test VM, so accept all certs + # for the duration of this script only. Do NOT do this in production. + [Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } # `irm | iex` in the SAME process so any env changes the installer makes # persist into our session. Invoke-Expression (Invoke-RestMethod -Uri https://astral.sh/uv/install.ps1) From 32fefcd24dc8b03b14a800246f97282737d25644 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 14:09:07 +0000 Subject: [PATCH 022/158] fix(vm-e2e): pre-stage uv + Python installer on share (proxy-free bootstrap) The disposable test VM can't trust the sandbox's MITM TLS cert, so any HTTPS download from inside Windows fails. Pre-stage uv.exe and the python.org Python 3.13 installer in the share (downloaded host-side where TLS trust is set up) and have run_all.ps1 copy them into the VM instead of downloading. bin/ is gitignored because the staged binaries are ~90 MB; they're regenerated host-side via: curl -sL -o /tmp/u.zip https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-pc-windows-msvc.zip unzip -o /tmp/u.zip -d tests/manual/vm_e2e/bin/ curl -sL -o tests/manual/vm_e2e/bin/python-install.exe https://www.python.org/ftp/python/3.13.0/python-3.13.0-amd64.exe --- tests/manual/vm_e2e/.gitignore | 4 ++ tests/manual/vm_e2e/run_all.ps1 | 79 +++++++++++++++++++-------------- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/tests/manual/vm_e2e/.gitignore b/tests/manual/vm_e2e/.gitignore index cbf3e52e..b2848874 100644 --- a/tests/manual/vm_e2e/.gitignore +++ b/tests/manual/vm_e2e/.gitignore @@ -2,3 +2,7 @@ results.json results-*.json *.log + +# Pre-staged binaries for the VM (huge, regenerated by host) +bin/ + diff --git a/tests/manual/vm_e2e/run_all.ps1 b/tests/manual/vm_e2e/run_all.ps1 index 854263f7..56a912ff 100644 --- a/tests/manual/vm_e2e/run_all.ps1 +++ b/tests/manual/vm_e2e/run_all.ps1 @@ -40,44 +40,58 @@ function Ensure-Uv { Log "uv present: $(uv --version)" return } - Log "Installing uv (in-process, no subshell)…" - # Win11 PowerShell 5.1 defaults to TLS 1.0/1.1 for outbound HTTPS. Force 1.2. - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor ` - [Net.SecurityProtocolType]::Tls11 -bor [Net.SecurityProtocolType]::Tls - # If the sandbox network MITMs TLS (cloud test envs commonly do), the VM - # has no way to validate the intercept CA — we'd hit "Could not establish - # trust relationship". This is a disposable test VM, so accept all certs - # for the duration of this script only. Do NOT do this in production. - [Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } - # `irm | iex` in the SAME process so any env changes the installer makes - # persist into our session. - Invoke-Expression (Invoke-RestMethod -Uri https://astral.sh/uv/install.ps1) + # We pre-stage uv.exe in the share at tests/manual/vm_e2e/bin/uv.exe so we + # don't depend on Windows being able to reach Astral's CDN. (In the + # disposable test VM the sandbox network MITMs TLS and the VM doesn't + # trust the intercept CA — outbound HTTPS from Windows is unreliable.) + $sharedUv = Join-Path $Repo "tests\manual\vm_e2e\bin\uv.exe" + $dest = "$env:USERPROFILE\.local\bin" + if (-not (Test-Path $sharedUv)) { + throw "Expected pre-staged uv.exe at $sharedUv but it was missing. Re-stage from the host: curl -sL -o /tmp/u.zip https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-pc-windows-msvc.zip && unzip -o /tmp/u.zip -d /tests/manual/vm_e2e/bin/" + } + Log "Copying pre-staged uv.exe from share to $dest…" + New-Item -ItemType Directory -Force -Path $dest | Out-Null + Copy-Item -Force $sharedUv "$dest\uv.exe" + $env:Path = "$dest;$env:Path" + Log "uv ready: $(& uv --version)" +} - # Astral's installer drops uv.exe at $env:USERPROFILE\.local\bin per docs. +function Ensure-Python { + # Detect a real (non-Store-stub) Python 3.13 if already installed. foreach ($candidate in @( - "$env:USERPROFILE\.local\bin", - "$env:LOCALAPPDATA\uv\bin", - "$env:LOCALAPPDATA\Programs\uv" + "$env:LOCALAPPDATA\Programs\Python\Python313\python.exe", + "$env:ProgramFiles\Python313\python.exe" )) { - if (Test-Path "$candidate\uv.exe") { - $env:Path = "$candidate;$env:Path" - Log "uv after install: $candidate\uv.exe" - return + if (Test-Path $candidate) { + $verOut = & $candidate --version 2>&1 | Out-String + if ($verOut -match 'Python 3\.13') { + $env:Path = "$([System.IO.Path]::GetDirectoryName($candidate));$env:Path" + Log "python already installed: $candidate ($($verOut.Trim()))" + return + } } } - throw "uv install ran but uv.exe was not found in any expected path." -} - -function Ensure-Python { - # uv ships a Python distribution manager. `uv python install 3.13` downloads - # and pins a real CPython under uv's own cache — no Microsoft Store, no - # winget, no PATH editing in Windows. - Log "Installing Python 3.13 via uv (uv-managed)…" - & uv python install 3.13 2>&1 | Out-File -FilePath (Join-Path $ResultsDir "uv-python.log") -Append + # Install via the pre-staged python.org installer (avoids winget + outbound HTTPS). + $stagedInstaller = Join-Path $Repo "tests\manual\vm_e2e\bin\python-install.exe" + if (-not (Test-Path $stagedInstaller)) { + throw "Expected pre-staged Python installer at $stagedInstaller but it was missing." + } + Log "Running pre-staged Python installer (quiet, per-user, add to PATH)…" + & $stagedInstaller /quiet InstallAllUsers=0 PrependPath=1 Include_test=0 Include_pip=1 | Out-Null if ($LASTEXITCODE -ne 0) { - throw "uv python install 3.13 failed (exit $LASTEXITCODE)." + throw "Python installer exited with $LASTEXITCODE" + } + foreach ($candidate in @( + "$env:LOCALAPPDATA\Programs\Python\Python313\python.exe", + "$env:ProgramFiles\Python313\python.exe" + )) { + if (Test-Path $candidate) { + $env:Path = "$([System.IO.Path]::GetDirectoryName($candidate));$env:Path" + Log "python installed: $candidate" + return + } } - Log "uv-managed Python ready." + throw "Python installer ran (exit 0) but python.exe not found in expected paths." } # ----------------------------------------------------------------------------- @@ -183,9 +197,8 @@ Set-Content -Path $LocalLog -Value "run_all.ps1 started $(Get-Date -Format o)" try { Copy-Item -Force $LocalLog $ShareLog -ErrorAction Stop } catch { } try { - # uv first — Ensure-Python now uses uv to install Python. - Ensure-Uv Ensure-Python + Ensure-Uv Stage-Repo Setup-Project Verify-Service From 41b973512980919c64a93ba9b2fcc7bfc024cfeb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 14:24:20 +0000 Subject: [PATCH 023/158] fix(vm-e2e): treat stderr from native commands as info, not error $ErrorActionPreference=Stop causes PowerShell to throw on the first stderr line from a native exe, even when the exit code is 0 (uv prints its python-detection info to stderr). Added Invoke-Native helper that locally switches ErrorActionPreference, captures stdout+stderr to a log file, mirrors to the share, and only throws on non-zero exit code. --- tests/manual/vm_e2e/run_all.ps1 | 54 ++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/tests/manual/vm_e2e/run_all.ps1 b/tests/manual/vm_e2e/run_all.ps1 index 56a912ff..fda08fc2 100644 --- a/tests/manual/vm_e2e/run_all.ps1 +++ b/tests/manual/vm_e2e/run_all.ps1 @@ -26,6 +26,27 @@ function Log($msg) { try { Copy-Item -Force $LocalLog $ShareLog -ErrorAction Stop } catch { } } +# Run a native command with stderr merged into stdout and Tee'd to a log, +# locally suppressing PowerShell's "stderr lines = error" treatment. Throws +# only on non-zero exit code, not on stderr noise. +function Invoke-Native { + param([string]$LogName, [scriptblock]$Block) + $localLog = "$env:TEMP\$LogName" + $shareLog = Join-Path $ResultsDir $LogName + $prev = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + & $Block 2>&1 | Tee-Object -FilePath $localLog + $rc = $LASTEXITCODE + } finally { + $ErrorActionPreference = $prev + } + try { Copy-Item -Force $localLog $shareLog -ErrorAction Stop } catch { } + if ($rc -ne 0) { + throw "Native command in $LogName exited with $rc (see $shareLog)" + } +} + # ----------------------------------------------------------------------------- # 1) Bootstrap. Skip Windows-managed Python entirely — winget on a fresh # dockur Win11 has a broken source ("Data required by the source is @@ -116,11 +137,12 @@ function Setup-Project { Push-Location $LocalRepo try { Log "uv sync" - uv sync 2>&1 | Tee-Object -FilePath (Join-Path $ResultsDir "uv_sync.log") + Invoke-Native "uv_sync.log" { & uv sync } Log "Installing the host service (allow-user-binary-path because this is a VM)…" - uv run windows-mcp service secure-desktop install ` - --policy allow_all --allow-user-binary-path --force 2>&1 | - Tee-Object -FilePath (Join-Path $ResultsDir "install.log") + Invoke-Native "install.log" { + & uv run windows-mcp service secure-desktop install ` + --policy allow_all --allow-user-binary-path --force + } } finally { Pop-Location } @@ -145,23 +167,27 @@ function Run-MCP-Tests { try { # ----- phase 1: allow_all (clicks Yes, asserts UAC dismissed) ----- Log "Setting policy=allow_all" - uv run windows-mcp service secure-desktop set-policy allow_all 2>&1 | - Tee-Object -FilePath (Join-Path $ResultsDir "set-policy-allow_all.log") | Out-Null + Invoke-Native "set-policy-allow_all.log" { + & uv run windows-mcp service secure-desktop set-policy allow_all + } Log "Running mcp_client.py --mode allow_all" $allowJson = Join-Path $ResultsDir "results-allow_all.json" - uv run python tests\manual\vm_e2e\mcp_client.py ` - --results $allowJson --mode allow_all 2>&1 | - Tee-Object -FilePath (Join-Path $ResultsDir "mcp_client-allow_all.log") + Invoke-Native "mcp_client-allow_all.log" { + & uv run python tests\manual\vm_e2e\mcp_client.py ` + --results $allowJson --mode allow_all + } # ----- phase 2: block (asserts click is refused) ----- Log "Setting policy=block" - uv run windows-mcp service secure-desktop set-policy block 2>&1 | - Tee-Object -FilePath (Join-Path $ResultsDir "set-policy-block.log") | Out-Null + Invoke-Native "set-policy-block.log" { + & uv run windows-mcp service secure-desktop set-policy block + } Log "Running mcp_client.py --mode block" $blockJson = Join-Path $ResultsDir "results-block.json" - uv run python tests\manual\vm_e2e\mcp_client.py ` - --results $blockJson --mode block 2>&1 | - Tee-Object -FilePath (Join-Path $ResultsDir "mcp_client-block.log") + Invoke-Native "mcp_client-block.log" { + & uv run python tests\manual\vm_e2e\mcp_client.py ` + --results $blockJson --mode block + } # ----- combined report ------------------------------------------------ $allow = Get-Content $allowJson -Raw | ConvertFrom-Json From 7bc9adafccea339c94481d01972416655978db63 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 14:35:37 +0000 Subject: [PATCH 024/158] fix(vm-e2e): UV_INSECURE_HOST to bypass MITM proxy for uv sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uv (rustls) hit 'invalid peer certificate: UnknownIssuer' against files.pythonhosted.org because the sandbox proxy intercepts TLS with a CA the VM doesn't trust. Same disposable-VM situation as the .NET cert bypass earlier — set UV_INSECURE_HOST for the needed hosts only. --- tests/manual/vm_e2e/run_all.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/manual/vm_e2e/run_all.ps1 b/tests/manual/vm_e2e/run_all.ps1 index fda08fc2..177a944c 100644 --- a/tests/manual/vm_e2e/run_all.ps1 +++ b/tests/manual/vm_e2e/run_all.ps1 @@ -136,7 +136,10 @@ function Stage-Repo { function Setup-Project { Push-Location $LocalRepo try { - Log "uv sync" + # Sandbox MITM proxy: tell uv to skip cert verification for PyPI hosts. + # Same disposable-VM caveat as the .NET cert bypass earlier. + $env:UV_INSECURE_HOST = "pypi.org files.pythonhosted.org github.com astral.sh objects.githubusercontent.com" + Log "uv sync (UV_INSECURE_HOST set for MITM proxy)" Invoke-Native "uv_sync.log" { & uv sync } Log "Installing the host service (allow-user-binary-path because this is a VM)…" Invoke-Native "install.log" { From 8b882fa75ce02575c6ed54729648e741d0cce3a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 14:48:47 +0000 Subject: [PATCH 025/158] fix(cli): replace Unicode arrows/em-dashes with ASCII in click.echo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows' default cp1252 codec can't encode '\u2192' (right arrow) or '\u2014' (em-dash) when stdout/stderr is piped to a non-tty. The VM test harness pipes the output to Tee-Object → uv sync → cp1252 encode → UnicodeEncodeError → exit 1. Replaced with -> and -- across the CLI for piping safety. Visible output is unchanged on TTYs; pipes no longer crash. --- src/windows_mcp/__main__.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/windows_mcp/__main__.py b/src/windows_mcp/__main__.py index 7ea40f58..e4c5aabf 100755 --- a/src/windows_mcp/__main__.py +++ b/src/windows_mcp/__main__.py @@ -489,7 +489,7 @@ def _gen_tls(host: str, cert_path, key_path) -> None: mkcert = subprocess.run(["where", "mkcert"], capture_output=True).returncode == 0 if mkcert: - click.echo("mkcert detected — generating a locally-trusted certificate...") + click.echo("mkcert detected -- generating a locally-trusted certificate...") install = subprocess.run(["mkcert", "-install"], capture_output=True, text=True) if install.returncode != 0: raise click.ClickException(f"mkcert -install failed:\n{install.stderr.strip()}") @@ -508,7 +508,7 @@ def _gen_tls(host: str, cert_path, key_path) -> None: raise click.ClickException(f"mkcert failed:\n{result.stderr.strip()}") click.echo(" Certificate is automatically trusted by Windows.") else: - click.echo("mkcert not found — falling back to openssl (self-signed)...") + click.echo("mkcert not found -- falling back to openssl (self-signed)...") click.echo(" Tip: winget install FiloSottile.mkcert for auto-trusted certs next time.") result = subprocess.run( [ @@ -525,8 +525,8 @@ def _gen_tls(host: str, cert_path, key_path) -> None: click.echo(" To make Windows trust this cert, run in an elevated PowerShell:") click.echo(f' Import-Certificate -FilePath "{cert_path}" -CertStoreLocation Cert:\\LocalMachine\\Root') - click.echo(f" cert → {cert_path}") - click.echo(f" key → {key_path}") + click.echo(f" cert -> {cert_path}") + click.echo(f" key -> {key_path}") _TASK_NAME = "windows-mcp-server" @@ -610,7 +610,7 @@ def install(transport: str, host: str, port: int, force: bool) -> None: if run_result.returncode != 0: raise click.ClickException(f"schtasks /Run failed:\n{run_result.stderr.strip() or run_result.stdout.strip()}") - click.echo("Scheduled task installed — server is starting now.") + click.echo("Scheduled task installed -- server is starting now.") click.echo(f" Task : {_TASK_NAME}") click.echo(f" Transport : {transport}") click.echo(f" Address : {host}:{port}") @@ -775,7 +775,7 @@ def _admin_only_prefixes() -> list[str]: def _path_is_admin_only(path: str) -> bool: """Return True if *path* lives under a default admin-only prefix. - This is a *heuristic*, not a permission check — but it covers 99% of + This is a *heuristic*, not a permission check -- but it covers 99% of real installs. Users on truly custom layouts can override with --allow-user-binary-path. """ @@ -863,7 +863,7 @@ def service_secure_desktop(): so the MCP broker can capture screenshots and route input across the Winlogon (Secure Desktop) boundary that fires during UAC consent prompts. - UAC remains fully enabled — the service does NOT weaken the Secure Desktop + UAC remains fully enabled -- the service does NOT weaken the Secure Desktop policy. Whether the broker may auto-click a UAC prompt is governed by the ``WINDOWS_MCP_SECURE_DESKTOP_POLICY`` env var (``block`` by default). @@ -900,7 +900,7 @@ def service_secure_desktop(): default=False, help=( "Allow installing even if Python or windows_mcp live in a user-writable " - "location. Unsafe outside a disposable VM — any local process running as " + "location. Unsafe outside a disposable VM -- any local process running as " "the user can replace the binary and gain SYSTEM at next service start." ), ) @@ -987,7 +987,7 @@ def service_secure_desktop_install( None, # load order group 0, # tag id None, # dependencies - None, # service account → LocalSystem + None, # service account -> LocalSystem None, # password ) win32service.ChangeServiceConfig2( @@ -1042,7 +1042,7 @@ def service_secure_desktop_uninstall(): win32serviceutil.StopService(_SERVICE_NAME) click.echo(f"Service '{_SERVICE_NAME}' stopped.") except pywintypes.error: - pass # Not running — that's fine + pass # Not running -- that's fine try: win32serviceutil.RemoveService(_SERVICE_NAME) @@ -1085,7 +1085,7 @@ def service_secure_desktop_set_policy(policy_name: str, allow_publisher: tuple[s ) except Exception as exc: raise click.ClickException(f"Failed to write policy: {exc}") - click.echo(f"Policy updated → {policy_name}") + click.echo(f"Policy updated -> {policy_name}") if allowlist: click.echo(f" publishers allowlist: {allowlist}") @@ -1141,7 +1141,7 @@ def service_secure_desktop_status(): else: click.echo("Pipe : not reachable (service may still be starting)") except Exception as exc: - click.echo(f"Pipe : error — {exc}") + click.echo(f"Pipe : error -- {exc}") except pywintypes.error: click.echo(f"Service '{_SERVICE_NAME}' is not installed.") From 2a7a068a1bcbbb4fad4dc096ef788b3033eb3508 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 14:55:46 +0000 Subject: [PATCH 026/158] fix(vm-e2e): stop+delete prior service before re-staging the repo Previous run leaves WindowsMCPHost service running. Its python.exe is locked, so Remove-Item -SilentlyContinue skips the .venv contents, leaving a stale venv that confuses uv on the next run ('failed to locate pyvenv.cfg'). Stop and delete the service first. --- tests/manual/vm_e2e/run_all.ps1 | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/manual/vm_e2e/run_all.ps1 b/tests/manual/vm_e2e/run_all.ps1 index 177a944c..d184b6b0 100644 --- a/tests/manual/vm_e2e/run_all.ps1 +++ b/tests/manual/vm_e2e/run_all.ps1 @@ -120,6 +120,16 @@ function Ensure-Python { # (uv refuses to write into UNC shares). # ----------------------------------------------------------------------------- function Stage-Repo { + # If a previous run left the service installed and running, its + # python.exe is locked by the service process — Remove-Item would skip + # those files, leaving a stale .venv that uv mistakes for a fresh one. + # Stop and remove the service first so we can wipe cleanly. + if (Get-Service WindowsMCPHost -ErrorAction SilentlyContinue) { + Log "Stopping/removing prior WindowsMCPHost service before re-staging…" + try { Stop-Service WindowsMCPHost -Force -ErrorAction SilentlyContinue } catch { } + try { sc.exe delete WindowsMCPHost | Out-Null } catch { } + Start-Sleep -Seconds 2 # let SCM finish + file handles release + } if (Test-Path $LocalRepo) { Log "Refreshing $LocalRepo" Remove-Item -Recurse -Force "$LocalRepo\*" -ErrorAction SilentlyContinue From a73699b6ad9d4ebb5ce95f3c6ad2e7357bd1e340 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 15:08:53 +0000 Subject: [PATCH 027/158] fix(vm-e2e): enable UAC + reboot if dockur disabled it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dockur's autounattend sets EnableLUA=false, which kills UAC entirely — Start-Process -Verb RunAs auto-elevates with no prompt and nothing fires on the Secure Desktop. The whole secure-desktop story can't be tested in that state. run_all.ps1 now detects EnableLUA != 1 and: 1. Sets EnableLUA=1 + ConsentPromptBehaviorAdmin=5 (default) 2. Registers an ONLOGON scheduled task to re-run itself 3. shutdown /r — auto-login resumes the test with UAC live 4. On the resumed run, deletes the task so it doesn't re-fire --- tests/manual/vm_e2e/run_all.ps1 | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/manual/vm_e2e/run_all.ps1 b/tests/manual/vm_e2e/run_all.ps1 index d184b6b0..14a7f4fc 100644 --- a/tests/manual/vm_e2e/run_all.ps1 +++ b/tests/manual/vm_e2e/run_all.ps1 @@ -236,6 +236,30 @@ Set-Content -Path $LocalLog -Value "run_all.ps1 started $(Get-Date -Format o)" try { Copy-Item -Force $LocalLog $ShareLog -ErrorAction Stop } catch { } try { + # Pre-flight: dockur's autounattend disables UAC entirely + # (EnableLUA=false). The whole point of this test is UAC handling, so we + # need it on. If it's off, turn it on, schedule run_all.ps1 to fire on + # next login, and reboot. The next boot's auto-login + scheduled task + # will resume here with UAC active. + $luaKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" + $lua = (Get-ItemProperty -Path $luaKey -Name EnableLUA -ErrorAction SilentlyContinue).EnableLUA + if ($lua -ne 1) { + Log "EnableLUA=$lua. Enabling UAC, scheduling re-run on next login, and rebooting…" + Set-ItemProperty -Path $luaKey -Name EnableLUA -Type DWord -Value 1 + Set-ItemProperty -Path $luaKey -Name ConsentPromptBehaviorAdmin -Type DWord -Value 5 + $task = "windows-mcp-test-resume" + schtasks.exe /Delete /TN $task /F 2>$null | Out-Null + $tr = "powershell.exe -ExecutionPolicy Bypass -File \\host.lan\Data\Windows-MCP\tests\manual\vm_e2e\run_all.ps1" + schtasks.exe /Create /TN $task /SC ONLOGON /RL HIGHEST /RU Docker /TR "$tr" /F | Out-Null + Log "Scheduled task $task. Rebooting in 5s…" + Start-Sleep -Seconds 2 + shutdown.exe /r /t 5 /c "Enabling UAC for windows-mcp test" + exit 0 + } + # If we just resumed via scheduled task, remove the task so future logins + # don't re-trigger the harness. + schtasks.exe /Delete /TN windows-mcp-test-resume /F 2>$null | Out-Null + Ensure-Python Ensure-Uv Stage-Repo From d0deb3acfdbbd06b1e39031bbd523d430fd751fd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 15:11:14 +0000 Subject: [PATCH 028/158] fix(vm-e2e): swallow schtasks stderr via cmd /c (avoid PS error promotion) `schtasks /Delete /TN ` writes 'cannot find file' to stderr when the task doesn't exist. PowerShell with ErrorActionPreference=Stop treats that as a fatal exception. Wrapping in cmd.exe /c with stderr redirected to nul gives us idempotent delete-if-present without the exception. --- tests/manual/vm_e2e/run_all.ps1 | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/manual/vm_e2e/run_all.ps1 b/tests/manual/vm_e2e/run_all.ps1 index 14a7f4fc..928f86c2 100644 --- a/tests/manual/vm_e2e/run_all.ps1 +++ b/tests/manual/vm_e2e/run_all.ps1 @@ -248,9 +248,11 @@ try { Set-ItemProperty -Path $luaKey -Name EnableLUA -Type DWord -Value 1 Set-ItemProperty -Path $luaKey -Name ConsentPromptBehaviorAdmin -Type DWord -Value 5 $task = "windows-mcp-test-resume" - schtasks.exe /Delete /TN $task /F 2>$null | Out-Null + # Use cmd to swallow schtasks's stderr-on-not-found that would otherwise + # be promoted to a fatal error by $ErrorActionPreference=Stop. + cmd.exe /c "schtasks.exe /Delete /TN $task /F >nul 2>&1" | Out-Null $tr = "powershell.exe -ExecutionPolicy Bypass -File \\host.lan\Data\Windows-MCP\tests\manual\vm_e2e\run_all.ps1" - schtasks.exe /Create /TN $task /SC ONLOGON /RL HIGHEST /RU Docker /TR "$tr" /F | Out-Null + cmd.exe /c "schtasks.exe /Create /TN $task /SC ONLOGON /RL HIGHEST /RU Docker /TR `"$tr`" /F" | Out-Null Log "Scheduled task $task. Rebooting in 5s…" Start-Sleep -Seconds 2 shutdown.exe /r /t 5 /c "Enabling UAC for windows-mcp test" @@ -258,7 +260,7 @@ try { } # If we just resumed via scheduled task, remove the task so future logins # don't re-trigger the harness. - schtasks.exe /Delete /TN windows-mcp-test-resume /F 2>$null | Out-Null + cmd.exe /c "schtasks.exe /Delete /TN windows-mcp-test-resume /F >nul 2>&1" | Out-Null Ensure-Python Ensure-Uv From 55ea7fab90b75b88efbebc75597f0bc2bd309de9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 15:24:49 +0000 Subject: [PATCH 029/158] test(vm-e2e): include MCP tool error/reason in WaitForUACPrompt assertion detail Previous detail only showed fired=False, hiding whether it was a timeout, a service-not-available, or a host-call error. Surface ok/fired/reason/error fields explicitly. --- tests/manual/vm_e2e/mcp_client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/manual/vm_e2e/mcp_client.py b/tests/manual/vm_e2e/mcp_client.py index fa0eb835..7cbe7d7d 100644 --- a/tests/manual/vm_e2e/mcp_client.py +++ b/tests/manual/vm_e2e/mcp_client.py @@ -163,8 +163,10 @@ async def assert_wait_for_uac_returns_dialog( tree = payload.get("tree") or [] ok = fired and bool(tree) detail = ( - f"fired={fired} top_windows={len(tree)} " - f"publisher={payload.get('publisher')!r} policy={payload.get('policy')}" + f"ok={payload.get('ok')!r} fired={payload.get('fired')!r} " + f"reason={payload.get('reason')!r} error={payload.get('error')!r} " + f"top_windows={len(tree)} publisher={payload.get('publisher')!r} " + f"policy={payload.get('policy')}" ) _record(report, "WaitForUACPrompt returns dialog", ok, detail, t0) From 0101de5e667a971d2064cdf0c25acd659bc745c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 15:45:53 +0000 Subject: [PATCH 030/158] fix(service): retry pipe Open on transient ERROR_FILE_NOT_FOUND MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed in the VM run: ping (is_available) succeeded, then the immediately-following wait_for_uac_prompt _call() failed with WaitNamedPipe ERROR_FILE_NOT_FOUND. The host service spends ~20-50 ms between accepting one connection and creating the next pipe instance; back-to-back broker calls land inside that window and Windows returns 'file not found' (not 'busy/timeout') when no instance is in WAITING_FOR_CONNECT state. Wrap WaitNamedPipe + CreateFile in _open_with_retry that retries on errors 2 (FILE_NOT_FOUND) and 231 (PIPE_BUSY) for up to 30 attempts × 100 ms = 3 s before giving up. --- src/windows_mcp/service/pipe.py | 51 ++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/src/windows_mcp/service/pipe.py b/src/windows_mcp/service/pipe.py index b88f7b77..85fdba10 100644 --- a/src/windows_mcp/service/pipe.py +++ b/src/windows_mcp/service/pipe.py @@ -122,22 +122,7 @@ def _call(self, method: str, params: dict[str, Any]) -> Any: raise RuntimeError("pywin32 is not available") req = Request(method=method, params=params) - - try: - # Block until the pipe is available (or timeout). - win32pipe.WaitNamedPipe(PIPE_NAME, CALL_TIMEOUT_MS) - - handle = win32file.CreateFile( - PIPE_NAME, - win32file.GENERIC_READ | win32file.GENERIC_WRITE, - 0, - None, - win32file.OPEN_EXISTING, - 0, - None, - ) - except pywintypes.error as exc: - raise RuntimeError(f"Cannot connect to host service pipe: {exc}") from exc + handle = self._open_with_retry() try: # Switch to message read mode so we get whole messages back. @@ -162,6 +147,40 @@ def _call(self, method: str, params: dict[str, Any]) -> Any: raise RuntimeError(f"Host service error ({method}): {resp.error}") return resp.result + def _open_with_retry(self, *, attempts: int = 30, gap_ms: int = 100) -> Any: + """Open the named pipe, retrying on the brief race window where the + server has connected one instance but not yet recreated the next. + + WaitNamedPipe returns ERROR_FILE_NOT_FOUND (not ERROR_SEM_TIMEOUT) + when no instance of the pipe is currently in WAITING_FOR_CONNECT + state. The host service spends ~20-50 ms between accepting one + connection and creating the next instance; back-to-back broker + calls (e.g. is_available() ping immediately followed by an actual + operation) often land inside that window. Retry with a short gap. + """ + last_exc: Any = None + for _ in range(attempts): + try: + win32pipe.WaitNamedPipe(PIPE_NAME, CALL_TIMEOUT_MS) + return win32file.CreateFile( + PIPE_NAME, + win32file.GENERIC_READ | win32file.GENERIC_WRITE, + 0, + None, + win32file.OPEN_EXISTING, + 0, + None, + ) + except pywintypes.error as exc: + last_exc = exc + if exc.winerror in (2, 231): # FILE_NOT_FOUND, PIPE_BUSY + time.sleep(gap_ms / 1000.0) + continue + raise RuntimeError(f"Cannot connect to host service pipe: {exc}") from exc + raise RuntimeError( + f"Cannot connect to host service pipe after {attempts} retries: {last_exc}" + ) + _client: HostServiceClient | None = None From bbf1bd271b2e6be68472c8b74cee625693a284bf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 15:59:21 +0000 Subject: [PATCH 031/158] fix(vm-e2e): fire UAC trigger via schtasks /IT to escape elevation inheritance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_all.ps1 runs in an elevated cmd (Docker is admin). Subprocess inheritance means our Python test process is also elevated; from elevated, `Start-Process -Verb RunAs` auto-elevates with no UAC prompt. So UAC never fires and WaitForUACPrompt times out — the exact failure we just saw. Re-routing the trigger through a one-shot scheduled task with /IT gets the user's standard (non-elevated) token. The non-elevated child PowerShell then fires Start-Process -Verb RunAs, which DOES prompt UAC because it's elevating from medium integrity. --- tests/manual/vm_e2e/mcp_client.py | 53 ++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/tests/manual/vm_e2e/mcp_client.py b/tests/manual/vm_e2e/mcp_client.py index 7cbe7d7d..48b31a10 100644 --- a/tests/manual/vm_e2e/mcp_client.py +++ b/tests/manual/vm_e2e/mcp_client.py @@ -106,13 +106,56 @@ def _find_named_invokable(tree: list[dict], name: str) -> dict | None: def _trigger_uac() -> subprocess.Popen: - """Fire a real UAC prompt asynchronously. Returns the Popen handle.""" - return subprocess.Popen( + """Fire a real UAC prompt asynchronously. Returns the Popen handle. + + Subtlety: if this Python process is itself elevated (which it usually is + inside the test harness, because run_all.ps1 runs in an elevated cmd), + `Start-Process -Verb RunAs` from a child process AUTO-ELEVATES with no + UAC prompt — defeating the entire point of the test. + + To force UAC to actually fire, spin up the trigger via a scheduled task + that runs as the Docker user *without* /RL HIGHEST. schtasks runs the + task with the user's standard (non-elevated) token, so Start-Process + -Verb RunAs from that child *does* trip UAC. + """ + task_name = "wmcp-test-trigger" + trigger_ps = ( + "Start-Sleep -Milliseconds 1500; " + "Start-Process -FilePath cmd.exe -Verb RunAs -WindowStyle Hidden" + ) + # Wrap the script so cmd's quoting survives schtasks's parser. + tr = ( + "powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass " + f"-Command \"{trigger_ps}\"" + ) + # Best-effort cleanup of any prior task. + subprocess.run( + ["schtasks.exe", "/Delete", "/TN", task_name, "/F"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + # Register a one-shot task that runs as the interactive Docker user + # *without* highest privileges (i.e. the standard user split token). + create = subprocess.run( [ - "powershell.exe", "-NoLogo", "-NoProfile", "-Command", - "Start-Sleep -Milliseconds 1500; " - "Start-Process -FilePath cmd.exe -Verb RunAs -WindowStyle Hidden", + "schtasks.exe", "/Create", + "/TN", task_name, + "/SC", "ONCE", "/ST", "23:59", + "/RU", "Docker", + "/TR", tr, + "/IT", # interactive — get the user's standard token, not the elevated one + "/F", ], + capture_output=True, text=True, + ) + if create.returncode != 0: + raise RuntimeError( + f"failed to register UAC trigger task: {create.returncode} " + f"{create.stdout} {create.stderr}" + ) + # Fire the task now. Task runs in the background as Docker (non-elevated). + return subprocess.Popen( + ["schtasks.exe", "/Run", "/TN", task_name], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) From dd39d7db4377315d69dfffa34cfc4665c75ee853 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 16:12:56 +0000 Subject: [PATCH 032/158] fix(vm-e2e): switch UAC trigger to runas /trustlevel for non-elevated context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit schtasks /IT didn't reliably get a non-elevated token in our run. runas /trustlevel:0x20000 is the documented way to spawn a child with the user's standard (basic) token, no password required — just the same user with admin stripped. From medium integrity, Start-Process -Verb RunAs trips UAC and consent.exe shows on the Secure Desktop, which is what the test needs. --- tests/manual/vm_e2e/mcp_client.py | 52 +++++++++---------------------- 1 file changed, 14 insertions(+), 38 deletions(-) diff --git a/tests/manual/vm_e2e/mcp_client.py b/tests/manual/vm_e2e/mcp_client.py index 48b31a10..3feed129 100644 --- a/tests/manual/vm_e2e/mcp_client.py +++ b/tests/manual/vm_e2e/mcp_client.py @@ -113,50 +113,26 @@ def _trigger_uac() -> subprocess.Popen: `Start-Process -Verb RunAs` from a child process AUTO-ELEVATES with no UAC prompt — defeating the entire point of the test. - To force UAC to actually fire, spin up the trigger via a scheduled task - that runs as the Docker user *without* /RL HIGHEST. schtasks runs the - task with the user's standard (non-elevated) token, so Start-Process - -Verb RunAs from that child *does* trip UAC. + Use `runas /trustlevel:0x20000` to spawn the trigger powershell with + the user's *basic* (non-elevated) token. trustlevel 0x20000 is the + "Basic User" / standard-user trust level, which doesn't require a + password — it's just the same user with admin privileges stripped. + From that medium-integrity child, Start-Process -Verb RunAs trips UAC + properly and consent.exe shows on the Secure Desktop. """ - task_name = "wmcp-test-trigger" - trigger_ps = ( + inner = ( "Start-Sleep -Milliseconds 1500; " "Start-Process -FilePath cmd.exe -Verb RunAs -WindowStyle Hidden" ) - # Wrap the script so cmd's quoting survives schtasks's parser. - tr = ( + # runas /trustlevel:0x20000 expects the program path (not a quoted arg + # string), but with a /trustlevel that's the only way to fork a fresh + # token without password prompting. + cmd = ( + "runas /trustlevel:0x20000 \"" "powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass " - f"-Command \"{trigger_ps}\"" - ) - # Best-effort cleanup of any prior task. - subprocess.run( - ["schtasks.exe", "/Delete", "/TN", task_name, "/F"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - ) - # Register a one-shot task that runs as the interactive Docker user - # *without* highest privileges (i.e. the standard user split token). - create = subprocess.run( - [ - "schtasks.exe", "/Create", - "/TN", task_name, - "/SC", "ONCE", "/ST", "23:59", - "/RU", "Docker", - "/TR", tr, - "/IT", # interactive — get the user's standard token, not the elevated one - "/F", - ], - capture_output=True, text=True, - ) - if create.returncode != 0: - raise RuntimeError( - f"failed to register UAC trigger task: {create.returncode} " - f"{create.stdout} {create.stderr}" - ) - # Fire the task now. Task runs in the background as Docker (non-elevated). - return subprocess.Popen( - ["schtasks.exe", "/Run", "/TN", task_name], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + f"-Command \\\"{inner}\\\"\"" ) + return subprocess.Popen(["cmd.exe", "/c", cmd]) def _record(report: Report, name: str, ok: bool, detail: str, t0: float) -> None: From 15c7cbab096919a2bf4a5ef322ded5ed3436e2b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 16:23:11 +0000 Subject: [PATCH 033/158] fix(vm-e2e): launch mcp_client.py at medium integrity via runas /trustlevel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole point of the test is to verify the agent (broker, medium integrity) can drive UAC. Running the broker elevated invalidates the test. run_all.ps1 now wraps the mcp_client.py invocation in runas /trustlevel:0x20000 — same user, admin token stripped, no password. From medium-integrity, the trigger Start-Process -Verb RunAs trips UAC properly. Trigger reverts to the simpler form. --- tests/manual/vm_e2e/mcp_client.py | 33 ++++++++++--------------------- tests/manual/vm_e2e/run_all.ps1 | 18 +++++++++++------ 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/tests/manual/vm_e2e/mcp_client.py b/tests/manual/vm_e2e/mcp_client.py index 3feed129..8c862be7 100644 --- a/tests/manual/vm_e2e/mcp_client.py +++ b/tests/manual/vm_e2e/mcp_client.py @@ -108,31 +108,18 @@ def _find_named_invokable(tree: list[dict], name: str) -> dict | None: def _trigger_uac() -> subprocess.Popen: """Fire a real UAC prompt asynchronously. Returns the Popen handle. - Subtlety: if this Python process is itself elevated (which it usually is - inside the test harness, because run_all.ps1 runs in an elevated cmd), - `Start-Process -Verb RunAs` from a child process AUTO-ELEVATES with no - UAC prompt — defeating the entire point of the test. - - Use `runas /trustlevel:0x20000` to spawn the trigger powershell with - the user's *basic* (non-elevated) token. trustlevel 0x20000 is the - "Basic User" / standard-user trust level, which doesn't require a - password — it's just the same user with admin privileges stripped. - From that medium-integrity child, Start-Process -Verb RunAs trips UAC - properly and consent.exe shows on the Secure Desktop. + Assumes this Python process is medium-integrity (run_all.ps1 launches + mcp_client.py via `runas /trustlevel:0x20000` for that reason). From + medium-integrity, Start-Process -Verb RunAs trips UAC and consent.exe + fires on the Secure Desktop. """ - inner = ( - "Start-Sleep -Milliseconds 1500; " - "Start-Process -FilePath cmd.exe -Verb RunAs -WindowStyle Hidden" + return subprocess.Popen( + [ + "powershell.exe", "-NoLogo", "-NoProfile", "-Command", + "Start-Sleep -Milliseconds 1500; " + "Start-Process -FilePath cmd.exe -Verb RunAs -WindowStyle Hidden", + ], ) - # runas /trustlevel:0x20000 expects the program path (not a quoted arg - # string), but with a /trustlevel that's the only way to fork a fresh - # token without password prompting. - cmd = ( - "runas /trustlevel:0x20000 \"" - "powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass " - f"-Command \\\"{inner}\\\"\"" - ) - return subprocess.Popen(["cmd.exe", "/c", cmd]) def _record(report: Report, name: str, ok: bool, detail: str, t0: float) -> None: diff --git a/tests/manual/vm_e2e/run_all.ps1 b/tests/manual/vm_e2e/run_all.ps1 index 928f86c2..3007ba8a 100644 --- a/tests/manual/vm_e2e/run_all.ps1 +++ b/tests/manual/vm_e2e/run_all.ps1 @@ -183,11 +183,16 @@ function Run-MCP-Tests { Invoke-Native "set-policy-allow_all.log" { & uv run windows-mcp service secure-desktop set-policy allow_all } - Log "Running mcp_client.py --mode allow_all" + Log "Running mcp_client.py --mode allow_all (basic-user token via runas /trustlevel)" $allowJson = Join-Path $ResultsDir "results-allow_all.json" Invoke-Native "mcp_client-allow_all.log" { - & uv run python tests\manual\vm_e2e\mcp_client.py ` - --results $allowJson --mode allow_all + # Run the broker (and its child MCP server) at medium integrity so + # Start-Process -Verb RunAs from inside the test actually fires UAC + # rather than auto-elevating. runas /trustlevel:0x20000 strips the + # admin token from the same user — no password required. + & cmd.exe /c ("runas /trustlevel:0x20000 " + + "`"uv run python tests\manual\vm_e2e\mcp_client.py " + + "--results `"$allowJson`" --mode allow_all`"") } # ----- phase 2: block (asserts click is refused) ----- @@ -195,11 +200,12 @@ function Run-MCP-Tests { Invoke-Native "set-policy-block.log" { & uv run windows-mcp service secure-desktop set-policy block } - Log "Running mcp_client.py --mode block" + Log "Running mcp_client.py --mode block (basic-user token via runas /trustlevel)" $blockJson = Join-Path $ResultsDir "results-block.json" Invoke-Native "mcp_client-block.log" { - & uv run python tests\manual\vm_e2e\mcp_client.py ` - --results $blockJson --mode block + & cmd.exe /c ("runas /trustlevel:0x20000 " + + "`"uv run python tests\manual\vm_e2e\mcp_client.py " + + "--results `"$blockJson`" --mode block`"") } # ----- combined report ------------------------------------------------ From e04643d7c6d8ac9e9b33f3f4596f4568c46734d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 16:33:06 +0000 Subject: [PATCH 034/158] fix(vm-e2e): wrap medium-int test invocation in batch file (no nested-quote pain) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runas /trustlevel: with directly-quoted arguments was getting mangled by cmd's quote handling — the 1-second exit suggested runas couldn't parse what to run. Switched to writing a tiny .bat per phase (cd to project + uv run python ... > out 2>&1) and running runas against the .bat. Wait for the JSON result file to appear (runas exits on spawn, not on completion). Copy the .out to the share for diagnostics. --- tests/manual/vm_e2e/run_all.ps1 | 46 +++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/tests/manual/vm_e2e/run_all.ps1 b/tests/manual/vm_e2e/run_all.ps1 index 3007ba8a..1464cae9 100644 --- a/tests/manual/vm_e2e/run_all.ps1 +++ b/tests/manual/vm_e2e/run_all.ps1 @@ -178,22 +178,40 @@ function Verify-Service { function Run-MCP-Tests { Push-Location $LocalRepo try { + # Build a tiny batch wrapper for each phase so we can invoke them + # under `runas /trustlevel:0x20000` without nested-quote pain. + $allowJson = Join-Path $ResultsDir "results-allow_all.json" + $blockJson = Join-Path $ResultsDir "results-block.json" + $allowBat = "$env:TEMP\wmcp-test-allow_all.bat" + $blockBat = "$env:TEMP\wmcp-test-block.bat" + # The batch needs to cd to the project so uv finds the venv. + Set-Content -Path $allowBat -Value "@echo off`r`ncd /d $LocalRepo`r`nuv run python tests\manual\vm_e2e\mcp_client.py --results `"$allowJson`" --mode allow_all > `"$env:TEMP\wmcp-allow_all.out`" 2>&1`r`n" + Set-Content -Path $blockBat -Value "@echo off`r`ncd /d $LocalRepo`r`nuv run python tests\manual\vm_e2e\mcp_client.py --results `"$blockJson`" --mode block > `"$env:TEMP\wmcp-block.out`" 2>&1`r`n" + + function Wait-For-File($path, $timeoutSec) { + $deadline = (Get-Date).AddSeconds($timeoutSec) + while ((Get-Date) -lt $deadline) { + if (Test-Path $path) { return $true } + Start-Sleep -Seconds 2 + } + return $false + } + # ----- phase 1: allow_all (clicks Yes, asserts UAC dismissed) ----- Log "Setting policy=allow_all" Invoke-Native "set-policy-allow_all.log" { & uv run windows-mcp service secure-desktop set-policy allow_all } Log "Running mcp_client.py --mode allow_all (basic-user token via runas /trustlevel)" - $allowJson = Join-Path $ResultsDir "results-allow_all.json" - Invoke-Native "mcp_client-allow_all.log" { - # Run the broker (and its child MCP server) at medium integrity so - # Start-Process -Verb RunAs from inside the test actually fires UAC - # rather than auto-elevating. runas /trustlevel:0x20000 strips the - # admin token from the same user — no password required. - & cmd.exe /c ("runas /trustlevel:0x20000 " + - "`"uv run python tests\manual\vm_e2e\mcp_client.py " + - "--results `"$allowJson`" --mode allow_all`"") + Remove-Item -Force $allowJson -ErrorAction SilentlyContinue + # runas spawns the program detached; we wait for the result file + # rather than relying on runas's exit code (which fires on launch, + # not completion). + & runas /trustlevel:0x20000 $allowBat | Out-Null + if (-not (Wait-For-File $allowJson 180)) { + throw "Phase allow_all: results-allow_all.json never appeared. See $env:TEMP\wmcp-allow_all.out" } + Copy-Item -Force "$env:TEMP\wmcp-allow_all.out" (Join-Path $ResultsDir "mcp_client-allow_all.log") # ----- phase 2: block (asserts click is refused) ----- Log "Setting policy=block" @@ -201,12 +219,12 @@ function Run-MCP-Tests { & uv run windows-mcp service secure-desktop set-policy block } Log "Running mcp_client.py --mode block (basic-user token via runas /trustlevel)" - $blockJson = Join-Path $ResultsDir "results-block.json" - Invoke-Native "mcp_client-block.log" { - & cmd.exe /c ("runas /trustlevel:0x20000 " + - "`"uv run python tests\manual\vm_e2e\mcp_client.py " + - "--results `"$blockJson`" --mode block`"") + Remove-Item -Force $blockJson -ErrorAction SilentlyContinue + & runas /trustlevel:0x20000 $blockBat | Out-Null + if (-not (Wait-For-File $blockJson 180)) { + throw "Phase block: results-block.json never appeared. See $env:TEMP\wmcp-block.out" } + Copy-Item -Force "$env:TEMP\wmcp-block.out" (Join-Path $ResultsDir "mcp_client-block.log") # ----- combined report ------------------------------------------------ $allow = Get-Content $allowJson -Raw | ConvertFrom-Json From c599241ed35edb202decb296788687b881c9d060 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 17:51:04 +0000 Subject: [PATCH 035/158] fix(vm-e2e): force ConsentPromptBehaviorAdmin=2 so Windows binaries trigger UAC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Win11 default (5 = 'Prompt for consent for non-Windows binaries') auto-elevates MS-signed binaries like cmd.exe silently for admin users. Our trigger (Start-Process -Verb RunAs cmd.exe) was therefore not firing the UAC dialog at all, even with EnableLUA=1 and the broker running at medium integrity. ConsentPromptBehaviorAdmin=2 forces consent prompts on the Secure Desktop for every elevation, MS-signed or not — which is the policy we actually want to test against. Also explicitly set PromptOnSecureDesktop=1 to belt-and-suspenders the dialog routing. --- tests/manual/vm_e2e/run_all.ps1 | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/manual/vm_e2e/run_all.ps1 b/tests/manual/vm_e2e/run_all.ps1 index 1464cae9..89728aab 100644 --- a/tests/manual/vm_e2e/run_all.ps1 +++ b/tests/manual/vm_e2e/run_all.ps1 @@ -267,10 +267,16 @@ try { # will resume here with UAC active. $luaKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" $lua = (Get-ItemProperty -Path $luaKey -Name EnableLUA -ErrorAction SilentlyContinue).EnableLUA - if ($lua -ne 1) { - Log "EnableLUA=$lua. Enabling UAC, scheduling re-run on next login, and rebooting…" + $consentBehavior = (Get-ItemProperty -Path $luaKey -Name ConsentPromptBehaviorAdmin -ErrorAction SilentlyContinue).ConsentPromptBehaviorAdmin + if ($lua -ne 1 -or $consentBehavior -ne 2) { + Log "UAC config (EnableLUA=$lua, ConsentPromptBehaviorAdmin=$consentBehavior). Setting to test-correct values + rebooting…" Set-ItemProperty -Path $luaKey -Name EnableLUA -Type DWord -Value 1 - Set-ItemProperty -Path $luaKey -Name ConsentPromptBehaviorAdmin -Type DWord -Value 5 + # ConsentPromptBehaviorAdmin=2 = "Prompt for consent on the Secure Desktop" + # for ALL elevations, including MS-signed binaries. The Win11 default + # is 5, which auto-elevates Windows binaries (cmd.exe, regedit) silently + # — that's the wrong shape to test the secure-desktop flow. + Set-ItemProperty -Path $luaKey -Name ConsentPromptBehaviorAdmin -Type DWord -Value 2 + Set-ItemProperty -Path $luaKey -Name PromptOnSecureDesktop -Type DWord -Value 1 $task = "windows-mcp-test-resume" # Use cmd to swallow schtasks's stderr-on-not-found that would otherwise # be promoted to a fatal error by $ErrorActionPreference=Stop. From 256a0201ea30596bf7a6a806e41ddf927d619bcd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 18:36:18 +0000 Subject: [PATCH 036/158] refactor(vm-e2e): split harness into one-time setup + verify-only test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user pointed out that the previous harness violated the test architecture: it was re-installing and re-launching windows-mcp on every iteration. That defeats the point — if windows-mcp can't survive a reboot on its own, that's a product bug to fix, not something the harness should paper over. New shape: setup.ps1 — runs ONCE, elevated. Installs python+uv (pre-staged), uv sync, sets UAC reg values, installs the LocalSystem host service via `windows-mcp service secure-desktop install` (SCM auto-start), registers the MCP server ONLOGON task via `windows-mcp install` (windows-mcp's own auto-start mechanism), registers a sibling ONLOGON test task at medium integrity, then reboots. test.ps1 — runs EVERY reboot, non-elevated, fired by the ONLOGON task setup registered. Verifies WindowsMCPHost is Running, waits for the MCP server's HTTP endpoint on 127.0.0.1:8000, then calls mcp_client.py --http against the already-running server. Never starts windows-mcp itself. run_all.ps1 — thin dispatcher: setup if first run, test otherwise. mcp_client.py — unchanged; already supported --http and trigger via Start-Process -Verb RunAs (which fires real UAC because test.ps1's parent task is medium integrity). Reboot survival is implicit now: every test cycle starts with a fresh boot, so 'service auto-started' is a direct observation rather than a separate assertion. --- .gitignore | 1 + tests/manual/vm_e2e/README.md | 109 +++++++--- tests/manual/vm_e2e/run_all.ps1 | 347 ++++---------------------------- tests/manual/vm_e2e/setup.ps1 | 222 ++++++++++++++++++++ tests/manual/vm_e2e/test.ps1 | 132 ++++++++++++ 5 files changed, 467 insertions(+), 344 deletions(-) create mode 100644 tests/manual/vm_e2e/setup.ps1 create mode 100644 tests/manual/vm_e2e/test.ps1 diff --git a/.gitignore b/.gitignore index c725c195..e81c0323 100755 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,4 @@ sandbox *.mcpb .idea/ node_modules +elevated-run.bat diff --git a/tests/manual/vm_e2e/README.md b/tests/manual/vm_e2e/README.md index 3ee2cc19..fd7773eb 100644 --- a/tests/manual/vm_e2e/README.md +++ b/tests/manual/vm_e2e/README.md @@ -1,44 +1,91 @@ # Windows-MCP — VM end-to-end test harness -Tests the secure-desktop host service against a real UAC prompt inside a -Windows VM. Two driver paths are supported: +Tests the secure-desktop story (UAC handling) against a real UAC prompt +fired on a clean Windows VM, by verifying that **windows-mcp comes up on +its own after reboot** and the MCP server is reachable. -## Path A — in-VM driver (default) +## Architecture -The MCP client runs *inside* the Windows VM and talks to the MCP server -over the stdio transport — the same shape Claude Desktop uses. Results -are written to a JSON file in the bind-mounted share so the Linux side -can read them without any port mapping. +The harness is split into setup and test phases so the test never restarts +windows-mcp itself — windows-mcp must self-start after reboot using its +own Windows service + scheduled-task mechanisms. - Linux Windows VM - ───── ────────── - /home/.../tests/ ◄──SMB──► \\host.lan\Data\…\tests\ - └─ vm_e2e/ └─ run_all.ps1 - └─ results.json ◄──── writes ◄──── mcp_client.py ──stdio──► windows-mcp serve +``` ++- setup.ps1 -------------+ one-time, elevated +| install python+uv | +| uv sync | +| set UAC reg values | +| install host service | SERVICE_AUTO_START → SCM brings it up +| windows-mcp install | ONLOGON task → MCP server brings itself up +| register test task | ONLOGON, non-elev → test.ps1 fires per boot +| shutdown /r | ++------------+------------+ + | + v (reboot) ++- after reboot ----------+ +| SCM auto-starts | +| WindowsMCPHost | +| TaskSched fires | +| windows-mcp-server | listens on 127.0.0.1:8000 +| windows-mcp-test | medium-integrity, runs test.ps1 ++------------+------------+ + | + v ++- test.ps1 (every boot) -+ +| verify service running | +| wait for MCP HTTP up | +| mcp_client.py --http | real MCP protocol over streamable-http +| triggers UAC | +| asserts WaitForUACPrompt +| asserts Click(Yes) | +| results.json | ++-------------------------+ +``` -## Path B — Linux-side driver +## Files -The MCP server inside the VM is served over streamable-http. Container -port 8000 is forwarded to the Linux host (needs container restart with -`-p 8000:8000`). The Python MCP client runs on Linux. Same assertions -but exercises HTTP transport too. +| File | Run when | Privilege | +|------|----------|-----------| +| `setup.ps1` | Once per VM | Elevated (admin) | +| `test.ps1` | Every reboot | Non-elevated (medium integrity) | +| `run_all.ps1` | Convenience dispatcher | Whichever | +| `mcp_client.py` | Called by test.ps1 | Medium integrity | +| `bin/` | Pre-staged binaries (gitignored) | n/a | -## Bring-up sequence +## First-time bring-up -Run from Linux: +1. Stage host-side binaries (uv, Python installer) into `bin/` — one-time: + ``` + curl -sL -o /tmp/u.zip https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-pc-windows-msvc.zip + unzip -o /tmp/u.zip -d tests/manual/vm_e2e/bin/ + curl -sL -o tests/manual/vm_e2e/bin/python-install.exe https://www.python.org/ftp/python/3.13.0/python-3.13.0-amd64.exe + ``` +2. From an **elevated** PowerShell inside the VM, run: + ``` + powershell -ExecutionPolicy Bypass -File \\host.lan\Data\Windows-MCP\tests\manual\vm_e2e\setup.ps1 + ``` +3. Setup reboots. After the reboot, both windows-mcp components come up on + their own. The `windows-mcp-test` task also fires automatically and + runs the assertions. Read `results.json` from the share to see the result. - bash tests/manual/vm_e2e/bringup.sh +## Reboot survival -That installs Python+uv+windows-mcp inside the VM, registers the service -with `--allow-user-binary-path` (since the VM is disposable), and runs -`run_all.ps1`. After completion, `results.json` will exist at -`tests/manual/vm_e2e/results.json` on the Linux side. +The architecture inherently tests reboot survival on every cycle: each +reboot validates that windows-mcp self-starts and the MCP tools work. -## Tests covered +## Re-running -1. Service install succeeds, service is RUNNING. -2. Service auto-starts after a Windows reboot (no manual start needed). -3. MCP `WaitForUACPrompt` blocks, returns a dialog after we trigger UAC. -4. Policy=`block` → service refuses auto-click on Winlogon. -5. Policy=`allow_all` → service performs the auto-click. -6. Service uninstall removes the registry policy and the service entry. +After the first setup, every subsequent reboot re-fires `test.ps1` and +overwrites `results.json`. To re-trigger without rebooting: + +``` +schtasks /Run /TN windows-mcp-test +``` + +To wipe everything and start over: +``` +schtasks /Delete /TN windows-mcp-test /F +schtasks /Delete /TN windows-mcp-server /F +sc.exe delete WindowsMCPHost +# then re-run setup.ps1 +``` diff --git a/tests/manual/vm_e2e/run_all.ps1 b/tests/manual/vm_e2e/run_all.ps1 index 89728aab..8d07c1d7 100644 --- a/tests/manual/vm_e2e/run_all.ps1 +++ b/tests/manual/vm_e2e/run_all.ps1 @@ -1,317 +1,38 @@ -# In-VM test orchestrator (Path A). +# run_all.ps1 — dispatcher. # -# Kicked off from the host via vncdotool keystroke. Picks up at first-boot, -# installs Python+uv if needed, registers the secure-desktop service, then -# runs the MCP client tests and writes results.json back to the share. - -$ErrorActionPreference = "Stop" -$ProgressPreference = "SilentlyContinue" - -$Repo = "\\host.lan\Data\Windows-MCP" -$LocalRepo = "C:\windows-mcp" -$ResultsDir = Join-Path $Repo "tests\manual\vm_e2e" -$ResultsJson = Join-Path $ResultsDir "results.json" - -# Write the log LOCALLY during execution; copy to the share at the end. -# Set-Content/Add-Content directly to a UNC path is flaky on Win11 (saw -# transient FileNotFoundException on a fresh path) — local writes are not. -$LocalLog = "$env:TEMP\windows-mcp-run_all.log" -$ShareLog = Join-Path $ResultsDir "run_all.log" - -function Log($msg) { - $ts = (Get-Date).ToString("HH:mm:ss") - Add-Content -Path $LocalLog -Value "[$ts] $msg" - Write-Host "[$ts] $msg" - # Best-effort live mirror to the share. Failure to mirror does not stop the run. - try { Copy-Item -Force $LocalLog $ShareLog -ErrorAction Stop } catch { } -} - -# Run a native command with stderr merged into stdout and Tee'd to a log, -# locally suppressing PowerShell's "stderr lines = error" treatment. Throws -# only on non-zero exit code, not on stderr noise. -function Invoke-Native { - param([string]$LogName, [scriptblock]$Block) - $localLog = "$env:TEMP\$LogName" - $shareLog = Join-Path $ResultsDir $LogName - $prev = $ErrorActionPreference - $ErrorActionPreference = "Continue" - try { - & $Block 2>&1 | Tee-Object -FilePath $localLog - $rc = $LASTEXITCODE - } finally { - $ErrorActionPreference = $prev - } - try { Copy-Item -Force $localLog $shareLog -ErrorAction Stop } catch { } - if ($rc -ne 0) { - throw "Native command in $LogName exited with $rc (see $shareLog)" - } -} - -# ----------------------------------------------------------------------------- -# 1) Bootstrap. Skip Windows-managed Python entirely — winget on a fresh -# dockur Win11 has a broken source ("Data required by the source is -# missing") and the python on PATH is an MS Store stub. +# Detects whether the VM has been set up. If not, runs setup.ps1 (which +# reboots). If setup is done, runs test.ps1 directly. # -# Instead, install uv (self-contained binary, ~15 MB) directly from -# Astral's CDN, then let uv install and manage its own Python via -# `uv python install 3.13`. Zero Windows-side Python machinery needed. -# ----------------------------------------------------------------------------- -function Ensure-Uv { - if (Get-Command uv -ErrorAction SilentlyContinue) { - Log "uv present: $(uv --version)" - return - } - # We pre-stage uv.exe in the share at tests/manual/vm_e2e/bin/uv.exe so we - # don't depend on Windows being able to reach Astral's CDN. (In the - # disposable test VM the sandbox network MITMs TLS and the VM doesn't - # trust the intercept CA — outbound HTTPS from Windows is unreliable.) - $sharedUv = Join-Path $Repo "tests\manual\vm_e2e\bin\uv.exe" - $dest = "$env:USERPROFILE\.local\bin" - if (-not (Test-Path $sharedUv)) { - throw "Expected pre-staged uv.exe at $sharedUv but it was missing. Re-stage from the host: curl -sL -o /tmp/u.zip https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-pc-windows-msvc.zip && unzip -o /tmp/u.zip -d /tests/manual/vm_e2e/bin/" - } - Log "Copying pre-staged uv.exe from share to $dest…" - New-Item -ItemType Directory -Force -Path $dest | Out-Null - Copy-Item -Force $sharedUv "$dest\uv.exe" - $env:Path = "$dest;$env:Path" - Log "uv ready: $(& uv --version)" -} - -function Ensure-Python { - # Detect a real (non-Store-stub) Python 3.13 if already installed. - foreach ($candidate in @( - "$env:LOCALAPPDATA\Programs\Python\Python313\python.exe", - "$env:ProgramFiles\Python313\python.exe" - )) { - if (Test-Path $candidate) { - $verOut = & $candidate --version 2>&1 | Out-String - if ($verOut -match 'Python 3\.13') { - $env:Path = "$([System.IO.Path]::GetDirectoryName($candidate));$env:Path" - Log "python already installed: $candidate ($($verOut.Trim()))" - return - } - } - } - # Install via the pre-staged python.org installer (avoids winget + outbound HTTPS). - $stagedInstaller = Join-Path $Repo "tests\manual\vm_e2e\bin\python-install.exe" - if (-not (Test-Path $stagedInstaller)) { - throw "Expected pre-staged Python installer at $stagedInstaller but it was missing." - } - Log "Running pre-staged Python installer (quiet, per-user, add to PATH)…" - & $stagedInstaller /quiet InstallAllUsers=0 PrependPath=1 Include_test=0 Include_pip=1 | Out-Null - if ($LASTEXITCODE -ne 0) { - throw "Python installer exited with $LASTEXITCODE" - } - foreach ($candidate in @( - "$env:LOCALAPPDATA\Programs\Python\Python313\python.exe", - "$env:ProgramFiles\Python313\python.exe" - )) { - if (Test-Path $candidate) { - $env:Path = "$([System.IO.Path]::GetDirectoryName($candidate));$env:Path" - Log "python installed: $candidate" - return - } - } - throw "Python installer ran (exit 0) but python.exe not found in expected paths." -} - -# ----------------------------------------------------------------------------- -# 2) Stage the repo locally so uv sync can write its venv on a normal drive -# (uv refuses to write into UNC shares). -# ----------------------------------------------------------------------------- -function Stage-Repo { - # If a previous run left the service installed and running, its - # python.exe is locked by the service process — Remove-Item would skip - # those files, leaving a stale .venv that uv mistakes for a fresh one. - # Stop and remove the service first so we can wipe cleanly. - if (Get-Service WindowsMCPHost -ErrorAction SilentlyContinue) { - Log "Stopping/removing prior WindowsMCPHost service before re-staging…" - try { Stop-Service WindowsMCPHost -Force -ErrorAction SilentlyContinue } catch { } - try { sc.exe delete WindowsMCPHost | Out-Null } catch { } - Start-Sleep -Seconds 2 # let SCM finish + file handles release - } - if (Test-Path $LocalRepo) { - Log "Refreshing $LocalRepo" - Remove-Item -Recurse -Force "$LocalRepo\*" -ErrorAction SilentlyContinue - } else { - Log "Creating $LocalRepo" - New-Item -ItemType Directory -Path $LocalRepo | Out-Null - } - robocopy $Repo $LocalRepo /MIR /XD .git .venv tests\manual\vm_e2e\.work | Out-Null -} - -# ----------------------------------------------------------------------------- -# 3) uv sync + install the host service -# ----------------------------------------------------------------------------- -function Setup-Project { - Push-Location $LocalRepo - try { - # Sandbox MITM proxy: tell uv to skip cert verification for PyPI hosts. - # Same disposable-VM caveat as the .NET cert bypass earlier. - $env:UV_INSECURE_HOST = "pypi.org files.pythonhosted.org github.com astral.sh objects.githubusercontent.com" - Log "uv sync (UV_INSECURE_HOST set for MITM proxy)" - Invoke-Native "uv_sync.log" { & uv sync } - Log "Installing the host service (allow-user-binary-path because this is a VM)…" - Invoke-Native "install.log" { - & uv run windows-mcp service secure-desktop install ` - --policy allow_all --allow-user-binary-path --force - } - } finally { - Pop-Location - } -} - -# ----------------------------------------------------------------------------- -# 4) Verify the service is RUNNING, then run the MCP client. -# ----------------------------------------------------------------------------- -function Verify-Service { - $svc = Get-Service WindowsMCPHost -ErrorAction SilentlyContinue - if ($null -eq $svc) { - throw "Service WindowsMCPHost not registered" - } - if ($svc.Status -ne "Running") { - throw "Service WindowsMCPHost not running: $($svc.Status)" - } - Log "Service WindowsMCPHost is Running" -} - -function Run-MCP-Tests { - Push-Location $LocalRepo - try { - # Build a tiny batch wrapper for each phase so we can invoke them - # under `runas /trustlevel:0x20000` without nested-quote pain. - $allowJson = Join-Path $ResultsDir "results-allow_all.json" - $blockJson = Join-Path $ResultsDir "results-block.json" - $allowBat = "$env:TEMP\wmcp-test-allow_all.bat" - $blockBat = "$env:TEMP\wmcp-test-block.bat" - # The batch needs to cd to the project so uv finds the venv. - Set-Content -Path $allowBat -Value "@echo off`r`ncd /d $LocalRepo`r`nuv run python tests\manual\vm_e2e\mcp_client.py --results `"$allowJson`" --mode allow_all > `"$env:TEMP\wmcp-allow_all.out`" 2>&1`r`n" - Set-Content -Path $blockBat -Value "@echo off`r`ncd /d $LocalRepo`r`nuv run python tests\manual\vm_e2e\mcp_client.py --results `"$blockJson`" --mode block > `"$env:TEMP\wmcp-block.out`" 2>&1`r`n" - - function Wait-For-File($path, $timeoutSec) { - $deadline = (Get-Date).AddSeconds($timeoutSec) - while ((Get-Date) -lt $deadline) { - if (Test-Path $path) { return $true } - Start-Sleep -Seconds 2 - } - return $false - } - - # ----- phase 1: allow_all (clicks Yes, asserts UAC dismissed) ----- - Log "Setting policy=allow_all" - Invoke-Native "set-policy-allow_all.log" { - & uv run windows-mcp service secure-desktop set-policy allow_all - } - Log "Running mcp_client.py --mode allow_all (basic-user token via runas /trustlevel)" - Remove-Item -Force $allowJson -ErrorAction SilentlyContinue - # runas spawns the program detached; we wait for the result file - # rather than relying on runas's exit code (which fires on launch, - # not completion). - & runas /trustlevel:0x20000 $allowBat | Out-Null - if (-not (Wait-For-File $allowJson 180)) { - throw "Phase allow_all: results-allow_all.json never appeared. See $env:TEMP\wmcp-allow_all.out" - } - Copy-Item -Force "$env:TEMP\wmcp-allow_all.out" (Join-Path $ResultsDir "mcp_client-allow_all.log") - - # ----- phase 2: block (asserts click is refused) ----- - Log "Setting policy=block" - Invoke-Native "set-policy-block.log" { - & uv run windows-mcp service secure-desktop set-policy block - } - Log "Running mcp_client.py --mode block (basic-user token via runas /trustlevel)" - Remove-Item -Force $blockJson -ErrorAction SilentlyContinue - & runas /trustlevel:0x20000 $blockBat | Out-Null - if (-not (Wait-For-File $blockJson 180)) { - throw "Phase block: results-block.json never appeared. See $env:TEMP\wmcp-block.out" - } - Copy-Item -Force "$env:TEMP\wmcp-block.out" (Join-Path $ResultsDir "mcp_client-block.log") - - # ----- combined report ------------------------------------------------ - $allow = Get-Content $allowJson -Raw | ConvertFrom-Json - $block = Get-Content $blockJson -Raw | ConvertFrom-Json - $combined = [pscustomobject]@{ - started_at = $allow.started_at - finished_at = $block.finished_at - transport = $allow.transport - phases = @{ - allow_all = $allow - block = $block - } - summary = @{ - total = ($allow.summary.total + $block.summary.total) - passed = ($allow.summary.passed + $block.summary.passed) - failed = ($allow.summary.failed + $block.summary.failed) - } - } - $combined | ConvertTo-Json -Depth 8 | Set-Content -Path $ResultsJson - Log "Combined results.json written: total=$($combined.summary.total) passed=$($combined.summary.passed) failed=$($combined.summary.failed)" - } finally { - Pop-Location - } -} - -# ----------------------------------------------------------------------------- -# Main -# ----------------------------------------------------------------------------- -if (-not (Test-Path $ResultsDir)) { - New-Item -ItemType Directory -Path $ResultsDir | Out-Null -} -Set-Content -Path $LocalLog -Value "run_all.ps1 started $(Get-Date -Format o)" -try { Copy-Item -Force $LocalLog $ShareLog -ErrorAction Stop } catch { } - -try { - # Pre-flight: dockur's autounattend disables UAC entirely - # (EnableLUA=false). The whole point of this test is UAC handling, so we - # need it on. If it's off, turn it on, schedule run_all.ps1 to fire on - # next login, and reboot. The next boot's auto-login + scheduled task - # will resume here with UAC active. - $luaKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" - $lua = (Get-ItemProperty -Path $luaKey -Name EnableLUA -ErrorAction SilentlyContinue).EnableLUA - $consentBehavior = (Get-ItemProperty -Path $luaKey -Name ConsentPromptBehaviorAdmin -ErrorAction SilentlyContinue).ConsentPromptBehaviorAdmin - if ($lua -ne 1 -or $consentBehavior -ne 2) { - Log "UAC config (EnableLUA=$lua, ConsentPromptBehaviorAdmin=$consentBehavior). Setting to test-correct values + rebooting…" - Set-ItemProperty -Path $luaKey -Name EnableLUA -Type DWord -Value 1 - # ConsentPromptBehaviorAdmin=2 = "Prompt for consent on the Secure Desktop" - # for ALL elevations, including MS-signed binaries. The Win11 default - # is 5, which auto-elevates Windows binaries (cmd.exe, regedit) silently - # — that's the wrong shape to test the secure-desktop flow. - Set-ItemProperty -Path $luaKey -Name ConsentPromptBehaviorAdmin -Type DWord -Value 2 - Set-ItemProperty -Path $luaKey -Name PromptOnSecureDesktop -Type DWord -Value 1 - $task = "windows-mcp-test-resume" - # Use cmd to swallow schtasks's stderr-on-not-found that would otherwise - # be promoted to a fatal error by $ErrorActionPreference=Stop. - cmd.exe /c "schtasks.exe /Delete /TN $task /F >nul 2>&1" | Out-Null - $tr = "powershell.exe -ExecutionPolicy Bypass -File \\host.lan\Data\Windows-MCP\tests\manual\vm_e2e\run_all.ps1" - cmd.exe /c "schtasks.exe /Create /TN $task /SC ONLOGON /RL HIGHEST /RU Docker /TR `"$tr`" /F" | Out-Null - Log "Scheduled task $task. Rebooting in 5s…" - Start-Sleep -Seconds 2 - shutdown.exe /r /t 5 /c "Enabling UAC for windows-mcp test" - exit 0 - } - # If we just resumed via scheduled task, remove the task so future logins - # don't re-trigger the harness. - cmd.exe /c "schtasks.exe /Delete /TN windows-mcp-test-resume /F >nul 2>&1" | Out-Null +# In normal operation the test.ps1 fires from the windows-mcp-test ONLOGON +# scheduled task after a reboot, NOT from this dispatcher. This file is a +# convenience for the human driver who wants a single entry point. +# +# IMPORTANT: this script never starts the windows-mcp services itself. +# windows-mcp must come up on its own after reboot via: +# - SCM auto-start for WindowsMCPHost (set by setup.ps1) +# - ONLOGON scheduled task `windows-mcp-server` (set by setup.ps1 via +# `windows-mcp install`) - Ensure-Python - Ensure-Uv - Stage-Repo - Setup-Project - Verify-Service - Run-MCP-Tests - Log "DONE" -} catch { - Log "FAILED: $($_.Exception.Message)" - @{ - started_at = (Get-Date).ToString("o") - finished_at = (Get-Date).ToString("o") - transport = "(bootstrap-failed)" - results = @(@{ - name = "run_all.ps1 bootstrap" - passed = $false - detail = $_.Exception.Message - duration_s = 0 - }) - summary = @{ total = 1; passed = 0; failed = 1 } - } | ConvertTo-Json -Depth 5 | Set-Content -Path $ResultsJson - exit 1 +$ErrorActionPreference = "Stop" +$here = Split-Path -Parent $PSCommandPath +$setup = Join-Path $here "setup.ps1" +$test = Join-Path $here "test.ps1" + +# Marker that setup has completed at least once. We treat "host service +# registered AND windows-mcp-server task registered" as the marker — both +# come from setup.ps1. +function Setup-Done { + if (-not (Get-Service WindowsMCPHost -ErrorAction SilentlyContinue)) { return $false } + $task = schtasks.exe /Query /TN windows-mcp-server 2>$null + return $LASTEXITCODE -eq 0 +} + +if (Setup-Done) { + Write-Host "Setup detected — running test.ps1 (verify-only, non-elevated path runs via ONLOGON task)." + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $test + exit $LASTEXITCODE +} else { + Write-Host "No setup detected — running setup.ps1 (will reboot)." + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $setup + exit $LASTEXITCODE } diff --git a/tests/manual/vm_e2e/setup.ps1 b/tests/manual/vm_e2e/setup.ps1 new file mode 100644 index 00000000..ec3c210f --- /dev/null +++ b/tests/manual/vm_e2e/setup.ps1 @@ -0,0 +1,222 @@ +# setup.ps1 — ONE-TIME bring-up of a fresh dockur Win11 VM. +# +# Idempotent. Runs elevated (must). Does the following and only the following: +# +# 1. Install Python 3.13 (pre-staged installer in tests/manual/vm_e2e/bin/). +# 2. Install uv (pre-staged binary). +# 3. Persist $env:Path so the user's PATH at login includes uv. +# 4. Mirror the repo to C:\windows-mcp and run `uv sync` (installs windows-mcp +# into a local .venv). +# 5. Set UAC registry values (EnableLUA=1, ConsentPromptBehaviorAdmin=2, +# PromptOnSecureDesktop=1) so the test exercises a real Secure Desktop +# consent flow. +# 6. Install the LocalSystem host service with `windows-mcp service +# secure-desktop install --policy allow_all --allow-user-binary-path`. +# 7. Install the MCP server ONLOGON scheduled task with `windows-mcp install +# --transport streamable-http --host 127.0.0.1 --port 8000`. +# 8. Register a one-shot ONLOGON scheduled task (`windows-mcp-test`, +# non-elevated) that runs tests/manual/vm_e2e/test.ps1. +# 9. Reboot. +# +# AFTER REBOOT — independent of this script: +# - SCM auto-starts WindowsMCPHost. +# - `windows-mcp-server` task fires; the MCP server listens on +# http://127.0.0.1:8000/mcp/. +# - `windows-mcp-test` task fires test.ps1 (non-elevated) which connects +# to the running MCP server, runs the assertions, and writes results.json. +# +# This script does NOT spawn windows-mcp at boot. It only configures the +# Windows-side mechanisms that windows-mcp itself provides for self-start. + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$Repo = "\\host.lan\Data\Windows-MCP" +$LocalRepo = "C:\windows-mcp" +$ResultsDir = Join-Path $Repo "tests\manual\vm_e2e" +$LocalLog = "$env:TEMP\windows-mcp-setup.log" +$ShareLog = Join-Path $ResultsDir "setup.log" + +function Log($msg) { + $ts = (Get-Date).ToString("HH:mm:ss") + Add-Content -Path $LocalLog -Value "[$ts] $msg" + Write-Host "[$ts] $msg" + try { Copy-Item -Force $LocalLog $ShareLog -ErrorAction Stop } catch { } +} + +function Invoke-Native { + param([string]$LogName, [scriptblock]$Block) + $localPath = "$env:TEMP\$LogName" + $sharePath = Join-Path $ResultsDir $LogName + $prev = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + & $Block 2>&1 | Tee-Object -FilePath $localPath | Out-Host + $rc = $LASTEXITCODE + } finally { + $ErrorActionPreference = $prev + } + try { Copy-Item -Force $localPath $sharePath -ErrorAction Stop } catch { } + if ($rc -ne 0) { + throw "Native command in $LogName exited with $rc (see $sharePath)." + } +} + +function Ensure-Python { + foreach ($candidate in @( + "$env:LOCALAPPDATA\Programs\Python\Python313\python.exe", + "$env:ProgramFiles\Python313\python.exe" + )) { + if (Test-Path $candidate) { + $verOut = & $candidate --version 2>&1 | Out-String + if ($verOut -match 'Python 3\.13') { + $env:Path = "$([System.IO.Path]::GetDirectoryName($candidate));$env:Path" + Log "python already installed: $candidate" + return + } + } + } + $stagedInstaller = Join-Path $Repo "tests\manual\vm_e2e\bin\python-install.exe" + if (-not (Test-Path $stagedInstaller)) { + throw "Pre-staged Python installer missing at $stagedInstaller. Re-stage from host." + } + Log "Installing Python (quiet, per-user, PrependPath)…" + & $stagedInstaller /quiet InstallAllUsers=0 PrependPath=1 Include_test=0 Include_pip=1 | Out-Null + foreach ($candidate in @( + "$env:LOCALAPPDATA\Programs\Python\Python313\python.exe" + )) { + if (Test-Path $candidate) { + $env:Path = "$([System.IO.Path]::GetDirectoryName($candidate));$env:Path" + Log "python installed: $candidate" + return + } + } + throw "Python installer ran but python.exe missing." +} + +function Ensure-Uv { + if (Get-Command uv -ErrorAction SilentlyContinue) { + Log "uv present: $(uv --version)" + return + } + $sharedUv = Join-Path $Repo "tests\manual\vm_e2e\bin\uv.exe" + if (-not (Test-Path $sharedUv)) { + throw "Pre-staged uv.exe missing at $sharedUv. Re-stage from host." + } + $dest = "$env:USERPROFILE\.local\bin" + New-Item -ItemType Directory -Force -Path $dest | Out-Null + Copy-Item -Force $sharedUv "$dest\uv.exe" + $env:Path = "$dest;$env:Path" + Log "uv installed at $dest\uv.exe" +} + +function Persist-Path-For-User { + # Append %USERPROFILE%\.local\bin to the *user* PATH (HKCU\Environment) so + # uv and windows-mcp resolve after reboot. Use [Environment]::SetEnvironmentVariable + # with User scope to also fire WM_SETTINGCHANGE. + $uvBin = "$env:USERPROFILE\.local\bin" + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + if ($null -eq $userPath) { $userPath = "" } + if ($userPath -notlike "*$uvBin*") { + $newPath = if ($userPath) { "$uvBin;$userPath" } else { $uvBin } + [Environment]::SetEnvironmentVariable("Path", $newPath, "User") + Log "Appended $uvBin to HKCU Path." + } else { + Log "User PATH already includes $uvBin." + } +} + +function Stage-Repo { + if (Get-Service WindowsMCPHost -ErrorAction SilentlyContinue) { + Log "Stopping/removing prior WindowsMCPHost before re-staging…" + try { Stop-Service WindowsMCPHost -Force -ErrorAction SilentlyContinue } catch { } + try { sc.exe delete WindowsMCPHost | Out-Null } catch { } + Start-Sleep -Seconds 2 + } + if (Test-Path $LocalRepo) { + Log "Refreshing $LocalRepo" + Remove-Item -Recurse -Force "$LocalRepo\*" -ErrorAction SilentlyContinue + } else { + New-Item -ItemType Directory -Path $LocalRepo | Out-Null + } + robocopy $Repo $LocalRepo /MIR /XD .git .venv tests\manual\vm_e2e\.work | Out-Null +} + +function Uv-Sync { + Push-Location $LocalRepo + try { + $env:UV_INSECURE_HOST = "pypi.org files.pythonhosted.org github.com astral.sh objects.githubusercontent.com" + Log "uv sync (UV_INSECURE_HOST set for the sandbox MITM proxy)" + Invoke-Native "uv_sync.log" { & uv sync } + } finally { + Pop-Location + } +} + +function Set-Uac-Config { + $luaKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" + Set-ItemProperty -Path $luaKey -Name EnableLUA -Type DWord -Value 1 + Set-ItemProperty -Path $luaKey -Name ConsentPromptBehaviorAdmin -Type DWord -Value 2 + Set-ItemProperty -Path $luaKey -Name PromptOnSecureDesktop -Type DWord -Value 1 + Log "UAC: EnableLUA=1 ConsentPromptBehaviorAdmin=2 PromptOnSecureDesktop=1" +} + +function Install-Host-Service { + Push-Location $LocalRepo + try { + Log "Installing host service (allow-user-binary-path because this is a VM)…" + Invoke-Native "install-host.log" { + & uv run windows-mcp service secure-desktop install ` + --policy allow_all --allow-user-binary-path --force + } + } finally { Pop-Location } +} + +function Install-Server-AutoStart { + Push-Location $LocalRepo + try { + Log "Installing 'windows-mcp install' ONLOGON task (server on 127.0.0.1:8000)…" + # windows-mcp's own install command — registers windows-mcp-server task. + # Force reinstall so any stale entry is replaced. + Invoke-Native "install-server.log" { + & uv run windows-mcp install ` + --transport streamable-http --host 127.0.0.1 --port 8000 --force + } + } finally { Pop-Location } +} + +function Register-Test-Task { + $task = "windows-mcp-test" + $tr = "powershell.exe -ExecutionPolicy Bypass -File \\host.lan\Data\Windows-MCP\tests\manual\vm_e2e\test.ps1" + cmd.exe /c "schtasks.exe /Delete /TN $task /F >nul 2>&1" | Out-Null + # Non-elevated: omit /RL HIGHEST. /IT ensures it runs interactively with + # the user's standard token so the trigger inside test.ps1 fires real UAC. + cmd.exe /c "schtasks.exe /Create /TN $task /SC ONLOGON /RU Docker /IT /TR `"$tr`" /F" | Out-Null + Log "Registered test task '$task' (non-elevated, ONLOGON)." +} + +# ----------------------------------------------------------------------------- +# Main +# ----------------------------------------------------------------------------- +if (-not (Test-Path $ResultsDir)) { New-Item -ItemType Directory -Path $ResultsDir | Out-Null } +Set-Content -Path $LocalLog -Value "setup.ps1 started $(Get-Date -Format o)" +try { Copy-Item -Force $LocalLog $ShareLog -ErrorAction Stop } catch { } + +try { + Ensure-Python + Ensure-Uv + Persist-Path-For-User + Stage-Repo + Uv-Sync + Set-Uac-Config + Install-Host-Service + Install-Server-AutoStart + Register-Test-Task + Log "SETUP DONE. Rebooting so SCM auto-start + ONLOGON tasks fire from a clean boot." + Start-Sleep -Seconds 2 + shutdown.exe /r /t 5 /c "windows-mcp test setup complete, rebooting to validate auto-start" + exit 0 +} catch { + Log "SETUP FAILED: $($_.Exception.Message)" + exit 1 +} diff --git a/tests/manual/vm_e2e/test.ps1 b/tests/manual/vm_e2e/test.ps1 new file mode 100644 index 00000000..0d11d88d --- /dev/null +++ b/tests/manual/vm_e2e/test.ps1 @@ -0,0 +1,132 @@ +# test.ps1 — runs after reboot, NON-elevated. Connects to the *already-running* +# windows-mcp MCP server over HTTP and runs the per-assertion suite. +# +# This script must NOT start the MCP server. Windows-MCP installs an ONLOGON +# scheduled task during setup that starts it on its own — the test verifies +# the system reached a working state without harness intervention. +# +# Pre-conditions (set up once by setup.ps1): +# - WindowsMCPHost service registered with SERVICE_AUTO_START +# - windows-mcp-server scheduled task registered (windows-mcp install) +# - User's PATH persisted so the task can resolve uv/windows-mcp +# - UAC: EnableLUA=1, ConsentPromptBehaviorAdmin=2, PromptOnSecureDesktop=1 +# - This task itself (windows-mcp-test) registered to fire ONLOGON, +# non-elevated. + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$Repo = "\\host.lan\Data\Windows-MCP" +$ResultsDir = Join-Path $Repo "tests\manual\vm_e2e" +$ResultsJson = Join-Path $ResultsDir "results.json" +$LocalLog = "$env:TEMP\windows-mcp-test.log" +$ShareLog = Join-Path $ResultsDir "test.log" + +# Where the MCP server should be listening, per setup.ps1. +$McpUrl = "http://127.0.0.1:8000/mcp/" + +function Log($msg) { + $ts = (Get-Date).ToString("HH:mm:ss") + Add-Content -Path $LocalLog -Value "[$ts] $msg" + Write-Host "[$ts] $msg" + try { Copy-Item -Force $LocalLog $ShareLog -ErrorAction Stop } catch { } +} + +function Wait-For-Url($url, $timeoutSec) { + $deadline = (Get-Date).AddSeconds($timeoutSec) + while ((Get-Date) -lt $deadline) { + try { + $res = Invoke-WebRequest -Uri $url -Method Get -TimeoutSec 5 -UseBasicParsing -ErrorAction SilentlyContinue + # Anything that returns a status code (even 4xx) means the server is up + # and answering on this endpoint. MCP servers commonly answer 404/406 on + # plain GET because they expect the initialization handshake. + if ($res) { return $true } + } catch [System.Net.WebException] { + # WebException for HTTP responses (4xx/5xx) still means the server + # exists and responded — that's good enough to proceed. + if ($_.Exception.Response) { return $true } + } catch { } + Start-Sleep -Seconds 2 + } + return $false +} + +# Set-Content opens the file in shared mode that Copy-Item dislikes; quick poll. +Set-Content -Path $LocalLog -Value "test.ps1 started $(Get-Date -Format o)" +try { Copy-Item -Force $LocalLog $ShareLog -ErrorAction Stop } catch { } + +try { + # ----- 1. Verify host service self-started -------------------------------- + $svc = Get-Service WindowsMCPHost -ErrorAction SilentlyContinue + if ($null -eq $svc) { + throw "WindowsMCPHost service is not registered. Did setup.ps1 run?" + } + if ($svc.Status -ne "Running") { + throw "WindowsMCPHost service did not auto-start after reboot. Current status: $($svc.Status)" + } + Log "WindowsMCPHost is Running (self-started)." + + # ----- 2. Wait for MCP server to come up on its own ----------------------- + Log "Waiting up to 120s for MCP server at $McpUrl …" + if (-not (Wait-For-Url $McpUrl 120)) { + throw "MCP server never came up at $McpUrl. Check the windows-mcp-server scheduled task." + } + Log "MCP server reachable." + + # ----- 3. Run the mcp_client tests against the running server ------------- + # We're already at medium integrity (this task was registered without + # /RL HIGHEST), so the trigger Start-Process -Verb RunAs will fire real UAC. + $localRepo = "C:\windows-mcp" + Push-Location $localRepo + try { + $allowJson = Join-Path $ResultsDir "results-allow_all.json" + Remove-Item -Force $allowJson -ErrorAction SilentlyContinue + Log "Running mcp_client.py --mode allow_all against $McpUrl …" + # uv run from medium integrity. uv reads venv from local repo. + $prev = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & uv run python tests\manual\vm_e2e\mcp_client.py ` + --http $McpUrl --results $allowJson --mode allow_all 2>&1 | + Tee-Object -FilePath "$env:TEMP\mcp_client.out" | Out-Host + $rc = $LASTEXITCODE + $ErrorActionPreference = $prev + Copy-Item -Force "$env:TEMP\mcp_client.out" ` + (Join-Path $ResultsDir "mcp_client-allow_all.log") + # rc may be 1 if assertions fail — that's a result, not a script error. + Log "mcp_client exited with $rc." + } finally { + Pop-Location + } + + # ----- 4. Combine + cleanup ---------------------------------------------- + if (Test-Path $allowJson) { + $allow = Get-Content $allowJson -Raw | ConvertFrom-Json + $combined = [pscustomobject]@{ + started_at = $allow.started_at + finished_at = $allow.finished_at + transport = $allow.transport + phases = @{ allow_all = $allow } + summary = $allow.summary + } + $combined | ConvertTo-Json -Depth 8 | Set-Content -Path $ResultsJson + Log "results.json written: total=$($combined.summary.total) passed=$($combined.summary.passed) failed=$($combined.summary.failed)" + } else { + throw "mcp_client did not write $allowJson." + } + Log "DONE" +} catch { + Log "FAILED: $($_.Exception.Message)" + @{ + started_at = (Get-Date).ToString("o") + finished_at = (Get-Date).ToString("o") + transport = "(test-failed)" + results = @(@{ + name = "test.ps1 driver" + passed = $false + detail = $_.Exception.Message + duration_s = 0 + }) + summary = @{ total = 1; passed = 0; failed = 1 } + } | ConvertTo-Json -Depth 5 | Set-Content -Path $ResultsJson + exit 1 +} From d444dca9dcac0c3eb011b92ab63197d325bc1b8f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 18:47:55 +0000 Subject: [PATCH 037/158] chore(vm-e2e): drop pre-refactor harness cruft bringup.sh / path_b_linux.sh / kickoff.bat / run-tests.bat were artifacts from when the harness re-installed windows-mcp every run. The new architecture (setup.ps1 / test.ps1 / run_all.ps1 / mcp_client.py) makes them dead. Removing to keep the harness directory honest. --- tests/manual/vm_e2e/bringup.sh | 66 ----------------------------- tests/manual/vm_e2e/kickoff.bat | 4 -- tests/manual/vm_e2e/path_b_linux.sh | 49 --------------------- tests/manual/vm_e2e/run-tests.bat | 4 -- 4 files changed, 123 deletions(-) delete mode 100755 tests/manual/vm_e2e/bringup.sh delete mode 100644 tests/manual/vm_e2e/kickoff.bat delete mode 100755 tests/manual/vm_e2e/path_b_linux.sh delete mode 100644 tests/manual/vm_e2e/run-tests.bat diff --git a/tests/manual/vm_e2e/bringup.sh b/tests/manual/vm_e2e/bringup.sh deleted file mode 100755 index 76b5f5dd..00000000 --- a/tests/manual/vm_e2e/bringup.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -# Kick off the in-VM test suite. -# -# bash tests/manual/vm_e2e/bringup.sh -# -# Requires the dockur/windows container to be running and an OOBE-complete -# Windows VM accessible on VNC port 5900 with samba share at \\host.lan\Data. -# -# After the script returns, read tests/manual/vm_e2e/results.json on the host -# side to see what passed/failed. - -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" -RESULTS="$REPO_ROOT/tests/manual/vm_e2e/results.json" -VNC="127.0.0.1::5900" - -vnc() { vncdotool --delay=80 -s "$VNC" "$@"; } - -echo "==> waiting for Windows desktop on VNC :5900" -# Heuristic: when the desktop is up, the VNC frame jumps past ~150 KB -# (Windows desktop background renders far more than the install splash). -for _ in $(seq 1 90); do - if vnc capture /tmp/vnc-bringup.png >/dev/null 2>&1; then - sz=$(stat -c %s /tmp/vnc-bringup.png 2>/dev/null || echo 0) - if [ "$sz" -gt 150000 ]; then - echo " desktop visible ($sz bytes)" - break - fi - fi - sleep 10 -done - -echo "==> opening Start menu and searching for powershell" -# Win+R is fragile when no app has focus (it can autocomplete to other exes). -# The Start-menu search is far more deterministic: tap Win, type, Enter. -vnc key super -sleep 1 -vnc type "powershell" -sleep 2 # let the search index resolve -vnc key enter -sleep 5 # PowerShell takes a beat to materialise - -echo "==> launching run_all.ps1 from the share" -# Single-line PowerShell launcher. ExecutionPolicy Bypass for the child only. -PS_CMD='powershell -ExecutionPolicy Bypass -File \\host.lan\Data\Windows-MCP\tests\manual\vm_e2e\run_all.ps1' -vnc type "$PS_CMD" -sleep 1 -vnc key enter - -echo "==> waiting for $RESULTS to appear" -rm -f "$RESULTS" -# 60 iterations * 30 s = 30 min wait. Windows install of Python+uv inside -# the VM takes ~10 min by itself under TCG. -for _ in $(seq 1 60); do - if [ -f "$RESULTS" ]; then - echo " results.json received" - cat "$RESULTS" - exit 0 - fi - sleep 30 -done - -echo "ERROR: results.json never appeared after 30 minutes" >&2 -echo "Check $REPO_ROOT/tests/manual/vm_e2e/run_all.log on the share for details" >&2 -exit 1 diff --git a/tests/manual/vm_e2e/kickoff.bat b/tests/manual/vm_e2e/kickoff.bat deleted file mode 100644 index 3b2a100a..00000000 --- a/tests/manual/vm_e2e/kickoff.bat +++ /dev/null @@ -1,4 +0,0 @@ -@echo off -REM Kickoff for the in-VM test harness. Launched via Win+R as a UNC path so -REM the typing surface is short and unambiguous: \\host.lan\Data\Windows-MCP\kickoff.bat -start "" powershell -ExecutionPolicy Bypass -File \\host.lan\Data\Windows-MCP\tests\manual\vm_e2e\run_all.ps1 diff --git a/tests/manual/vm_e2e/path_b_linux.sh b/tests/manual/vm_e2e/path_b_linux.sh deleted file mode 100755 index 446ec4d9..00000000 --- a/tests/manual/vm_e2e/path_b_linux.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# Path B — Linux-side MCP client driver. -# -# Runs the same mcp_client.py against the running MCP server inside the -# Windows VM, but over streamable-http transport so the protocol travels -# Linux → host:8000 → container → VM. This validates that the MCP server -# is reachable over a real network transport (the shape Claude Desktop -# uses when the server is remote). -# -# Prerequisites (one-time host setup): -# -# 1. Container must be running with port 8000 forwarded: -# docker run … -p 8000:8000 … -# To re-add to a running container without losing the disk image, -# docker stop winvm; docker rm winvm (keeps /tmp/winvm-storage); -# then `docker run` again with the same -v /tmp/winvm-storage:/storage -# plus -p 8000:8000. -# -# 2. Inside the container, forward container:8000 to the Windows VM's -# IP (dockur typically assigns 20.20.20.21): -# docker exec -d winvm sh -c 'apt-get -qq install -y socat 2>/dev/null; -# socat TCP-LISTEN:8000,fork,reuseaddr TCP:20.20.20.21:8000' -# -# 3. Inside the Windows VM, the MCP server must be running: -# windows-mcp serve --transport streamable-http --host 0.0.0.0 \ -# --port 8000 --allow-insecure-remote -# (run_all.ps1's path-B mode does this automatically.) - -set -euo pipefail -REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" -RESULTS="$REPO_ROOT/tests/manual/vm_e2e/results-path-b.json" -URL="${WINDOWS_MCP_URL:-http://localhost:8000/mcp/}" - -echo "==> ensuring local mcp client SDK is installed" -pip install --quiet --break-system-packages mcp >/dev/null - -echo "==> probing $URL" -if ! curl -sf --max-time 5 -o /dev/null "$URL" 2>/dev/null; then - echo " (probe returned non-200, but the MCP server might require a session; continuing)" -fi - -echo "==> running mcp_client.py against $URL" -python3 "$REPO_ROOT/tests/manual/vm_e2e/mcp_client.py" \ - --results "$RESULTS" \ - --http "$URL" - -echo -echo "==> path B results:" -cat "$RESULTS" diff --git a/tests/manual/vm_e2e/run-tests.bat b/tests/manual/vm_e2e/run-tests.bat deleted file mode 100644 index 3b2a100a..00000000 --- a/tests/manual/vm_e2e/run-tests.bat +++ /dev/null @@ -1,4 +0,0 @@ -@echo off -REM Kickoff for the in-VM test harness. Launched via Win+R as a UNC path so -REM the typing surface is short and unambiguous: \\host.lan\Data\Windows-MCP\kickoff.bat -start "" powershell -ExecutionPolicy Bypass -File \\host.lan\Data\Windows-MCP\tests\manual\vm_e2e\run_all.ps1 From 38520c33e8de4cbde4aa2213ff73e946459206dd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 19:21:23 +0000 Subject: [PATCH 038/158] fix(service): wait_for_uac_prompt must attach to WinSta0 first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LocalSystem host service starts on Service-0x0-3e7$ window station. OpenInputDesktop on that station never returns the interactive user's input desktop, so the wait_for_uac_prompt polling loop never observed Winlogon and always timed out — which is what was happening in the VM e2e harness (fired=False reason='timeout' top_windows=0). Switch the process to WinSta0 once for the duration of the poll, then restore on exit. Also harden get_input_desktop_name with the same fallback so callers that happen to run in service context still see the correct desktop name without paying for a winsta switch in the user-mode broker path. --- src/windows_mcp/service/secure_desktop.py | 82 +++++++++++++++++++---- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/src/windows_mcp/service/secure_desktop.py b/src/windows_mcp/service/secure_desktop.py index 757831ae..0cd5b93b 100644 --- a/src/windows_mcp/service/secure_desktop.py +++ b/src/windows_mcp/service/secure_desktop.py @@ -197,14 +197,38 @@ def get_input_desktop_name() -> str: Returns ``"Default"`` during normal desktop use and ``"Winlogon"`` while a UAC prompt is active. Works from user-mode too (used for detection in the broker via :func:`~windows_mcp.desktop.screenshot.is_secure_desktop_active`). + + When called from a LocalSystem service the process window station is + ``Service-0x0-3e7$``, not ``WinSta0`` — and ``OpenInputDesktop`` on the + service winstation never returns the user's input desktop. We first try a + plain ``OpenInputDesktop`` (cheap, works from user mode) and fall back to + momentarily attaching to ``WinSta0`` when that returns nothing useful. """ hdesk = _open_input_desktop(_DESKTOP_READOBJECTS) - if not hdesk: + if hdesk: + try: + name = _get_desktop_name(hdesk) + finally: + _user32.CloseDesktop(hdesk) + if name: + return name + + hwinsta_prev = _user32.GetProcessWindowStation() + hwinsta = _open_winsta0() + if not hwinsta: return "Default" try: - return _get_desktop_name(hdesk) + _user32.SetProcessWindowStation(hwinsta) + hdesk = _open_input_desktop(_DESKTOP_READOBJECTS) + if not hdesk: + return "Default" + try: + return _get_desktop_name(hdesk) or "Default" + finally: + _user32.CloseDesktop(hdesk) finally: - _user32.CloseDesktop(hdesk) + _user32.SetProcessWindowStation(hwinsta_prev) + _user32.CloseWindowStation(hwinsta) def capture_screenshot() -> bytes: @@ -497,16 +521,46 @@ def wait_for_uac_prompt(timeout_ms: int = 60_000, poll_ms: int = 250) -> dict | Returns a dict with the UIA tree of the consent dialog plus the extracted publisher, or ``None`` if the timeout expires without UAC firing. + + Attaches the calling process to ``WinSta0`` once for the duration of the + poll loop — the LocalSystem host service starts on ``Service-0x0-3e7$``, + and ``OpenInputDesktop`` on that station never returns the interactive + user's input desktop. Restoring the original window station on exit + keeps subsequent pipe handlers on their original station. """ deadline = time.monotonic() + (timeout_ms / 1000.0) - while time.monotonic() < deadline: - if get_input_desktop_name().lower() == "winlogon": - tree = uia_get_tree() - publisher = get_uac_publisher() - return { - "desktop": "Winlogon", - "publisher": publisher, - "tree": tree, - } - time.sleep(poll_ms / 1000.0) - return None + hwinsta_prev = _user32.GetProcessWindowStation() + hwinsta = _open_winsta0() + if hwinsta: + _user32.SetProcessWindowStation(hwinsta) + logger.info( + "wait_for_uac_prompt: polling winsta=%s for up to %dms (hwinsta=%s)", + "WinSta0" if hwinsta else "(failed-open)", timeout_ms, hwinsta, + ) + seen: dict[str, int] = {} + try: + while time.monotonic() < deadline: + name = "" + hdesk = _open_input_desktop(_DESKTOP_READOBJECTS) + if hdesk: + try: + name = _get_desktop_name(hdesk) or "" + finally: + _user32.CloseDesktop(hdesk) + seen[name] = seen.get(name, 0) + 1 + if name.lower() == "winlogon": + logger.info("wait_for_uac_prompt: Winlogon detected after %d polls", sum(seen.values())) + tree = uia_get_tree() + publisher = get_uac_publisher() + return { + "desktop": "Winlogon", + "publisher": publisher, + "tree": tree, + } + time.sleep(poll_ms / 1000.0) + logger.warning("wait_for_uac_prompt: timed out; saw desktops: %s", seen) + return None + finally: + if hwinsta: + _user32.SetProcessWindowStation(hwinsta_prev) + _user32.CloseWindowStation(hwinsta) From 32facab15b5c34853d11ce984016d968a45ad0b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 19:57:45 +0000 Subject: [PATCH 039/158] fix(service): retry uia tree fetch + fix test.ps1 server probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues seen in the VM e2e run: 1. wait_for_uac_prompt detected Winlogon (fired=True after the winsta switch fix landed) but uia_get_tree returned an empty list — consent.exe paints its window a few hundred ms after the input desktop flips. Add a tight retry around the UIA walk so we wait for the dialog to actually render before reporting its tree. 2. test.ps1's Wait-For-Url was hung on Invoke-WebRequest. Windows PowerShell 5.1 fires a WebException for non-2xx, but -ErrorAction SilentlyContinue swallows it before the catch block runs, so a server returning 406 ("Client must accept text/event-stream", which streamable-http always does on a plain GET) read as "no response". Switch to a plain TCP connect probe — we just need the port reachable so the MCP client can negotiate. --- src/windows_mcp/service/secure_desktop.py | 17 +++++++++++++++-- tests/manual/vm_e2e/test.ps1 | 23 +++++++++++++---------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/windows_mcp/service/secure_desktop.py b/src/windows_mcp/service/secure_desktop.py index 0cd5b93b..e28f2585 100644 --- a/src/windows_mcp/service/secure_desktop.py +++ b/src/windows_mcp/service/secure_desktop.py @@ -550,8 +550,21 @@ def wait_for_uac_prompt(timeout_ms: int = 60_000, poll_ms: int = 250) -> dict | seen[name] = seen.get(name, 0) + 1 if name.lower() == "winlogon": logger.info("wait_for_uac_prompt: Winlogon detected after %d polls", sum(seen.values())) - tree = uia_get_tree() - publisher = get_uac_publisher() + # consent.exe paints its window a few hundred ms after the input + # desktop flips to Winlogon. Retry the UIA walk until it sees at + # least one child, or 1.5 s elapses — beyond that the tree is + # really empty. + tree: list[dict] = [] + publisher = None + for attempt in range(8): + tree = uia_get_tree() or [] + publisher = get_uac_publisher() + if tree: + logger.info("wait_for_uac_prompt: tree captured after %d retries (%d top windows)", attempt, len(tree)) + break + time.sleep(0.2) + else: + logger.warning("wait_for_uac_prompt: Winlogon active but UIA tree stayed empty after 8 retries") return { "desktop": "Winlogon", "publisher": publisher, diff --git a/tests/manual/vm_e2e/test.ps1 b/tests/manual/vm_e2e/test.ps1 index 0d11d88d..22e1c14e 100644 --- a/tests/manual/vm_e2e/test.ps1 +++ b/tests/manual/vm_e2e/test.ps1 @@ -33,19 +33,22 @@ function Log($msg) { } function Wait-For-Url($url, $timeoutSec) { + # Probe at the TCP layer. We don't care that the streamable-http MCP server + # answers 406 on plain GET ("Client must accept text/event-stream") — we + # just need it bound and accepting connections so the real MCP client can + # negotiate. Invoke-WebRequest's WebException path interacts badly with + # -ErrorAction SilentlyContinue (the catch block never sees the response + # in Windows PowerShell 5.1, which is what test.ps1 runs under). + $uri = [Uri]$url $deadline = (Get-Date).AddSeconds($timeoutSec) while ((Get-Date) -lt $deadline) { + $client = New-Object System.Net.Sockets.TcpClient try { - $res = Invoke-WebRequest -Uri $url -Method Get -TimeoutSec 5 -UseBasicParsing -ErrorAction SilentlyContinue - # Anything that returns a status code (even 4xx) means the server is up - # and answering on this endpoint. MCP servers commonly answer 404/406 on - # plain GET because they expect the initialization handshake. - if ($res) { return $true } - } catch [System.Net.WebException] { - # WebException for HTTP responses (4xx/5xx) still means the server - # exists and responded — that's good enough to proceed. - if ($_.Exception.Response) { return $true } - } catch { } + $task = $client.ConnectAsync($uri.Host, $uri.Port) + if ($task.Wait(2000) -and $client.Connected) { return $true } + } catch { } finally { + try { $client.Close() } catch { } + } Start-Sleep -Seconds 2 } return $false From edbca0fe6beef771d4d960920d3feaab1ec3ca4a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 20:36:13 +0000 Subject: [PATCH 040/158] fix(vm-e2e): wait for WindowsMCPHost to actually stop before re-staging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop-Service is async — sc.exe delete unregistered the SCM entry but the python.exe service process kept windows-mcp.exe open under .venv, so the subsequent uv sync failed with "Access is denied" trying to overwrite the binary. Wait up to 20 s for the service to enter Stopped state, then sweep any leftover python.exe still anchored in the venv before robocopy. --- tests/manual/vm_e2e/setup.ps1 | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/manual/vm_e2e/setup.ps1 b/tests/manual/vm_e2e/setup.ps1 index ec3c210f..0b14f950 100644 --- a/tests/manual/vm_e2e/setup.ps1 +++ b/tests/manual/vm_e2e/setup.ps1 @@ -130,8 +130,25 @@ function Stage-Repo { if (Get-Service WindowsMCPHost -ErrorAction SilentlyContinue) { Log "Stopping/removing prior WindowsMCPHost before re-staging…" try { Stop-Service WindowsMCPHost -Force -ErrorAction SilentlyContinue } catch { } + # Wait up to 20s for SCM to mark the service Stopped — Stop-Service + # is async and uv sync will fail with "Access is denied" if the + # service process still has windows-mcp.exe open under .venv. + for ($i = 0; $i -lt 40; $i++) { + $svc = Get-Service WindowsMCPHost -ErrorAction SilentlyContinue + if (-not $svc -or $svc.Status -eq "Stopped") { break } + Start-Sleep -Milliseconds 500 + } try { sc.exe delete WindowsMCPHost | Out-Null } catch { } - Start-Sleep -Seconds 2 + # Belt-and-braces: nuke any leftover python.exe that's still holding + # files in C:\windows-mcp (e.g. the SCM marked the service Stopped + # but the host.py worker thread is mid-shutdown). + Get-Process -ErrorAction SilentlyContinue | + Where-Object { $_.Path -and $_.Path -like "$LocalRepo\.venv\*" } | + ForEach-Object { + Log "Killing leftover venv process pid=$($_.Id) name=$($_.ProcessName)" + try { $_.Kill() } catch { } + } + Start-Sleep -Seconds 1 } if (Test-Path $LocalRepo) { Log "Refreshing $LocalRepo" From 4de4654127526832cdeea2762faa081cc83c322c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 21:18:22 +0000 Subject: [PATCH 041/158] feat(service): user-session worker for Winlogon UIA (Session 0 isolation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A LocalSystem service thread in session 0 cannot enumerate UIA windows owned by user-session processes — even with SetProcessWindowStation to WinSta0 and SetThreadDesktop to Winlogon. consent.exe lives in the interactive session, behind the session-0 isolation boundary that Windows introduced in Vista. That's exactly what the previous VM run hit: the polling thread saw "Winlogon" as the input desktop (fired=True), but iuia walked an empty tree (top_windows=0). Retrying didn't help because permission semantics don't change with time. Splashtop / TeamViewer / AnyDesk solve this the same way: pair the LocalSystem service with a one-shot helper spawned into the active console user's session via CreateProcessAsUser, and have *that* helper do the UIA work. UIA from inside the user's session sees Winlogon normally. Wire that up: * new `windows_mcp.service.user_session_worker` module — entry point the helper runs as. Reuses the existing secure_desktop UIA primitives and emits one JSON line on stdout. * `secure_desktop._spawn_in_user_session()` — discovers the active console session, grabs the user's primary token, prefers the linked elevated token when available so the helper can reach consent.exe, CreateProcessAsUser's the worker with stdout/stderr redirected, drains both pipes, returns the parsed result. * `wait_for_uac_prompt` now routes the tree + publisher fetch through the worker once it detects Winlogon, with a short retry loop in case consent.exe is mid-paint. * `host.py` dispatch routes `uia_click_at`, `uia_type_at`, `uia_drag_from_to`, `uia_invoke` through the worker too — without it the click that should dismiss UAC under `allow_all` would hit the same isolation wall. --- src/windows_mcp/service/host.py | 22 +- src/windows_mcp/service/secure_desktop.py | 206 +++++++++++++++++- .../service/user_session_worker.py | 108 +++++++++ 3 files changed, 320 insertions(+), 16 deletions(-) create mode 100644 src/windows_mcp/service/user_session_worker.py diff --git a/src/windows_mcp/service/host.py b/src/windows_mcp/service/host.py index f8c287bf..f77b59bc 100644 --- a/src/windows_mcp/service/host.py +++ b/src/windows_mcp/service/host.py @@ -251,22 +251,29 @@ def _dispatch(req: Request) -> Response: allowed, reason = _enforce_policy("uia_invoke") if not allowed: return Response(id=req.id, error=f"policy denied: {reason}") - ok = secure_desktop.uia_invoke_element(req.params["name"]) + # uia_invoke_element matches by name on the input desktop — + # session 0 isolation makes that empty for user-session windows, + # so route the actual UIA call through a user-session worker. + ok = secure_desktop._spawn_in_user_session("invoke", req.params["name"]) return Response(id=req.id, result=ok) case "uia_click_at": allowed, reason = _enforce_policy("uia_click_at") if not allowed: return Response(id=req.id, error=f"policy denied: {reason}") - ok = secure_desktop.uia_click_at(req.params["x"], req.params["y"]) + ok = secure_desktop._spawn_in_user_session( + "click_at", str(req.params["x"]), str(req.params["y"]) + ) return Response(id=req.id, result=ok) case "uia_type_at": allowed, reason = _enforce_policy("uia_type_at") if not allowed: return Response(id=req.id, error=f"policy denied: {reason}") - ok = secure_desktop.uia_type_at( - req.params["x"], req.params["y"], req.params["text"] + ok = secure_desktop._spawn_in_user_session( + "type_at", + str(req.params["x"]), str(req.params["y"]), + req.params["text"], ) return Response(id=req.id, result=ok) @@ -274,9 +281,10 @@ def _dispatch(req: Request) -> Response: allowed, reason = _enforce_policy("uia_drag_from_to") if not allowed: return Response(id=req.id, error=f"policy denied: {reason}") - ok = secure_desktop.uia_drag_from_to( - req.params["x1"], req.params["y1"], - req.params["x2"], req.params["y2"], + ok = secure_desktop._spawn_in_user_session( + "drag_from_to", + str(req.params["x1"]), str(req.params["y1"]), + str(req.params["x2"]), str(req.params["y2"]), ) return Response(id=req.id, result=ok) diff --git a/src/windows_mcp/service/secure_desktop.py b/src/windows_mcp/service/secure_desktop.py index e28f2585..b56a0bfd 100644 --- a/src/windows_mcp/service/secure_desktop.py +++ b/src/windows_mcp/service/secure_desktop.py @@ -25,8 +25,11 @@ import ctypes import ctypes.wintypes import io +import json import logging import re +import subprocess +import sys import threading import time from contextlib import contextmanager @@ -511,6 +514,176 @@ def _collect(elem: Any, depth: int = 0) -> None: return None +# --------------------------------------------------------------------------- +# User-session worker spawn +# --------------------------------------------------------------------------- + + +def _spawn_in_user_session(*op_args: str, timeout: float = 30.0) -> Any: + """Run one ``user_session_worker`` op inside the active console user's session. + + Session 0 isolation blocks the LocalSystem service from walking UIA trees + owned by user-session processes (consent.exe is the case that matters for + UAC). We side-step that by ``CreateProcessAsUser``-ing a one-shot helper + into the interactive session — UIA from inside the user's session sees + Winlogon normally — and parse the JSON it writes to stdout. + + Uses the user's *linked* elevated token when available so the helper has + enough access to enumerate consent.exe; falls back to the standard user + token otherwise. + """ + import pywintypes + import win32api + import win32con + import win32event + import win32file + import win32pipe + import win32process + import win32security + import win32ts + + session_id = win32ts.WTSGetActiveConsoleSessionId() + if session_id in (0xFFFFFFFF, 0): + raise RuntimeError( + "no interactive console session is active " + "(WTSGetActiveConsoleSessionId returned no user session)" + ) + + user_token = win32ts.WTSQueryUserToken(session_id) + elevated_token = None + try: + elevated_token = win32security.GetTokenInformation( + user_token, win32security.TokenLinkedToken + ) + except Exception: + elevated_token = None + spawn_token = elevated_token or user_token + using_elevated = bool(elevated_token) + + sa = win32security.SECURITY_ATTRIBUTES() + sa.bInheritHandle = True + stdout_r, stdout_w = win32pipe.CreatePipe(sa, 0) + stderr_r, stderr_w = win32pipe.CreatePipe(sa, 0) + # Read ends stay in the service; do not let them leak into the child. + win32api.SetHandleInformation(stdout_r, win32con.HANDLE_FLAG_INHERIT, 0) + win32api.SetHandleInformation(stderr_r, win32con.HANDLE_FLAG_INHERIT, 0) + + cmd_line = subprocess.list2cmdline([ + sys.executable, + "-m", + "windows_mcp.service.user_session_worker", + *op_args, + ]) + + startup = win32process.STARTUPINFO() + startup.dwFlags = win32con.STARTF_USESTDHANDLES + startup.hStdInput = None + startup.hStdOutput = stdout_w + startup.hStdError = stderr_w + # Spawn on the interactive Default desktop; the worker re-binds its own + # thread to whichever desktop is currently the input desktop via + # _input_desktop() before touching UIA. + startup.lpDesktop = r"winsta0\default" + + user_env = win32process.CreateEnvironmentBlock(spawn_token, False) + + creation_flags = ( + win32con.CREATE_NO_WINDOW + | win32process.CREATE_UNICODE_ENVIRONMENT + | win32con.CREATE_NEW_CONSOLE + ) + + proc_handle = thread_handle = None + try: + proc_info = win32process.CreateProcessAsUser( + spawn_token, + None, + cmd_line, + None, + None, + True, + creation_flags, + user_env, + None, + startup, + ) + proc_handle, thread_handle, _pid, _tid = proc_info + finally: + # Now that the child has inherited the write ends we can drop ours. + try: win32file.CloseHandle(stdout_w) + except Exception: pass + try: win32file.CloseHandle(stderr_w) + except Exception: pass + try: win32api.CloseHandle(user_token) + except Exception: pass + if elevated_token: + try: win32api.CloseHandle(elevated_token) + except Exception: pass + + logger.info( + "spawned user-session worker pid=? session=%d elevated=%s op=%s", + session_id, using_elevated, " ".join(op_args), + ) + + stdout_chunks: list[bytes] = [] + stderr_chunks: list[bytes] = [] + + def _drain(handle: Any, sink: list[bytes]) -> None: + while True: + try: + _, chunk = win32file.ReadFile(handle, 4096) + except pywintypes.error as exc: + if exc.winerror in (109, 233): # BROKEN_PIPE, NO_DATA + return + raise + if not chunk: + return + sink.append(bytes(chunk)) + + import threading as _threading + err_thread = _threading.Thread(target=_drain, args=(stderr_r, stderr_chunks), daemon=True) + err_thread.start() + try: + _drain(stdout_r, stdout_chunks) + finally: + err_thread.join(timeout=1.0) + + try: + win32event.WaitForSingleObject(proc_handle, int(timeout * 1000)) + exit_code = win32process.GetExitCodeProcess(proc_handle) + finally: + try: win32file.CloseHandle(stdout_r) + except Exception: pass + try: win32file.CloseHandle(stderr_r) + except Exception: pass + try: win32api.CloseHandle(proc_handle) + except Exception: pass + try: win32api.CloseHandle(thread_handle) + except Exception: pass + + stdout_text = b"".join(stdout_chunks).decode("utf-8", errors="replace").strip() + stderr_text = b"".join(stderr_chunks).decode("utf-8", errors="replace").strip() + if stderr_text: + logger.info("user-session worker stderr: %s", stderr_text) + + if not stdout_text: + raise RuntimeError( + f"user-session worker produced no stdout " + f"(exit={exit_code}, stderr={stderr_text!r})" + ) + try: + payload = json.loads(stdout_text) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"user-session worker stdout not JSON (exit={exit_code}): {stdout_text!r}" + ) from exc + if not payload.get("ok"): + raise RuntimeError( + f"user-session worker error: {payload.get('error', 'unknown')}" + ) + return payload.get("result") + + # --------------------------------------------------------------------------- # WaitForUACPrompt # --------------------------------------------------------------------------- @@ -550,21 +723,36 @@ def wait_for_uac_prompt(timeout_ms: int = 60_000, poll_ms: int = 250) -> dict | seen[name] = seen.get(name, 0) + 1 if name.lower() == "winlogon": logger.info("wait_for_uac_prompt: Winlogon detected after %d polls", sum(seen.values())) - # consent.exe paints its window a few hundred ms after the input - # desktop flips to Winlogon. Retry the UIA walk until it sees at - # least one child, or 1.5 s elapses — beyond that the tree is - # really empty. + # consent.exe is a user-session process — Session 0 isolation + # blocks the LocalSystem service from walking its UIA tree even + # though we're attached to the right desktop. Route the walk + # through a user-session helper instead (CreateProcessAsUser + # into the active console session), retrying briefly to absorb + # consent.exe's paint delay. tree: list[dict] = [] publisher = None for attempt in range(8): - tree = uia_get_tree() or [] - publisher = get_uac_publisher() + try: + tree = _spawn_in_user_session("tree", timeout=20.0) or [] + except Exception as exc: + logger.warning("user-session tree spawn failed: %s", exc) + tree = [] + try: + publisher = _spawn_in_user_session("publisher", timeout=15.0) + except Exception as exc: + logger.warning("user-session publisher spawn failed: %s", exc) + publisher = None if tree: - logger.info("wait_for_uac_prompt: tree captured after %d retries (%d top windows)", attempt, len(tree)) + logger.info( + "wait_for_uac_prompt: tree captured after %d retries (%d top windows)", + attempt, len(tree), + ) break - time.sleep(0.2) + time.sleep(0.3) else: - logger.warning("wait_for_uac_prompt: Winlogon active but UIA tree stayed empty after 8 retries") + logger.warning( + "wait_for_uac_prompt: Winlogon active but user-session UIA tree stayed empty after 8 retries" + ) return { "desktop": "Winlogon", "publisher": publisher, diff --git a/src/windows_mcp/service/user_session_worker.py b/src/windows_mcp/service/user_session_worker.py new file mode 100644 index 00000000..b240af7f --- /dev/null +++ b/src/windows_mcp/service/user_session_worker.py @@ -0,0 +1,108 @@ +"""Worker executed inside the active console user's session. + +The LocalSystem host service spawns this helper via ``CreateProcessAsUser`` +when it needs to walk or click the Winlogon (Secure Desktop) UIA tree. + +Session 0 isolation prevents a service thread — even one bound to +``WinSta0\\Winlogon`` via ``SetProcessWindowStation`` + ``SetThreadDesktop`` +— from enumerating windows owned by user-session processes such as +``consent.exe``. A process *inside* the user's session is not subject to +that boundary, and with the user's elevated linked token it has enough +access to the Winlogon desktop to walk the consent dialog normally. + +Invocation:: + + python -m windows_mcp.service.user_session_worker [args...] + +The worker emits a single JSON line on stdout describing the result and +exits with code 0 on success / 1 on failure. The parent service reads the +pipe and forwards the payload over the named-pipe protocol. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys + +logger = logging.getLogger(__name__) + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description="windows-mcp user-session UIA worker") + sub = p.add_subparsers(dest="op", required=True) + sub.add_parser("tree", help="Walk the input desktop's UIA tree.") + sub.add_parser("publisher", help="Extract the UAC consent publisher string.") + sub.add_parser("windows", help="List top-level window titles on the input desktop.") + iv = sub.add_parser("invoke", help="Invoke the named UIA element.") + iv.add_argument("name") + cl = sub.add_parser("click_at", help="Invoke the UIA element at (x, y).") + cl.add_argument("x", type=int) + cl.add_argument("y", type=int) + ty = sub.add_parser("type_at", help="Type text into the UIA element at (x, y).") + ty.add_argument("x", type=int) + ty.add_argument("y", type=int) + ty.add_argument("text") + dr = sub.add_parser("drag_from_to", help="Drag from (x1, y1) to (x2, y2).") + dr.add_argument("x1", type=int) + dr.add_argument("y1", type=int) + dr.add_argument("x2", type=int) + dr.add_argument("y2", type=int) + return p + + +def main() -> int: + # Worker diagnostics go to stderr; stdout is reserved for the JSON payload + # the parent service reads back. + logging.basicConfig( + stream=sys.stderr, + level=logging.INFO, + format="[user-session-worker pid=%(process)d] %(message)s", + ) + args = _build_parser().parse_args() + + # Import lazily so a failed import surfaces as JSON instead of a Python + # traceback the parent can't parse. + try: + from windows_mcp.service import secure_desktop + except Exception as exc: + json.dump( + {"ok": False, "error": f"import failed: {exc}", "type": type(exc).__name__}, + sys.stdout, + ) + return 1 + + try: + if args.op == "tree": + result = secure_desktop.uia_get_tree() + elif args.op == "publisher": + result = secure_desktop.get_uac_publisher() + elif args.op == "windows": + result = secure_desktop.uia_get_window_titles() + elif args.op == "invoke": + result = secure_desktop.uia_invoke_element(args.name) + elif args.op == "click_at": + result = secure_desktop.uia_click_at(args.x, args.y) + elif args.op == "type_at": + result = secure_desktop.uia_type_at(args.x, args.y, args.text) + elif args.op == "drag_from_to": + result = secure_desktop.uia_drag_from_to(args.x1, args.y1, args.x2, args.y2) + else: + json.dump({"ok": False, "error": f"unknown op: {args.op}"}, sys.stdout) + return 1 + except Exception as exc: + logger.exception("op %s failed", args.op) + json.dump( + {"ok": False, "error": str(exc), "type": type(exc).__name__}, + sys.stdout, + ) + return 1 + + json.dump({"ok": True, "result": result}, sys.stdout) + sys.stdout.flush() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 015e50d20c1d9d30f352a5e47ed76acb7844a3a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 22:01:50 +0000 Subject: [PATCH 042/158] fix(service): drop CREATE_NEW_CONSOLE from CreateProcessAsUser flags CREATE_NEW_CONSOLE + STARTF_USESTDHANDLES + redirected pipes is a no-op at best and could pop a visible console in the user's session at worst. CREATE_NO_WINDOW + the pipe redirection we already have is enough. --- src/windows_mcp/service/secure_desktop.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/windows_mcp/service/secure_desktop.py b/src/windows_mcp/service/secure_desktop.py index b56a0bfd..3fdb5d5f 100644 --- a/src/windows_mcp/service/secure_desktop.py +++ b/src/windows_mcp/service/secure_desktop.py @@ -590,7 +590,6 @@ def _spawn_in_user_session(*op_args: str, timeout: float = 30.0) -> Any: creation_flags = ( win32con.CREATE_NO_WINDOW | win32process.CREATE_UNICODE_ENVIRONMENT - | win32con.CREATE_NEW_CONSOLE ) proc_handle = thread_handle = None From 5cc2aa4fc4d8495236d4117118fa33e7ef7e3545 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 00:32:16 +0000 Subject: [PATCH 043/158] feat(service): UIAccess-signed worker for cross-integrity Winlogon UIA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last debugging pass showed the user-session worker was correctly spawning into the active console session via CreateProcessAsUser, but UIA still returned an empty tree for consent.exe. Cause is the third Windows boundary: UIAccess. consent.exe runs at System integrity, and even the user's elevated linked admin token can't enumerate higher- integrity UI unless the calling .exe is 1. manifested with uiAccess="true", 2. Authenticode-signed, 3. launched from a "trusted location" (Program Files / WinDir). Wire all three end-to-end while leaving signing as the only gap: * packaging/uia_worker.manifest — uiAccess=true + requireAdministrator, with a long comment explaining why each flag is mandatory. * packaging/uia_worker.spec — PyInstaller spec that freezes windows_mcp.service.user_session_worker into a standalone .exe with the manifest embedded. Excludes fastmcp/mcp/uvicorn/etc to keep the worker minimal. * `windows-mcp service secure-desktop install --uia-worker ` — copies the signed binary into %ProgramFiles%\WindowsMCP\, locks the ACL to BUILTIN\Administrators + SYSTEM, and records the absolute path in HKLM under SecureDesktop\UiaWorkerPath. * secure_desktop._spawn_in_user_session now resolves the worker command line via policy.read_uia_worker_path() — uses the signed binary when registered, falls back to `python -m windows_mcp.service.user_session_worker` otherwise. The fallback is honest about its limits in the install output and in the docs. * Uninstall removes the binary and the registry entry. * docs/secure-desktop.md walks Jeomon (and any future maintainer) through build → sign → install → uninstall, including why EnableLUA=0 and ConsentPromptBehaviorAdmin=0 are NOT acceptable workarounds. Signing itself stays out of the repo: an Authenticode cert belongs in the release pipeline's secret store, not in git. --- .gitignore | 4 + docs/secure-desktop.md | 130 ++++++++++++++++++++++ packaging/uia_worker.manifest | 45 ++++++++ packaging/uia_worker.spec | 90 +++++++++++++++ src/windows_mcp/__main__.py | 90 +++++++++++++++ src/windows_mcp/service/policy.py | 39 +++++++ src/windows_mcp/service/secure_desktop.py | 22 +++- 7 files changed, 414 insertions(+), 6 deletions(-) create mode 100644 docs/secure-desktop.md create mode 100644 packaging/uia_worker.manifest create mode 100644 packaging/uia_worker.spec diff --git a/.gitignore b/.gitignore index e81c0323..4153c6d6 100755 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,10 @@ MANIFEST # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec +# ...except the UIAccess worker spec + manifest, which are checked-in inputs +# to the build (signing happens later in the release pipeline). +!packaging/*.manifest +!packaging/*.spec # Installer logs pip-log.txt diff --git a/docs/secure-desktop.md b/docs/secure-desktop.md new file mode 100644 index 00000000..2c24eaab --- /dev/null +++ b/docs/secure-desktop.md @@ -0,0 +1,130 @@ +# Secure Desktop / UAC support + +Per [issue #236](https://github.com/CursorTouch/Windows-MCP/issues/236), an +optional service mode lets an LLM agent **see and click UAC consent +dialogs** that fire on the Winlogon (Secure Desktop). Without this, every +elevation interrupt halts the agent. + +The service ships in two pieces. Both must be installed for the full +"detect *and* dismiss" flow to work end-to-end: + +1. **`WindowsMCPHost`** — a LocalSystem Windows service. Detects when the + input desktop flips to Winlogon (UAC fired), enforces the consent + policy persisted in the registry, and brokers UIA / click requests to + the user-session worker over a named pipe. +2. **`windows-mcp-uia-worker.exe`** — an Authenticode-signed, + UIAccess-enabled binary that runs *inside* the active console user's + session, walks consent.exe's UIA tree, and returns it to the host + service. + +Why two pieces? Two Windows boundaries get in the way of "just walk the +tree from the service": + +* **Session 0 isolation**: a service in session 0 cannot enumerate UIA + elements owned by user-session processes, even after `SetThreadDesktop` + to Winlogon. The host service polls the input desktop name (which + *does* cross the boundary) and dispatches via `CreateProcessAsUser` + into the user's session. +* **UIAccess + integrity levels**: consent.exe runs at *System* + integrity. A user-session process — even running with the user's + elevated linked admin token (high integrity) — is denied UI + enumeration of higher-integrity processes unless its application + manifest declares `uiAccess="true"` **and** the binary is + Authenticode-signed **and** it was launched from a trusted path + (`%ProgramFiles%` or `%WinDir%`). The worker is built and shipped + with all three. + +If you install the host service *without* the signed worker, the service +falls back to a plain `python -m windows_mcp.service.user_session_worker` +spawn. That fallback works for `WaitForUACPrompt`'s detection half +(`fired=True`, `desktop="Winlogon"`, `policy=…`), but the UIA tree it +returns will be empty — UIAccess denies cross-integrity enumeration to +unsigned binaries. + +## Building the signed worker + +1. Build the unsigned `.exe`: + + ``` + uv pip install pyinstaller + uv run pyinstaller packaging/uia_worker.spec --clean + ``` + + The result is `dist/windows-mcp-uia-worker.exe` with the + `packaging/uia_worker.manifest` embedded + (`uiAccess="true"` / `requireAdministrator`). + +2. Sign it with an Authenticode code-signing certificate. EV is preferred + but not required. **Do not check the cert into git** — keep it in your + release pipeline's secret store. + + ``` + signtool sign /fd SHA256 /a /tr http://timestamp.digicert.com /td SHA256 ^ + dist\windows-mcp-uia-worker.exe + ``` + +3. (Optional) verify: + + ``` + signtool verify /pa dist\windows-mcp-uia-worker.exe + ``` + +If you skip step 2 (or sign with a self-signed cert that the target +machine doesn't trust), Windows will refuse to grant UIAccess at launch +time — the worker will run, but UIA against consent.exe will silently +return nothing. + +## Installing on the target machine + +Run the install command **elevated**, pointing at the signed binary: + +``` +uv run windows-mcp service secure-desktop install ^ + --policy block ^ + --uia-worker C:\path\to\windows-mcp-uia-worker.exe +``` + +What the install does: + +* Registers the LocalSystem `WindowsMCPHost` service to auto-start at + boot. +* Copies the signed worker into `%ProgramFiles%\WindowsMCP\` and locks + the directory ACL to `BUILTIN\Administrators` + `NT AUTHORITY\SYSTEM` + (so a non-admin user cannot replace the worker and trick the service + into running attacker-supplied UIA code as themselves). +* Records the installed path under + `HKLM\SOFTWARE\Windows-MCP\SecureDesktop\UiaWorkerPath` so the host + service knows to spawn the signed binary instead of the unsigned + fallback. +* Persists the consent policy (`block` / `allow_with_match` / + `allow_all`) under the same registry key. + +## Uninstalling + +``` +uv run windows-mcp service secure-desktop uninstall +``` + +Stops the service, removes the SCM registration, deletes the registry +key, and removes the worker binary from `%ProgramFiles%\WindowsMCP\`. + +## Threat model and "why not just disable UAC" + +Setting `EnableLUA=0` or `ConsentPromptBehaviorAdmin=0` would also +"solve" the problem in a trivial sense — every elevation just succeeds +silently. We don't do this because: + +1. UAC then no longer protects against *any* process the user didn't + start themselves. The agent's elevation handling has to be a + per-prompt decision, not a global "always yes". +2. `EnableLUA=0` disables file/registry virtualization and breaks + AppContainer / Modern apps in the same session, including Microsoft + Store apps. +3. The agent loses any audit trail of *what* it just authorized — no + publisher string, no app name, no opportunity to refuse a specific + prompt under `policy=allow_with_match`. + +Keeping UAC at its strictest setting and giving the agent eyes into the +prompt via this two-process pattern is the same approach Microsoft's +own accessibility tools use (Magnifier, Narrator) — UIAccess is the +documented, supported mechanism for cross-integrity UI access. diff --git a/packaging/uia_worker.manifest b/packaging/uia_worker.manifest new file mode 100644 index 00000000..33767943 --- /dev/null +++ b/packaging/uia_worker.manifest @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + diff --git a/packaging/uia_worker.spec b/packaging/uia_worker.spec new file mode 100644 index 00000000..8113f3e8 --- /dev/null +++ b/packaging/uia_worker.spec @@ -0,0 +1,90 @@ +# PyInstaller spec for the UIAccess-enabled worker binary. +# +# Build: +# uv run pyinstaller packaging/uia_worker.spec --clean +# +# Output: +# dist/windows-mcp-uia-worker.exe (single-file, manifested) +# +# Sign (in CI or release pipeline; do NOT check the cert in): +# signtool sign /fd SHA256 /a /tr http://timestamp.digicert.com /td SHA256 \ +# dist/windows-mcp-uia-worker.exe +# +# Install (run elevated on the target machine): +# uv run windows-mcp service secure-desktop install \ +# --policy block \ +# --uia-worker dist\windows-mcp-uia-worker.exe +# +# That last command copies the signed worker into +# %ProgramFiles%\WindowsMCP\windows-mcp-uia-worker.exe +# (a trusted path) and records the absolute path in HKLM so the host service +# knows to spawn it instead of `python -m windows_mcp.service.user_session_worker`. + +# ruff: noqa -- PyInstaller specs run as Python with the spec API in scope. + +import os + +block_cipher = None + +a = Analysis( + ['../src/windows_mcp/service/user_session_worker.py'], + pathex=[os.path.abspath('../src')], + binaries=[], + datas=[], + hiddenimports=[ + 'windows_mcp', + 'windows_mcp.service', + 'windows_mcp.service.secure_desktop', + 'comtypes', + 'comtypes.client', + 'comtypes.gen', + 'win32api', + 'win32con', + 'win32process', + 'win32security', + 'win32ts', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + # Keep the worker as small as possible — it just walks UIA and prints + # JSON. No need for the rest of the windows_mcp tool surface. + 'fastmcp', + 'mcp', + 'starlette', + 'uvicorn', + 'sse_starlette', + 'pydantic', + 'posthog', + ], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='windows-mcp-uia-worker', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + upx_exclude=[], + runtime_tmpdir=None, + console=True, # console exe — service captures stdout/stderr. + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + manifest='uia_worker.manifest', # uiAccess=true, requireAdministrator +) diff --git a/src/windows_mcp/__main__.py b/src/windows_mcp/__main__.py index e4c5aabf..61875857 100755 --- a/src/windows_mcp/__main__.py +++ b/src/windows_mcp/__main__.py @@ -789,6 +789,52 @@ def _path_is_admin_only(path: str) -> bool: return False +_UIA_WORKER_INSTALL_DIR = os.path.join( + os.environ.get("ProgramFiles", r"C:\Program Files"), "WindowsMCP" +) +_UIA_WORKER_INSTALL_NAME = "windows-mcp-uia-worker.exe" + + +def _install_uia_worker(src_path: str) -> str: + """Copy a UIAccess-signed worker into ``%ProgramFiles%\\WindowsMCP\\`` and + lock its ACLs to admin-only. Returns the absolute installed path. + + Two reasons we *must* land in Program Files (not a user dir): + + 1. Windows only grants UIAccess to manifested + signed binaries that + live in a "trusted location" — Program Files and the Windows + directory qualify by default. Copying to %TEMP% or %LOCALAPPDATA% + silently downgrades the binary back to "no UIAccess" and the + consent.exe tree walks return empty. + 2. Anything readable by the LocalSystem service that the standard + user can rewrite is a SYSTEM-elevation hole. Locking the dir to + BUILTIN\\Administrators + SYSTEM closes that. + """ + import shutil + import subprocess + + src = os.path.abspath(src_path) + if not os.path.isfile(src): + raise click.ClickException(f"UIA worker not found: {src}") + os.makedirs(_UIA_WORKER_INSTALL_DIR, exist_ok=True) + dest = os.path.join(_UIA_WORKER_INSTALL_DIR, _UIA_WORKER_INSTALL_NAME) + shutil.copy2(src, dest) + + # icacls: reset ACL, then grant SYSTEM + Administrators full control, + # remove inherited Users entries. Best effort -- if icacls fails the + # binary is still functional, just less defensively ACLed. + for cmd in ( + ["icacls", _UIA_WORKER_INSTALL_DIR, "/inheritance:r"], + ["icacls", _UIA_WORKER_INSTALL_DIR, "/grant", "*S-1-5-18:(OI)(CI)F"], # SYSTEM + ["icacls", _UIA_WORKER_INSTALL_DIR, "/grant", "*S-1-5-32-544:(OI)(CI)F"], # Administrators + ): + try: + subprocess.run(cmd, capture_output=True, check=False) + except Exception: + pass + return dest + + def _verify_install_paths_are_admin_only() -> None: """Raise ClickException if the Python interpreter or windows_mcp package live in a user-writable location. @@ -904,11 +950,28 @@ def service_secure_desktop(): "the user can replace the binary and gain SYSTEM at next service start." ), ) +@click.option( + "--uia-worker", + "uia_worker", + type=click.Path(exists=True, dir_okay=False, resolve_path=True), + default=None, + help=( + "Path to a UIAccess-signed worker .exe (built from " + "packaging/uia_worker.spec and Authenticode-signed). When provided, " + "the binary is copied to %ProgramFiles%\\WindowsMCP\\ (a trusted " + "path) and registered in HKLM so the LocalSystem service spawns it " + "in the user session for cross-integrity UIA against consent.exe. " + "Without this flag the service falls back to a plain python worker " + "that *cannot* walk the consent dialog tree -- see " + "docs/secure-desktop.md." + ), +) def service_secure_desktop_install( force: bool, policy: str | None, allow_publisher: tuple[str, ...], allow_user_binary_path: bool, + uia_worker: str | None, ): """Install and start the Secure Desktop host service (requires elevation).""" _require_win32() @@ -1025,6 +1088,22 @@ def service_secure_desktop_install( click.echo(f"Warning: could not persist UAC policy: {exc}") click.echo(" Service will refuse auto-clicks until policy is set.") + if uia_worker: + try: + installed = _install_uia_worker(uia_worker) + policy_mod.write_uia_worker_path(installed) + click.echo(f"UIA worker : {installed}") + except Exception as exc: + click.echo(f"Warning: failed to install UIA worker: {exc}") + click.echo(" Service will use the unsigned fallback; " + "consent.exe tree walking will return empty.") + else: + click.echo( + "UIA worker : (none) -- service will use the unsigned " + "python fallback; cross-integrity UIA against consent.exe will " + "return empty. Re-install with --uia-worker to enable." + ) + click.echo("\nThe host service is now running as NT AUTHORITY\\SYSTEM.") click.echo("It will restart automatically at each boot.") click.echo("Run `windows-mcp service secure-desktop set-policy ` to change without reinstalling.") @@ -1057,6 +1136,17 @@ def service_secure_desktop_uninstall(): except Exception as exc: click.echo(f"Warning: could not clear UAC policy registry key: {exc}") + # Best-effort: remove the installed UIA worker binary. The registry + # entry is gone with the parent key above, so even if the .exe lingers + # the service won't try to spawn it on next install. + installed = os.path.join(_UIA_WORKER_INSTALL_DIR, _UIA_WORKER_INSTALL_NAME) + if os.path.isfile(installed): + try: + os.remove(installed) + click.echo(f"UIA worker : removed {installed}") + except Exception as exc: + click.echo(f"Warning: could not remove UIA worker: {exc}") + @service_secure_desktop.command("set-policy") @click.argument("policy_name", type=click.Choice(["block", "allow_with_match", "allow_all"])) diff --git a/src/windows_mcp/service/policy.py b/src/windows_mcp/service/policy.py index 8bd665da..5ca5e487 100644 --- a/src/windows_mcp/service/policy.py +++ b/src/windows_mcp/service/policy.py @@ -39,6 +39,7 @@ _REG_PATH = r"SOFTWARE\Windows-MCP\SecureDesktop" _REG_POLICY = "Policy" _REG_ALLOWLIST = "PublishersAllowlist" +_REG_UIA_WORKER = "UiaWorkerPath" @dataclass @@ -143,6 +144,44 @@ def delete_from_registry() -> None: logger.warning("Could not delete policy registry key: %s", exc) +def read_uia_worker_path() -> str | None: + """Read the path of the installed UIAccess-signed worker, if any. + + Returns ``None`` when no signed worker is registered. The service falls + back to spawning ``python -m windows_mcp.service.user_session_worker`` + directly in that case — which works for everything *except* walking the + Winlogon UIA tree across the integrity-level boundary (consent.exe runs + at System integrity, and only a manifested + signed binary in a + trusted path is granted UIAccess). See docs/secure-desktop.md. + """ + try: + import winreg + except ImportError: + return None + try: + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, _REG_PATH, access=winreg.KEY_READ + ) as key: + value, _ = winreg.QueryValueEx(key, _REG_UIA_WORKER) + value = str(value).strip() + return value or None + except FileNotFoundError: + return None + except OSError as exc: + logger.warning("Reading UIA worker path from registry failed: %s", exc) + return None + + +def write_uia_worker_path(path: str) -> None: + """Persist the installed UIAccess worker's path to HKLM. Requires elevation.""" + import winreg + with winreg.CreateKeyEx( + winreg.HKEY_LOCAL_MACHINE, _REG_PATH, access=winreg.KEY_SET_VALUE + ) as key: + winreg.SetValueEx(key, _REG_UIA_WORKER, 0, winreg.REG_SZ, path) + logger.info("Recorded UIA worker path: %s", path) + + def resolve_install_time_policy( cli_policy: str | None, cli_allowlist: list[str] | None, diff --git a/src/windows_mcp/service/secure_desktop.py b/src/windows_mcp/service/secure_desktop.py index 3fdb5d5f..82c40b8c 100644 --- a/src/windows_mcp/service/secure_desktop.py +++ b/src/windows_mcp/service/secure_desktop.py @@ -568,12 +568,22 @@ def _spawn_in_user_session(*op_args: str, timeout: float = 30.0) -> Any: win32api.SetHandleInformation(stdout_r, win32con.HANDLE_FLAG_INHERIT, 0) win32api.SetHandleInformation(stderr_r, win32con.HANDLE_FLAG_INHERIT, 0) - cmd_line = subprocess.list2cmdline([ - sys.executable, - "-m", - "windows_mcp.service.user_session_worker", - *op_args, - ]) + # Prefer a UIAccess-signed worker installed in a trusted path. Without + # it, the unsigned fallback (this Python module) cannot enumerate + # consent.exe's UIA tree across the integrity boundary — see + # docs/secure-desktop.md and policy.read_uia_worker_path(). + from windows_mcp.service import policy as _policy_mod + signed_worker = _policy_mod.read_uia_worker_path() + if signed_worker: + argv = [signed_worker, *op_args] + else: + argv = [ + sys.executable, + "-m", + "windows_mcp.service.user_session_worker", + *op_args, + ] + cmd_line = subprocess.list2cmdline(argv) startup = win32process.STARTUPINFO() startup.dwFlags = win32con.STARTF_USESTDHANDLES From 176498f3a1df9e086e1a15c580ae34c42e917ed8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 00:44:01 +0000 Subject: [PATCH 044/158] feat(service): route all UIA dispatch via user-session worker + VM build-and-sign helper Two pieces: * host.py dispatch: uia_windows / uia_tree / get_uac_publisher and the publisher lookup inside _enforce_policy were still calling secure_desktop.* directly from session 0. Same Session 0 + UIAccess walls as the click path; they would return empty against consent.exe. Route them through _spawn_in_user_session like the others. * tests/manual/vm_e2e/build-and-sign-uia-worker.ps1: VM-only helper. Builds the worker via PyInstaller, generates a one-shot self-signed code-signing cert, plants it in LocalMachine\Root and LocalMachine\TrustedPublisher so the OS treats the binary as Authenticode-trusted, signs the exe with Set-AuthenticodeSignature, and sets EnableSecureUIAPaths=0 (Win10+ trusted-path requirement) so the binary works from C:\windows-mcp\packaging\dist\. setup.ps1 now calls it and threads the resulting path into `windows-mcp service secure-desktop install --uia-worker`. This is the test-environment shortcut around the requirement that production deploys ship a commercially-signed worker into %ProgramFiles%\WindowsMCP\. Self-signed + EnableSecureUIAPaths=0 is sufficient for the dockur e2e harness to verify the UIA tree actually populates against consent.exe. --- src/windows_mcp/service/host.py | 14 ++- .../vm_e2e/build-and-sign-uia-worker.ps1 | 111 ++++++++++++++++++ tests/manual/vm_e2e/setup.ps1 | 38 +++++- 3 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 tests/manual/vm_e2e/build-and-sign-uia-worker.ps1 diff --git a/src/windows_mcp/service/host.py b/src/windows_mcp/service/host.py index f77b59bc..cd164e74 100644 --- a/src/windows_mcp/service/host.py +++ b/src/windows_mcp/service/host.py @@ -199,7 +199,11 @@ def _enforce_policy(operation: str) -> tuple[bool, str]: if secure_desktop.get_input_desktop_name().lower() != "winlogon": return True, "input desktop is not Winlogon" pol = policy.read_from_registry() - publisher = secure_desktop.get_uac_publisher() + try: + publisher = secure_desktop._spawn_in_user_session("publisher", timeout=15.0) + except Exception as exc: + logger.warning("policy: user-session publisher lookup failed: %s", exc) + publisher = None allowed, reason = pol.allows_auto_click(publisher) logger.info( "policy check: op=%s desktop=Winlogon policy=%s publisher=%r → %s (%s)", @@ -224,15 +228,17 @@ def _dispatch(req: Request) -> Response: return Response(id=req.id, result=base64.b64encode(png).decode()) case "uia_windows": - titles = secure_desktop.uia_get_window_titles() + # Same Session 0 isolation as uia_invoke / uia_click_at — + # walk in the user session via the worker. + titles = secure_desktop._spawn_in_user_session("windows") return Response(id=req.id, result=titles) case "uia_tree": - tree = secure_desktop.uia_get_tree() + tree = secure_desktop._spawn_in_user_session("tree") return Response(id=req.id, result=tree) case "get_uac_publisher": - pub = secure_desktop.get_uac_publisher() + pub = secure_desktop._spawn_in_user_session("publisher") return Response(id=req.id, result=pub) case "wait_for_uac_prompt": diff --git a/tests/manual/vm_e2e/build-and-sign-uia-worker.ps1 b/tests/manual/vm_e2e/build-and-sign-uia-worker.ps1 new file mode 100644 index 00000000..0d290336 --- /dev/null +++ b/tests/manual/vm_e2e/build-and-sign-uia-worker.ps1 @@ -0,0 +1,111 @@ +# build-and-sign-uia-worker.ps1 — VM-only helper. +# +# Produces a fully-functional UIAccess worker .exe inside the dockur VM +# without dragging in a commercial code-signing cert. The trick is that +# Windows accepts any Authenticode signature whose root CA is trusted on +# the local box — so we generate a one-shot self-signed cert, plant it +# in the machine's Trusted Root + Trusted Publisher stores, and sign +# with that. +# +# We also flip HKLM\...\Policies\System\EnableSecureUIAPaths = 0 so the +# binary can live anywhere (not just %ProgramFiles%). Production deploys +# should NOT do this — they should ship a real Authenticode-signed +# worker into %ProgramFiles%\WindowsMCP\. +# +# Inputs: +# $LocalRepo : C:\windows-mcp (already mirrored from the share by setup.ps1) +# +# Outputs: +# $LocalRepo\dist\windows-mcp-uia-worker.exe (signed) +# +# Prints the path to stdout on success. + +param( + [string]$LocalRepo = "C:\windows-mcp" +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +function Log($msg) { Write-Host "[uia-sign] $msg" } + +# ----- 1. install PyInstaller into the venv -------------------------------- +Log "Installing PyInstaller into $LocalRepo\.venv" +Push-Location $LocalRepo +try { + $env:UV_INSECURE_HOST = "pypi.org files.pythonhosted.org github.com astral.sh objects.githubusercontent.com" + & uv pip install pyinstaller 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { throw "uv pip install pyinstaller failed ($LASTEXITCODE)" } +} finally { Pop-Location } + +# ----- 2. build the unsigned worker --------------------------------------- +Log "Building windows-mcp-uia-worker.exe via PyInstaller" +Push-Location (Join-Path $LocalRepo "packaging") +try { + & "$LocalRepo\.venv\Scripts\pyinstaller.exe" uia_worker.spec --clean --noconfirm 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed ($LASTEXITCODE)" } +} finally { Pop-Location } + +$exe = Join-Path $LocalRepo "packaging\dist\windows-mcp-uia-worker.exe" +if (-not (Test-Path $exe)) { throw "Build succeeded but $exe not found." } +Log "Built: $exe" + +# ----- 3. self-signed code-signing cert ----------------------------------- +$certSubject = "CN=WindowsMCP-Dev-Test-Only" +$existing = Get-ChildItem Cert:\LocalMachine\My -CodeSigningCert -ErrorAction SilentlyContinue | + Where-Object { $_.Subject -eq $certSubject } | Select-Object -First 1 +if ($existing) { + Log "Reusing existing cert thumbprint=$($existing.Thumbprint)" + $cert = $existing +} else { + Log "Creating self-signed code-signing cert ($certSubject)" + $cert = New-SelfSignedCertificate ` + -Type CodeSigningCert ` + -Subject $certSubject ` + -KeyUsage DigitalSignature ` + -KeyAlgorithm RSA -KeyLength 2048 ` + -HashAlgorithm SHA256 ` + -NotAfter (Get-Date).AddYears(5) ` + -CertStoreLocation Cert:\LocalMachine\My ` + -KeyExportPolicy Exportable + Log "Created cert thumbprint=$($cert.Thumbprint)" +} + +# Make sure the cert is trusted by the local machine: Trusted Root + +# Trusted Publisher. Both stores need the same cert for Authenticode +# to be considered "trusted by the local OS" during UIAccess checks. +foreach ($store in @("Root", "TrustedPublisher")) { + $storeObj = New-Object System.Security.Cryptography.X509Certificates.X509Store ` + $store, "LocalMachine" + $storeObj.Open("ReadWrite") + if (-not ($storeObj.Certificates | Where-Object { $_.Thumbprint -eq $cert.Thumbprint })) { + $storeObj.Add($cert) + Log "Added cert to LocalMachine\$store" + } + $storeObj.Close() +} + +# ----- 4. sign the exe ---------------------------------------------------- +Log "Signing $exe" +$sig = Set-AuthenticodeSignature -FilePath $exe -Certificate $cert ` + -HashAlgorithm SHA256 -IncludeChain All +if ($sig.Status -ne "Valid") { + throw "Set-AuthenticodeSignature returned Status=$($sig.Status): $($sig.StatusMessage)" +} +Log "Signature status: $($sig.Status)" + +# ----- 5. flip the trusted-path requirement off -------------------------- +# UIAccess on Win10+ also requires the binary to live in a "trusted path" +# (Program Files / WinDir). Setting EnableSecureUIAPaths=0 lifts that +# restriction so we can run from $LocalRepo\packaging\dist\. Production +# deployment installs into %ProgramFiles%\WindowsMCP\ instead and leaves +# this policy alone. +$polKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" +$cur = (Get-ItemProperty -Path $polKey -Name EnableSecureUIAPaths -ErrorAction SilentlyContinue).EnableSecureUIAPaths +if ($cur -ne 0) { + Log "Setting EnableSecureUIAPaths=0 (was $cur) — VM-only override" + Set-ItemProperty -Path $polKey -Name EnableSecureUIAPaths -Type DWord -Value 0 +} + +# ----- 6. report path to caller ------------------------------------------ +Write-Output $exe diff --git a/tests/manual/vm_e2e/setup.ps1 b/tests/manual/vm_e2e/setup.ps1 index 0b14f950..249c5f56 100644 --- a/tests/manual/vm_e2e/setup.ps1 +++ b/tests/manual/vm_e2e/setup.ps1 @@ -178,13 +178,47 @@ function Set-Uac-Config { Log "UAC: EnableLUA=1 ConsentPromptBehaviorAdmin=2 PromptOnSecureDesktop=1" } +function Build-And-Sign-Uia-Worker { + # Builds the UIAccess-enabled worker .exe, self-signs it, plants the + # cert in the machine's trust store, flips EnableSecureUIAPaths=0. + # Returns the absolute path of the signed binary (or $null on failure). + $signer = Join-Path $Repo "tests\manual\vm_e2e\build-and-sign-uia-worker.ps1" + if (-not (Test-Path $signer)) { + Log "WARN: $signer missing — skipping signed-worker build." + return $null + } + Log "Building + self-signing UIA worker…" + $signedPath = $null + Invoke-Native "uia-worker-sign.log" { + $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File $signer -LocalRepo $LocalRepo 2>&1 + $output | Out-Host + # The signer prints the path of the final exe on its last stdout line. + $script:signedPath = ($output | Where-Object { $_ -is [string] } | + Select-Object -Last 1).ToString().Trim() + } + if ($signedPath -and (Test-Path $signedPath)) { + Log "UIA worker signed: $signedPath" + return $signedPath + } + Log "WARN: signed UIA worker not produced; install will use unsigned fallback." + return $null +} + function Install-Host-Service { + $signedWorker = Build-And-Sign-Uia-Worker Push-Location $LocalRepo try { Log "Installing host service (allow-user-binary-path because this is a VM)…" Invoke-Native "install-host.log" { - & uv run windows-mcp service secure-desktop install ` - --policy allow_all --allow-user-binary-path --force + if ($signedWorker) { + & uv run windows-mcp service secure-desktop install ` + --policy allow_all --allow-user-binary-path ` + --uia-worker $signedWorker --force + } else { + & uv run windows-mcp service secure-desktop install ` + --policy allow_all --allow-user-binary-path --force + } } } finally { Pop-Location } } From 22ee9314b46f872d7dca365354aa195170266d88 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 01:28:57 +0000 Subject: [PATCH 045/158] feat(service): in-CLI build + self-sign of UIAccess worker Replace the test-harness-only build script with a production install flow shipped in the wheel. End-user experience now is just: windows-mcp service secure-desktop install The install command prompts (verbose explanation of why signing is needed, default Yes), and on confirmation: 1. Installs PyInstaller into the current Python env if missing (~25 MB, one time). 2. Freezes windows_mcp.service.user_session_worker into a single windows-mcp-uia-worker.exe, with the bundled _uia_worker.manifest (uiAccess="true", requireAdministrator) embedded. 3. Generates a self-signed code-signing cert in Cert:\LocalMachine\My, plants it in \Root + \TrustedPublisher so Windows trusts signatures made with it on *this machine only*. 4. Signs the frozen exe via Set-AuthenticodeSignature. 5. Copies the signed binary into %ProgramFiles%\WindowsMCP\ and locks the directory ACL to Administrators + SYSTEM. 6. Records the installed path under HKLM\SOFTWARE\Windows-MCP\SecureDesktop\UiaWorkerPath so the host service spawns the signed binary instead of the unsigned fallback. Non-interactive flags for automation: --self-sign-uia-worker run the flow without prompting --no-uia-worker skip the worker, install in detect-only mode --uia-worker commercial-signed binary supplied by caller Uninstall reverses everything: stops + removes the service, clears the registry key, removes the signed exe, and yanks the self-signed cert from all three LocalMachine cert stores. Wheel + packaging: * _uia_worker.manifest moves into the package as package_data so it ships with `pip install windows-mcp`; the build is fully self-contained. * packaging/uia_worker.spec deleted -- replaced by an inline PyInstaller CLI invocation that uses the bundled manifest and only pulls in the modules the worker actually imports (excludes fastmcp / mcp / starlette / uvicorn / pydantic / posthog to keep the frozen exe under ~15 MB). Test harness: * tests/manual/vm_e2e/setup.ps1 now passes --self-sign-uia-worker to install; the production code path is the test path. * tests/manual/vm_e2e/build-and-sign-uia-worker.ps1 deleted. Docs: docs/secure-desktop.md rewritten around the new prompt-driven flow, including a "why we self-sign" section that maps the UIAccess requirements to the local-only cert. --- .gitignore | 7 +- docs/secure-desktop.md | 132 ++++---- packaging/uia_worker.spec | 90 ------ pyproject.toml | 3 + src/windows_mcp/__main__.py | 155 +++++++++- .../windows_mcp/service/_uia_worker.manifest | 0 src/windows_mcp/service/uia_worker_install.py | 283 ++++++++++++++++++ .../vm_e2e/build-and-sign-uia-worker.ps1 | 111 ------- tests/manual/vm_e2e/setup.ps1 | 47 +-- 9 files changed, 505 insertions(+), 323 deletions(-) delete mode 100644 packaging/uia_worker.spec rename packaging/uia_worker.manifest => src/windows_mcp/service/_uia_worker.manifest (100%) create mode 100644 src/windows_mcp/service/uia_worker_install.py delete mode 100644 tests/manual/vm_e2e/build-and-sign-uia-worker.ps1 diff --git a/.gitignore b/.gitignore index 4153c6d6..737fdf2a 100755 --- a/.gitignore +++ b/.gitignore @@ -31,10 +31,9 @@ MANIFEST # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec -# ...except the UIAccess worker spec + manifest, which are checked-in inputs -# to the build (signing happens later in the release pipeline). -!packaging/*.manifest -!packaging/*.spec +# ...except the UIAccess worker manifest, which is shipped with the wheel +# as package data and embedded by PyInstaller at install time. +!src/windows_mcp/service/_uia_worker.manifest # Installer logs pip-log.txt diff --git a/docs/secure-desktop.md b/docs/secure-desktop.md index 2c24eaab..62b036a7 100644 --- a/docs/secure-desktop.md +++ b/docs/secure-desktop.md @@ -5,19 +5,19 @@ optional service mode lets an LLM agent **see and click UAC consent dialogs** that fire on the Winlogon (Secure Desktop). Without this, every elevation interrupt halts the agent. -The service ships in two pieces. Both must be installed for the full +The feature has two moving parts. Both must be installed for the full "detect *and* dismiss" flow to work end-to-end: 1. **`WindowsMCPHost`** — a LocalSystem Windows service. Detects when the input desktop flips to Winlogon (UAC fired), enforces the consent - policy persisted in the registry, and brokers UIA / click requests to - the user-session worker over a named pipe. + policy persisted in the registry, and dispatches UIA / click requests + to the user-session worker over a named pipe. 2. **`windows-mcp-uia-worker.exe`** — an Authenticode-signed, UIAccess-enabled binary that runs *inside* the active console user's session, walks consent.exe's UIA tree, and returns it to the host service. -Why two pieces? Two Windows boundaries get in the way of "just walk the +Why two parts? Two Windows boundaries get in the way of "just walk the tree from the service": * **Session 0 isolation**: a service in session 0 cannot enumerate UIA @@ -31,84 +31,62 @@ tree from the service": enumeration of higher-integrity processes unless its application manifest declares `uiAccess="true"` **and** the binary is Authenticode-signed **and** it was launched from a trusted path - (`%ProgramFiles%` or `%WinDir%`). The worker is built and shipped - with all three. + (`%ProgramFiles%` or `%WinDir%`). -If you install the host service *without* the signed worker, the service -falls back to a plain `python -m windows_mcp.service.user_session_worker` -spawn. That fallback works for `WaitForUACPrompt`'s detection half -(`fired=True`, `desktop="Winlogon"`, `policy=…`), but the UIA tree it -returns will be empty — UIAccess denies cross-integrity enumeration to -unsigned binaries. +## Installing -## Building the signed worker - -1. Build the unsigned `.exe`: - - ``` - uv pip install pyinstaller - uv run pyinstaller packaging/uia_worker.spec --clean - ``` - - The result is `dist/windows-mcp-uia-worker.exe` with the - `packaging/uia_worker.manifest` embedded - (`uiAccess="true"` / `requireAdministrator`). - -2. Sign it with an Authenticode code-signing certificate. EV is preferred - but not required. **Do not check the cert into git** — keep it in your - release pipeline's secret store. - - ``` - signtool sign /fd SHA256 /a /tr http://timestamp.digicert.com /td SHA256 ^ - dist\windows-mcp-uia-worker.exe - ``` - -3. (Optional) verify: - - ``` - signtool verify /pa dist\windows-mcp-uia-worker.exe - ``` +``` +windows-mcp service secure-desktop install +``` -If you skip step 2 (or sign with a self-signed cert that the target -machine doesn't trust), Windows will refuse to grant UIAccess at launch -time — the worker will run, but UIA against consent.exe will silently -return nothing. +Run elevated. By default this prompts you: -## Installing on the target machine +``` +The Secure-Desktop helper can also be enabled, which lets the LLM agent +SEE and CLICK Windows UAC consent dialogs... -Run the install command **elevated**, pointing at the signed binary: +Enabling will: + 1. Install PyInstaller into the current Python env (~25 MB, one time). + 2. Build the helper as windows-mcp-uia-worker.exe (~60-120 s). + 3. Generate a self-signed code-signing cert (this machine only). + 4. Add the cert to LocalMachine\Root and \TrustedPublisher. + 5. Sign the helper and install it to %ProgramFiles%\WindowsMCP\. -``` -uv run windows-mcp service secure-desktop install ^ - --policy block ^ - --uia-worker C:\path\to\windows-mcp-uia-worker.exe +Build the signed helper now? [Y/n]: ``` -What the install does: - -* Registers the LocalSystem `WindowsMCPHost` service to auto-start at - boot. -* Copies the signed worker into `%ProgramFiles%\WindowsMCP\` and locks - the directory ACL to `BUILTIN\Administrators` + `NT AUTHORITY\SYSTEM` - (so a non-admin user cannot replace the worker and trick the service - into running attacker-supplied UIA code as themselves). -* Records the installed path under - `HKLM\SOFTWARE\Windows-MCP\SecureDesktop\UiaWorkerPath` so the host - service knows to spawn the signed binary instead of the unsigned - fallback. -* Persists the consent policy (`block` / `allow_with_match` / - `allow_all`) under the same registry key. +* **Yes** (default): the install command runs the full + build-then-self-sign-then-install flow. The cert it generates is local + to your machine — it's added to your machine's trust stores but no + other Windows install will accept signatures made with it. +* **No**: the host service installs in **detect-only** mode. + `WaitForUACPrompt` will still fire on UAC, you'll still get the + desktop name and the persisted policy, but the dialog's UIA tree will + come back empty and `Click(loc=...)` against the dialog will fail — + every elevation has to be approved or denied by hand at the keyboard. + You can re-run `install` later to switch on the helper. + +Non-interactive flags (skip the prompt): + +* `--self-sign-uia-worker` — auto-Yes, no prompt. +* `--no-uia-worker` — auto-No, no prompt. +* `--uia-worker ` — provide a commercially-signed binary you've + built yourself (e.g. in a release pipeline with an Authenticode EV + cert). Skips the build / cert / sign steps entirely, just installs + and registers your binary. ## Uninstalling ``` -uv run windows-mcp service secure-desktop uninstall +windows-mcp service secure-desktop uninstall ``` -Stops the service, removes the SCM registration, deletes the registry -key, and removes the worker binary from `%ProgramFiles%\WindowsMCP\`. +Stops and removes the service, clears the registry key, removes the +worker binary from `%ProgramFiles%\WindowsMCP\`, and — if you opted into +self-signing — removes the self-signed cert from `LocalMachine\My`, +`LocalMachine\Root`, and `LocalMachine\TrustedPublisher`. -## Threat model and "why not just disable UAC" +## Why not just disable UAC? Setting `EnableLUA=0` or `ConsentPromptBehaviorAdmin=0` would also "solve" the problem in a trivial sense — every elevation just succeeds @@ -128,3 +106,23 @@ Keeping UAC at its strictest setting and giving the agent eyes into the prompt via this two-process pattern is the same approach Microsoft's own accessibility tools use (Magnifier, Narrator) — UIAccess is the documented, supported mechanism for cross-integrity UI access. + +## Why we self-sign + +UIAccess is one of the very few Windows features that explicitly +requires a signature even when the user is admin. Without it, any +medium-integrity process could declare `uiAccess="true"` in its +manifest and silently bypass UAC for the user — defeating the feature +entirely. + +But Windows doesn't care *whose* cert it is, only that the cert chains +to a root the local machine trusts. So a self-signed cert added only to +this machine's `LocalMachine\Root` (which is what the install flow +does) is enough to satisfy the OS, without paying for a commercial +Authenticode cert. The cert never leaves this machine, and uninstall +removes it. + +If you're packaging windows-mcp for redistribution to many machines, +sign the worker once with a commercial cert at CI time and ship the +binary; users pass it via `--uia-worker ` and skip the self-sign +flow. diff --git a/packaging/uia_worker.spec b/packaging/uia_worker.spec deleted file mode 100644 index 8113f3e8..00000000 --- a/packaging/uia_worker.spec +++ /dev/null @@ -1,90 +0,0 @@ -# PyInstaller spec for the UIAccess-enabled worker binary. -# -# Build: -# uv run pyinstaller packaging/uia_worker.spec --clean -# -# Output: -# dist/windows-mcp-uia-worker.exe (single-file, manifested) -# -# Sign (in CI or release pipeline; do NOT check the cert in): -# signtool sign /fd SHA256 /a /tr http://timestamp.digicert.com /td SHA256 \ -# dist/windows-mcp-uia-worker.exe -# -# Install (run elevated on the target machine): -# uv run windows-mcp service secure-desktop install \ -# --policy block \ -# --uia-worker dist\windows-mcp-uia-worker.exe -# -# That last command copies the signed worker into -# %ProgramFiles%\WindowsMCP\windows-mcp-uia-worker.exe -# (a trusted path) and records the absolute path in HKLM so the host service -# knows to spawn it instead of `python -m windows_mcp.service.user_session_worker`. - -# ruff: noqa -- PyInstaller specs run as Python with the spec API in scope. - -import os - -block_cipher = None - -a = Analysis( - ['../src/windows_mcp/service/user_session_worker.py'], - pathex=[os.path.abspath('../src')], - binaries=[], - datas=[], - hiddenimports=[ - 'windows_mcp', - 'windows_mcp.service', - 'windows_mcp.service.secure_desktop', - 'comtypes', - 'comtypes.client', - 'comtypes.gen', - 'win32api', - 'win32con', - 'win32process', - 'win32security', - 'win32ts', - ], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[ - # Keep the worker as small as possible — it just walks UIA and prints - # JSON. No need for the rest of the windows_mcp tool surface. - 'fastmcp', - 'mcp', - 'starlette', - 'uvicorn', - 'sse_starlette', - 'pydantic', - 'posthog', - ], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, - noarchive=False, -) - -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - [], - name='windows-mcp-uia-worker', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=False, - upx_exclude=[], - runtime_tmpdir=None, - console=True, # console exe — service captures stdout/stderr. - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, - manifest='uia_worker.manifest', # uiAccess=true, requireAdministrator -) diff --git a/pyproject.toml b/pyproject.toml index af5d0679..40d821a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,9 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools.package-data] +"windows_mcp.service" = ["_uia_worker.manifest"] + [tool.ruff] line-length = 100 target-version = "py313" diff --git a/src/windows_mcp/__main__.py b/src/windows_mcp/__main__.py index 61875857..1548249a 100755 --- a/src/windows_mcp/__main__.py +++ b/src/windows_mcp/__main__.py @@ -835,6 +835,95 @@ def _install_uia_worker(src_path: str) -> str: return dest +_UIA_PROMPT = """\ +The Secure-Desktop helper can also be enabled, which lets the LLM agent +SEE and CLICK Windows UAC consent dialogs (the "Do you want this app to +make changes" prompts). Without it, the agent can detect that UAC fired +but cannot read the publisher, find the Yes/No buttons, or dismiss the +dialog -- every elevation has to be approved or denied by hand. + +This feature has limited value if the helper isn't enabled. + +Enabling it requires the helper binary to be Authenticode-signed, +because Windows refuses to grant cross-integrity UI access to unsigned +processes (otherwise any malware could declare uiAccess="true" and +silently auto-approve elevations). The standard ways to satisfy that: + + * Pay for a commercial code-signing cert (~$100/yr) and pass the + pre-signed binary via --uia-worker . + * Or generate a one-shot self-signed cert on this machine only -- it + never leaves your computer, only this Windows install trusts it, + and `windows-mcp service secure-desktop uninstall` removes the cert + and binary together. + +Enabling will: + 1. Install PyInstaller into the current Python env (~25 MB, one time). + 2. Build the helper as windows-mcp-uia-worker.exe (~60-120 s). + 3. Generate a self-signed code-signing cert (this machine only). + 4. Add the cert to LocalMachine\\Root and \\TrustedPublisher. + 5. Sign the helper and install it to %ProgramFiles%\\WindowsMCP\\. + +Build the signed helper now? (No installs in detect-only mode; you can +re-run install later to enable.) +""" + + +def _resolve_uia_worker_choice(non_interactive: bool | None) -> bool: + """Decide whether to run the self-sign + build flow. + + * ``--self-sign-uia-worker`` -> True + * ``--no-uia-worker`` -> False + * Neither flag, TTY available -> verbose prompt, default Yes + * Neither flag, no TTY -> False (don't hang automation) + """ + if non_interactive is not None: + return non_interactive + if not sys.stdin.isatty(): + click.echo( + "No TTY detected; defaulting to --no-uia-worker. Re-run with " + "--self-sign-uia-worker to enable the consent-dialog helper." + ) + return False + click.echo("") + click.echo(_UIA_PROMPT) + return click.confirm("Enable consent-dialog helper", default=True) + + +_REMOVE_CERT_PS = r""" +$ErrorActionPreference = 'Continue' +$subject = $args[0] +$removed = 0 +foreach ($store in @('My','Root','TrustedPublisher')) { + $items = Get-ChildItem "Cert:\LocalMachine\$store" -ErrorAction SilentlyContinue | + Where-Object { $_.Subject -eq $subject } + foreach ($c in $items) { + try { Remove-Item -Path $c.PSPath -Force -ErrorAction Stop; $removed++ } catch {} + } +} +Write-Output $removed +""" + + +def _try_remove_self_signed_cert() -> None: + """Best-effort: remove our self-signed cert from LocalMachine cert stores + on uninstall. Silent on failure -- the uninstall succeeds regardless.""" + try: + from windows_mcp.service.uia_worker_install import _CERT_SUBJECT + p = subprocess.run( + ["powershell.exe", "-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", + "-Command", _REMOVE_CERT_PS, "--", _CERT_SUBJECT], + capture_output=True, text=True, check=False, + ) + n = (p.stdout or "0").strip().splitlines()[-1] if p.stdout else "0" + if n != "0": + click.echo(f"Self-signed cert : removed {n} entries from LocalMachine cert stores.") + except Exception as exc: + click.echo(f"Note: could not clean up self-signed cert ({exc}). " + "You can remove it manually with certlm.msc -> " + "Personal/Trusted Root/Trusted Publishers -> " + "CN=WindowsMCP-Local-UiaWorker.") + + def _verify_install_paths_are_admin_only() -> None: """Raise ClickException if the Python interpreter or windows_mcp package live in a user-writable location. @@ -956,14 +1045,23 @@ def service_secure_desktop(): type=click.Path(exists=True, dir_okay=False, resolve_path=True), default=None, help=( - "Path to a UIAccess-signed worker .exe (built from " - "packaging/uia_worker.spec and Authenticode-signed). When provided, " - "the binary is copied to %ProgramFiles%\\WindowsMCP\\ (a trusted " - "path) and registered in HKLM so the LocalSystem service spawns it " - "in the user session for cross-integrity UIA against consent.exe. " - "Without this flag the service falls back to a plain python worker " - "that *cannot* walk the consent dialog tree -- see " - "docs/secure-desktop.md." + "Path to an already-signed UIAccess worker .exe (e.g. one signed " + "by your own commercial Authenticode cert in a release pipeline). " + "Skips the interactive prompt; the binary is copied to " + "%ProgramFiles%\\WindowsMCP\\ and registered in HKLM." + ), +) +@click.option( + "--self-sign-uia-worker/--no-uia-worker", + "self_sign_choice", + default=None, + help=( + "Non-interactive override of the UIA worker prompt. " + "--self-sign-uia-worker builds the worker via PyInstaller, " + "generates a local self-signed cert, signs and installs. " + "--no-uia-worker installs in detect-only mode (UAC is detected " + "but the dialog cannot be read or clicked). Without either flag, " + "the install command prompts interactively." ), ) def service_secure_desktop_install( @@ -972,6 +1070,7 @@ def service_secure_desktop_install( allow_publisher: tuple[str, ...], allow_user_binary_path: bool, uia_worker: str | None, + self_sign_choice: bool | None, ): """Install and start the Secure Desktop host service (requires elevation).""" _require_win32() @@ -1088,21 +1187,43 @@ def service_secure_desktop_install( click.echo(f"Warning: could not persist UAC policy: {exc}") click.echo(" Service will refuse auto-clicks until policy is set.") + # ----- UIA worker ------------------------------------------------------- + # Three input paths, in precedence order: + # 1. --uia-worker : user-supplied pre-signed binary (commercial Authenticode) + # 2. --self-sign-uia-worker / --no-uia-worker : non-interactive choice + # 3. interactive prompt : default if uia_worker: try: installed = _install_uia_worker(uia_worker) policy_mod.write_uia_worker_path(installed) - click.echo(f"UIA worker : {installed}") + click.echo(f"UIA worker : {installed} (pre-signed)") except Exception as exc: click.echo(f"Warning: failed to install UIA worker: {exc}") click.echo(" Service will use the unsigned fallback; " "consent.exe tree walking will return empty.") else: - click.echo( - "UIA worker : (none) -- service will use the unsigned " - "python fallback; cross-integrity UIA against consent.exe will " - "return empty. Re-install with --uia-worker to enable." - ) + do_self_sign = _resolve_uia_worker_choice(self_sign_choice) + if do_self_sign: + try: + from windows_mcp.service import uia_worker_install + installed = uia_worker_install.build_sign_and_install( + progress=lambda msg: click.echo(f" · {msg}") + ) + policy_mod.write_uia_worker_path(str(installed)) + click.echo(f"UIA worker : {installed} (self-signed, this machine only)") + except Exception as exc: + click.echo(f"Warning: self-sign UIA worker flow failed: {exc}") + click.echo(" Service will use the detect-only fallback; " + "WaitForUACPrompt will report fired=True but the dialog " + "tree will be empty. Re-run install to try again, or " + "pass --uia-worker with a pre-signed binary.") + else: + click.echo( + "UIA worker : skipped -- service will run in detect-only " + "mode. WaitForUACPrompt will fire on UAC but the consent dialog's " + "UIA tree will be empty. Re-run install and answer 'y' (or pass " + "--self-sign-uia-worker) to enable." + ) click.echo("\nThe host service is now running as NT AUTHORITY\\SYSTEM.") click.echo("It will restart automatically at each boot.") @@ -1147,6 +1268,12 @@ def service_secure_desktop_uninstall(): except Exception as exc: click.echo(f"Warning: could not remove UIA worker: {exc}") + # Best-effort: remove the self-signed cert + LocalMachine trust entries. + # If the user signed with their own commercial cert via --uia-worker, + # there's nothing of ours in the cert stores; the lookup-by-subject + # below simply matches nothing and we exit cleanly. + _try_remove_self_signed_cert() + @service_secure_desktop.command("set-policy") @click.argument("policy_name", type=click.Choice(["block", "allow_with_match", "allow_all"])) diff --git a/packaging/uia_worker.manifest b/src/windows_mcp/service/_uia_worker.manifest similarity index 100% rename from packaging/uia_worker.manifest rename to src/windows_mcp/service/_uia_worker.manifest diff --git a/src/windows_mcp/service/uia_worker_install.py b/src/windows_mcp/service/uia_worker_install.py new file mode 100644 index 00000000..92bd7b36 --- /dev/null +++ b/src/windows_mcp/service/uia_worker_install.py @@ -0,0 +1,283 @@ +"""Build + self-sign + install the UIAccess worker. + +Driven by ``windows-mcp service secure-desktop install``. The whole flow: + + 1. Ensure PyInstaller is importable (auto-installs into the current Python + environment if it isn't — service-mode install is opt-in and already + requires admin, so a one-time PyPI fetch is acceptable here). + 2. Freeze ``windows_mcp.service.user_session_worker`` into a single + ``windows-mcp-uia-worker.exe`` with the manifest from this package + (uiAccess="true", requireAdministrator) embedded. + 3. Generate a self-signed code-signing certificate in + ``Cert:\\LocalMachine\\My``, copy it to ``Root`` and + ``TrustedPublisher`` so Windows treats the resulting signature as + trusted on *this* machine only. + 4. Sign the frozen exe with that cert. + 5. Copy the signed binary into ``%ProgramFiles%\\WindowsMCP\\``, lock + the directory's ACL down to Administrators + SYSTEM. + 6. Return the installed absolute path so the caller can persist it in + HKLM via ``policy.write_uia_worker_path``. + +All cert + signing logic is shelled out to PowerShell (`New-SelfSignedCertificate`, +`Set-AuthenticodeSignature`) because those cmdlets are present on every +modern Windows install and we'd otherwise be hand-rolling crypt32 ctypes +calls for no good reason. +""" + +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +import sys +import tempfile +import textwrap +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Lives next to this module; copied in as package_data via pyproject.toml. +_MANIFEST_NAME = "_uia_worker.manifest" +_WORKER_EXE_NAME = "windows-mcp-uia-worker.exe" +_INSTALL_DIR = Path( + os.environ.get("ProgramFiles", r"C:\Program Files") +) / "WindowsMCP" +_CERT_SUBJECT = "CN=WindowsMCP-Local-UiaWorker" + + +# --------------------------------------------------------------------------- +# PyInstaller bootstrap +# --------------------------------------------------------------------------- + +def _have_pyinstaller() -> bool: + try: + import PyInstaller # noqa: F401 + except ImportError: + return False + return True + + +def ensure_pyinstaller(progress: callable | None = None) -> None: + """Install PyInstaller into the current interpreter if not present.""" + if _have_pyinstaller(): + return + if progress: + progress("Installing PyInstaller (one-time, ~25 MB)…") + rc = subprocess.run( + [sys.executable, "-m", "pip", "install", "--quiet", "pyinstaller"], + check=False, + ).returncode + if rc != 0 or not _have_pyinstaller(): + raise RuntimeError( + "pip install pyinstaller failed. Install it manually with: " + f"{sys.executable} -m pip install pyinstaller" + ) + + +# --------------------------------------------------------------------------- +# Build +# --------------------------------------------------------------------------- + +def _resolve_manifest() -> Path: + """Return the absolute path to ``_uia_worker.manifest`` shipped with this package.""" + here = Path(__file__).resolve().parent + manifest = here / _MANIFEST_NAME + if not manifest.is_file(): + raise FileNotFoundError( + f"Bundled UIAccess manifest missing at {manifest}. " + "The wheel may be corrupt or the package data exclude is wrong." + ) + return manifest + + +def _resolve_worker_script() -> Path: + from windows_mcp.service import user_session_worker + return Path(user_session_worker.__file__).resolve() + + +def build_worker(workdir: Path, progress: callable | None = None) -> Path: + """Run PyInstaller to freeze the worker into a single exe. + + Returns the absolute path to the built exe. + """ + ensure_pyinstaller(progress=progress) + manifest = _resolve_manifest() + script = _resolve_worker_script() + src_root = script.parents[2] # .../site-packages + + workdir = Path(workdir).resolve() + workdir.mkdir(parents=True, exist_ok=True) + dist_dir = workdir / "dist" + build_dir = workdir / "build" + + cmd = [ + sys.executable, "-m", "PyInstaller", + "--onefile", + "--noconfirm", + "--clean", + "--log-level", "WARN", + "--distpath", str(dist_dir), + "--workpath", str(build_dir), + "--specpath", str(workdir), + "--name", "windows-mcp-uia-worker", + "--manifest", str(manifest), + "--paths", str(src_root), + # comtypes generates COM proxy modules at runtime; PyInstaller's + # static analysis can't see them, so collect the whole subtree. + "--collect-submodules", "comtypes", + "--hidden-import", "windows_mcp.service.secure_desktop", + # Trim things the worker doesn't actually need; keeps the exe under + # ~15 MB instead of pulling in fastmcp / mcp / uvicorn / pydantic. + "--exclude-module", "fastmcp", + "--exclude-module", "mcp", + "--exclude-module", "starlette", + "--exclude-module", "uvicorn", + "--exclude-module", "sse_starlette", + "--exclude-module", "pydantic", + "--exclude-module", "posthog", + str(script), + ] + if progress: + progress("Building UIA worker .exe (this takes 60–120s; PyInstaller output follows)…") + rc = subprocess.run(cmd, check=False).returncode + exe = dist_dir / _WORKER_EXE_NAME + if rc != 0 or not exe.is_file(): + raise RuntimeError( + f"PyInstaller build failed (exit={rc}). See output above for details." + ) + return exe + + +# --------------------------------------------------------------------------- +# Cert + sign (PowerShell) +# --------------------------------------------------------------------------- + +_GENERATE_CERT_PS = textwrap.dedent( + r""" + $ErrorActionPreference = 'Stop' + $subject = $args[0] + # Reuse an existing cert if one already exists with this subject + # (idempotent re-runs of install). Otherwise create a fresh one. + $existing = Get-ChildItem Cert:\LocalMachine\My -CodeSigningCert -ErrorAction SilentlyContinue | + Where-Object { $_.Subject -eq $subject } | Select-Object -First 1 + if ($existing) { + $cert = $existing + } else { + $cert = New-SelfSignedCertificate ` + -Type CodeSigningCert ` + -Subject $subject ` + -KeyUsage DigitalSignature ` + -KeyAlgorithm RSA -KeyLength 2048 ` + -HashAlgorithm SHA256 ` + -NotAfter (Get-Date).AddYears(5) ` + -CertStoreLocation Cert:\LocalMachine\My ` + -KeyExportPolicy NonExportable + } + # Plant a public copy in Root + TrustedPublisher so the OS treats + # the signed binary as trusted at runtime. + foreach ($store in @('Root','TrustedPublisher')) { + $s = New-Object System.Security.Cryptography.X509Certificates.X509Store $store,'LocalMachine' + $s.Open('ReadWrite') + if (-not ($s.Certificates | Where-Object { $_.Thumbprint -eq $cert.Thumbprint })) { + $s.Add($cert) + } + $s.Close() + } + Write-Output $cert.Thumbprint + """ +) + + +_SIGN_PS = textwrap.dedent( + r""" + $ErrorActionPreference = 'Stop' + $thumbprint = $args[0] + $exePath = $args[1] + $cert = Get-Item "Cert:\LocalMachine\My\$thumbprint" + $sig = Set-AuthenticodeSignature -FilePath $exePath -Certificate $cert ` + -HashAlgorithm SHA256 -IncludeChain All + if ($sig.Status -ne 'Valid') { + throw "Signature status is '$($sig.Status)': $($sig.StatusMessage)" + } + """ +) + + +def _run_powershell(script: str, *args: str) -> str: + """Run an inline PowerShell script with positional args. Returns trimmed stdout.""" + cmd = [ + "powershell.exe", + "-NoLogo", "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-Command", script + "\n", + "--", + *args, + ] + p = subprocess.run(cmd, capture_output=True, text=True, check=False) + if p.returncode != 0: + raise RuntimeError( + f"PowerShell call failed (exit={p.returncode}): {p.stderr.strip() or p.stdout.strip()}" + ) + return p.stdout.strip() + + +def generate_local_cert(progress: callable | None = None) -> str: + """Generate (or reuse) a self-signed code-signing cert and plant it in + LocalMachine\\Root + LocalMachine\\TrustedPublisher. Returns the thumbprint. + """ + if progress: + progress(f"Generating self-signed code-signing cert ({_CERT_SUBJECT})…") + return _run_powershell(_GENERATE_CERT_PS, _CERT_SUBJECT) + + +def sign_worker(exe_path: Path, thumbprint: str, progress: callable | None = None) -> None: + """Authenticode-sign *exe_path* with the cert identified by *thumbprint*.""" + if progress: + progress(f"Signing {exe_path.name} with self-signed cert…") + _run_powershell(_SIGN_PS, thumbprint, str(exe_path)) + + +# --------------------------------------------------------------------------- +# Install to Program Files +# --------------------------------------------------------------------------- + +def install_signed_worker(exe_path: Path, progress: callable | None = None) -> Path: + """Copy the signed exe into ``%ProgramFiles%\\WindowsMCP\\`` and lock the + directory's ACL to Administrators + SYSTEM. Returns the installed path. + """ + if progress: + progress(f"Installing to {_INSTALL_DIR}…") + _INSTALL_DIR.mkdir(parents=True, exist_ok=True) + dest = _INSTALL_DIR / _WORKER_EXE_NAME + shutil.copy2(exe_path, dest) + # Best-effort tighten ACLs. Failure here is non-fatal: the binary works, + # just with default Program Files ACLs (Authenticated Users: Read & + # Execute) — which is already fine because Authenticated Users can't + # write to Program Files. + for cmd in ( + ["icacls", str(_INSTALL_DIR), "/inheritance:r"], + ["icacls", str(_INSTALL_DIR), "/grant", "*S-1-5-18:(OI)(CI)F"], # NT AUTHORITY\SYSTEM + ["icacls", str(_INSTALL_DIR), "/grant", "*S-1-5-32-544:(OI)(CI)F"], # BUILTIN\Administrators + ): + try: + subprocess.run(cmd, capture_output=True, check=False) + except Exception: + pass + return dest + + +# --------------------------------------------------------------------------- +# Top-level orchestrator +# --------------------------------------------------------------------------- + +def build_sign_and_install(progress: callable | None = None) -> Path: + """One-shot: ensure PyInstaller, build, generate cert, sign, install. Returns the + installed path. + """ + progress = progress or (lambda _msg: None) + with tempfile.TemporaryDirectory(prefix="windows-mcp-uia-build-") as tmpdir: + exe = build_worker(Path(tmpdir), progress=progress) + thumbprint = generate_local_cert(progress=progress) + sign_worker(exe, thumbprint, progress=progress) + return install_signed_worker(exe, progress=progress) diff --git a/tests/manual/vm_e2e/build-and-sign-uia-worker.ps1 b/tests/manual/vm_e2e/build-and-sign-uia-worker.ps1 deleted file mode 100644 index 0d290336..00000000 --- a/tests/manual/vm_e2e/build-and-sign-uia-worker.ps1 +++ /dev/null @@ -1,111 +0,0 @@ -# build-and-sign-uia-worker.ps1 — VM-only helper. -# -# Produces a fully-functional UIAccess worker .exe inside the dockur VM -# without dragging in a commercial code-signing cert. The trick is that -# Windows accepts any Authenticode signature whose root CA is trusted on -# the local box — so we generate a one-shot self-signed cert, plant it -# in the machine's Trusted Root + Trusted Publisher stores, and sign -# with that. -# -# We also flip HKLM\...\Policies\System\EnableSecureUIAPaths = 0 so the -# binary can live anywhere (not just %ProgramFiles%). Production deploys -# should NOT do this — they should ship a real Authenticode-signed -# worker into %ProgramFiles%\WindowsMCP\. -# -# Inputs: -# $LocalRepo : C:\windows-mcp (already mirrored from the share by setup.ps1) -# -# Outputs: -# $LocalRepo\dist\windows-mcp-uia-worker.exe (signed) -# -# Prints the path to stdout on success. - -param( - [string]$LocalRepo = "C:\windows-mcp" -) - -$ErrorActionPreference = "Stop" -$ProgressPreference = "SilentlyContinue" - -function Log($msg) { Write-Host "[uia-sign] $msg" } - -# ----- 1. install PyInstaller into the venv -------------------------------- -Log "Installing PyInstaller into $LocalRepo\.venv" -Push-Location $LocalRepo -try { - $env:UV_INSECURE_HOST = "pypi.org files.pythonhosted.org github.com astral.sh objects.githubusercontent.com" - & uv pip install pyinstaller 2>&1 | Out-Host - if ($LASTEXITCODE -ne 0) { throw "uv pip install pyinstaller failed ($LASTEXITCODE)" } -} finally { Pop-Location } - -# ----- 2. build the unsigned worker --------------------------------------- -Log "Building windows-mcp-uia-worker.exe via PyInstaller" -Push-Location (Join-Path $LocalRepo "packaging") -try { - & "$LocalRepo\.venv\Scripts\pyinstaller.exe" uia_worker.spec --clean --noconfirm 2>&1 | Out-Host - if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed ($LASTEXITCODE)" } -} finally { Pop-Location } - -$exe = Join-Path $LocalRepo "packaging\dist\windows-mcp-uia-worker.exe" -if (-not (Test-Path $exe)) { throw "Build succeeded but $exe not found." } -Log "Built: $exe" - -# ----- 3. self-signed code-signing cert ----------------------------------- -$certSubject = "CN=WindowsMCP-Dev-Test-Only" -$existing = Get-ChildItem Cert:\LocalMachine\My -CodeSigningCert -ErrorAction SilentlyContinue | - Where-Object { $_.Subject -eq $certSubject } | Select-Object -First 1 -if ($existing) { - Log "Reusing existing cert thumbprint=$($existing.Thumbprint)" - $cert = $existing -} else { - Log "Creating self-signed code-signing cert ($certSubject)" - $cert = New-SelfSignedCertificate ` - -Type CodeSigningCert ` - -Subject $certSubject ` - -KeyUsage DigitalSignature ` - -KeyAlgorithm RSA -KeyLength 2048 ` - -HashAlgorithm SHA256 ` - -NotAfter (Get-Date).AddYears(5) ` - -CertStoreLocation Cert:\LocalMachine\My ` - -KeyExportPolicy Exportable - Log "Created cert thumbprint=$($cert.Thumbprint)" -} - -# Make sure the cert is trusted by the local machine: Trusted Root + -# Trusted Publisher. Both stores need the same cert for Authenticode -# to be considered "trusted by the local OS" during UIAccess checks. -foreach ($store in @("Root", "TrustedPublisher")) { - $storeObj = New-Object System.Security.Cryptography.X509Certificates.X509Store ` - $store, "LocalMachine" - $storeObj.Open("ReadWrite") - if (-not ($storeObj.Certificates | Where-Object { $_.Thumbprint -eq $cert.Thumbprint })) { - $storeObj.Add($cert) - Log "Added cert to LocalMachine\$store" - } - $storeObj.Close() -} - -# ----- 4. sign the exe ---------------------------------------------------- -Log "Signing $exe" -$sig = Set-AuthenticodeSignature -FilePath $exe -Certificate $cert ` - -HashAlgorithm SHA256 -IncludeChain All -if ($sig.Status -ne "Valid") { - throw "Set-AuthenticodeSignature returned Status=$($sig.Status): $($sig.StatusMessage)" -} -Log "Signature status: $($sig.Status)" - -# ----- 5. flip the trusted-path requirement off -------------------------- -# UIAccess on Win10+ also requires the binary to live in a "trusted path" -# (Program Files / WinDir). Setting EnableSecureUIAPaths=0 lifts that -# restriction so we can run from $LocalRepo\packaging\dist\. Production -# deployment installs into %ProgramFiles%\WindowsMCP\ instead and leaves -# this policy alone. -$polKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" -$cur = (Get-ItemProperty -Path $polKey -Name EnableSecureUIAPaths -ErrorAction SilentlyContinue).EnableSecureUIAPaths -if ($cur -ne 0) { - Log "Setting EnableSecureUIAPaths=0 (was $cur) — VM-only override" - Set-ItemProperty -Path $polKey -Name EnableSecureUIAPaths -Type DWord -Value 0 -} - -# ----- 6. report path to caller ------------------------------------------ -Write-Output $exe diff --git a/tests/manual/vm_e2e/setup.ps1 b/tests/manual/vm_e2e/setup.ps1 index 249c5f56..9467b72b 100644 --- a/tests/manual/vm_e2e/setup.ps1 +++ b/tests/manual/vm_e2e/setup.ps1 @@ -178,47 +178,20 @@ function Set-Uac-Config { Log "UAC: EnableLUA=1 ConsentPromptBehaviorAdmin=2 PromptOnSecureDesktop=1" } -function Build-And-Sign-Uia-Worker { - # Builds the UIAccess-enabled worker .exe, self-signs it, plants the - # cert in the machine's trust store, flips EnableSecureUIAPaths=0. - # Returns the absolute path of the signed binary (or $null on failure). - $signer = Join-Path $Repo "tests\manual\vm_e2e\build-and-sign-uia-worker.ps1" - if (-not (Test-Path $signer)) { - Log "WARN: $signer missing — skipping signed-worker build." - return $null - } - Log "Building + self-signing UIA worker…" - $signedPath = $null - Invoke-Native "uia-worker-sign.log" { - $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass ` - -File $signer -LocalRepo $LocalRepo 2>&1 - $output | Out-Host - # The signer prints the path of the final exe on its last stdout line. - $script:signedPath = ($output | Where-Object { $_ -is [string] } | - Select-Object -Last 1).ToString().Trim() - } - if ($signedPath -and (Test-Path $signedPath)) { - Log "UIA worker signed: $signedPath" - return $signedPath - } - Log "WARN: signed UIA worker not produced; install will use unsigned fallback." - return $null -} - function Install-Host-Service { - $signedWorker = Build-And-Sign-Uia-Worker Push-Location $LocalRepo try { - Log "Installing host service (allow-user-binary-path because this is a VM)…" + Log "Installing host service (allow-user-binary-path because this is a VM)..." + # --self-sign-uia-worker drives the production build+self-sign+install + # flow shipped in the wheel: ensures PyInstaller, freezes the worker + # with the embedded uiAccess manifest, generates a self-signed cert, + # plants it in LocalMachine\Root + \TrustedPublisher, signs the exe, + # and copies into %ProgramFiles%\WindowsMCP\. Same code path an end + # user gets when they answer 'y' to the interactive prompt. Invoke-Native "install-host.log" { - if ($signedWorker) { - & uv run windows-mcp service secure-desktop install ` - --policy allow_all --allow-user-binary-path ` - --uia-worker $signedWorker --force - } else { - & uv run windows-mcp service secure-desktop install ` - --policy allow_all --allow-user-binary-path --force - } + & uv run windows-mcp service secure-desktop install ` + --policy allow_all --allow-user-binary-path ` + --self-sign-uia-worker --force } } finally { Pop-Location } } From 787dd309984572f005a37a2fb5c6c3e4da56c44c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 01:31:13 +0000 Subject: [PATCH 046/158] fix(service): use -File + named params for PowerShell calls `powershell.exe -Command "