diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 6f63cc8..8cc50b5 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -19,6 +19,12 @@ "source": {"source": "local", "path": "./plugins/frida-use"}, "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}, "category": "Security" + }, + { + "name": "hyper-v-control", + "source": {"source": "local", "path": "./plugins/hyper-v-control"}, + "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}, + "category": "Developer Tools" } ] } diff --git a/.gitattributes b/.gitattributes index 936a228..5abe26d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,6 @@ *.zip filter=lfs diff=lfs merge=lfs -text *.patch text eol=lf -whitespace *.manifest text eol=lf +src/HyperVControl/** text eol=lf +tests/HyperVControl.Tests/** text eol=lf +plugins/hyper-v-control/** text eol=lf diff --git a/.github/workflows/hyper-v-control.yml b/.github/workflows/hyper-v-control.yml new file mode 100644 index 0000000..14c03d8 --- /dev/null +++ b/.github/workflows/hyper-v-control.yml @@ -0,0 +1,35 @@ +name: Hyper-V Control +on: + push: + paths: ['src/HyperVControl/**', 'tests/HyperVControl.Tests/**', 'plugins/hyper-v-control/**', 'plugins/rizin-re-toolkit/**', 'scripts/build-hyperv-control.ps1', 'scripts/test-installer.ps1', 'scripts/test-release-isolation.ps1', '.github/workflows/hyper-v-control.yml'] + pull_request: + paths: ['src/HyperVControl/**', 'tests/HyperVControl.Tests/**', 'plugins/hyper-v-control/**', 'plugins/rizin-re-toolkit/**', 'scripts/build-hyperv-control.ps1', 'scripts/test-installer.ps1', 'scripts/test-release-isolation.ps1', '.github/workflows/hyper-v-control.yml'] + workflow_dispatch: +permissions: + contents: read +jobs: + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.401' + - name: Build self-contained EXE and plugin + shell: pwsh + run: ./scripts/build-hyperv-control.ps1 + - name: Verify pinned runtime reproducibility + shell: pwsh + run: git diff --exit-code -- plugins/hyper-v-control/runtime.json + - name: Unit and stdio protocol validation + shell: pwsh + run: | + dotnet run --project tests/HyperVControl.Tests -c Release -- unit $PWD + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet run --project tests/HyperVControl.Tests -c Release -- smoke $PWD + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + ./scripts/test-release-isolation.ps1 + - uses: actions/upload-artifact@v4 + with: + name: hyper-v-control-win-x64 + path: build/hyperv-control/release/ diff --git a/.gitignore b/.gitignore index 7d4c127..e67c307 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ build/ test/ __pycache__/ *.pyc +**/bin/ +**/obj/ +plugins/hyper-v-control/bin/ diff --git a/HyperVControl.slnx b/HyperVControl.slnx new file mode 100644 index 0000000..c57a8b3 --- /dev/null +++ b/HyperVControl.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/README.md b/README.md index 0b0adf8..7e1ecb1 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # Jerry's Plugin Marketplace -A personal Codex plugin marketplace. Each plugin lives in its own folder, so this repository can grow beyond the original Rizin bundle. +A personal Codex plugin monorepo. Each plugin has its own identity, version, installation directory and release stream. | Plugin | What it provides | | --- | --- | | [Rizin RE Toolkit](plugins/rizin-re-toolkit) | A portable Windows x64 Rizin CLI, RetDec/Ghidra/jsdec decompilers, YARA, FLIRT, and a skill for native binary and memory-dump analysis. | | [Capstone Binary Patching](plugins/capstone-binary-patching) | A uv-managed Capstone/LIEF CLI and skill for binary patches, instruction diffs, patch porting, verification and rollback, with native Windows experiments. | | [Frida Use](plugins/frida-use) | Windows-focused live instrumentation, Python/JavaScript debugging, API tracing, malware analysis, reverse engineering, and reversible runtime patching. | +| [Hyper-V Control](plugins/hyper-v-control) | A compiled C# stdio MCP EXE and skill for VM lifecycle, checkpoints, Basic/Enhanced computer use, guest files/UI, and user/kernel debugging. | ## Add to Codex @@ -14,7 +15,9 @@ A personal Codex plugin marketplace. Each plugin lives in its own folder, so thi codex plugin marketplace add JerryLinLinLin/jerry-plugin-marketplace --ref main ``` -Open the plugin directory, select **Jerry's Plugin Marketplace**, and install **Rizin RE Toolkit**, **Capstone Binary Patching**, or **Frida Use** as needed. A normal marketplace clone does not need the build-source submodules. The Rizin CLI is downloaded separately from GitHub Releases when its skill needs it. Frida Use bundles its own skill, scripts, and references; install the Frida runtime in your analysis environment as described in its README. +Open the plugin directory, select **Jerry's Plugin Marketplace**, and install **Rizin RE Toolkit**, **Capstone Binary Patching**, **Frida Use**, or **Hyper-V Control** as needed. A normal marketplace clone does not need the build-source submodules. Rizin and Hyper-V Control download their own verified runtimes from separate release streams. Frida Use bundles its own skill, scripts, and references; install the Frida runtime in your analysis environment as described in its README. + +**Hyper-V Control:** plugin/skill ID `hyper-v-control`, invocation `$hyper-v-control`, version `0.1.0`. Its C# stdio MCP is distributed as a self-contained Windows x64 EXE. A complete plugin ZIP includes the EXE; for a marketplace source installation, run [its installer](plugins/hyper-v-control/scripts/install.ps1) before reconnecting the MCP. See [setup and validation](plugins/hyper-v-control/README.md). **Capstone Binary Patching** is a separate skill-and-CLI plugin for patching work; see its [setup and experiments](plugins/capstone-binary-patching). Its Python dependencies are managed by uv, @@ -28,7 +31,15 @@ and it requires no MCP server or account connection. The catalog is [.agents/plugins/marketplace.json](.agents/plugins/marketplace.json), following the [official OpenAI plugin marketplace format](https://developers.openai.com/plugins/build/plugins). Plugin paths are relative to the repository root. -The [plugin/skill release 0.4.0](https://github.com/JerryLinLinLin/jerry-plugin-marketplace/releases/tag/rizin-re-toolkit-v0.4.0) is versioned separately from the Rizin portable runtime below. +## Releases and downloads + +| Component | Release | What to download | +| --- | --- | --- | +| Hyper-V Control | [0.1.0](https://github.com/JerryLinLinLin/jerry-plugin-marketplace/releases/tag/hyper-v-control-v0.1.0) | Namespaced Windows x64 EXE or complete plugin ZIP | +| Rizin RE Toolkit plugin/skill | [0.4.1](https://github.com/JerryLinLinLin/jerry-plugin-marketplace/releases/tag/rizin-re-toolkit-v0.4.1) | Plugin ZIP or skill; includes the isolated, paginated runtime installer | +| Rizin portable runtime | [0.3.1](https://github.com/JerryLinLinLin/jerry-plugin-marketplace/releases/tag/rizin-v0.3.1) | Existing runtime bundle and its unchanged `SHA256SUMS` | + +Use the [release organization guide](docs/releases.md) for tag/asset naming, per-plugin checksum pins, independent runtime versions and publication steps. Installers do not use the repository-wide Latest release to select a plugin. Existing Rizin URLs remain compatible. ## Portable Rizin download @@ -50,13 +61,15 @@ and use English link labels even when the original source is in another language 1. Create `plugins//plugin.json` and `skills//SKILL.md` within that folder. Keep referenced assets and scripts inside the plugin. 2. Add its entry to `.agents/plugins/marketplace.json`, using `source.path: ./plugins/`, a category, and installation/authentication policies. -3. Bump the plugin's semantic version when shipping changes. Keep large CLI binaries in versioned GitHub release assets. +3. Bump the plugin's semantic version when shipping changes. Follow [the release conventions](docs/releases.md) for component tags, namespaced assets and isolated runtime installation. Keep large binaries in GitHub Releases. 4. Validate the manifest and skill, then test discovery with Codex. Rizin includes a portable Agent Plugins manifest and a `.codex-plugin/plugin.json` compatibility manifest. It is a CLI-and-skill plugin and needs no MCP server or account connection. ## Build and verification +This is a plugin monorepo. Hyper-V Control's C# source is under `src/HyperVControl`, its protocol/live validation harness under `tests/HyperVControl.Tests`, and its distributable plugin under `plugins/hyper-v-control`. Build its self-contained EXE and plugin ZIP with `scripts/build-hyperv-control.ps1`; see [Hyper-V Control](plugins/hyper-v-control/README.md) for runtime setup and [feature extraction](plugins/hyper-v-control/docs/extraction.md). + See [the build guide](docs/build.md), [component lock file](bundle.lock.json), and [release validation](docs/validation-v0.3.1.md). Build sources are pinned under `sources/`; Windows compatibility patches live under `patches/`. Generated files stay in the ignored `build/` directory. ## License diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..12e9096 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,66 @@ +# Releases and binary distribution + +This repository is a marketplace of independently versioned plugins. A GitHub Release belongs to one plugin or one explicitly separate runtime stream; it is not a version of the whole marketplace. GitHub assets are a flat list, so filenames carry their own identity. + +## Release index + +| Stream | Version and tag | Downloads | Installation | +| --- | --- | --- | --- | +| Hyper-V Control | [0.1.0](https://github.com/JerryLinLinLin/jerry-plugin-marketplace/releases/tag/hyper-v-control-v0.1.0) | C# EXE and complete Windows x64 plugin ZIP | `%LOCALAPPDATA%\Programs\HyperVControl\0.1.0` or the ZIP's `bin` directory | +| Rizin RE Toolkit plugin/skill | [0.4.1](https://github.com/JerryLinLinLin/jerry-plugin-marketplace/releases/tag/rizin-re-toolkit-v0.4.1) | Plugin ZIP and `.skill` | Codex plugin/skill directory | +| Rizin portable runtime | [0.3.1](https://github.com/JerryLinLinLin/jerry-plugin-marketplace/releases/tag/rizin-v0.3.1) | Rizin 0.9.1 Windows x64 runtime bundle | `%LOCALAPPDATA%\Programs\Rizin\rizin-v0.3.1` | +| Capstone Binary Patching | Plugin `0.1.0`, marketplace source | Skill/scripts; dependencies installed by uv | Its uv-managed environment; no repository binary release required | +| Frida Use | Plugin `1.0.0`, marketplace source | Skill/scripts/references; Frida installed separately | The selected analysis environment; no repository binary release required | + +The marketplace's source of truth for plugin identity/version remains `plugins//plugin.json`. A compiled plugin additionally owns its runtime pin, installer, launcher and release builder. Do not couple unrelated plugin versions. + +## Naming rules for new releases + +| Item | Pattern | Hyper-V example | +| --- | --- | --- | +| Git tag | `-v` | `hyper-v-control-v0.1.0` | +| Release title | ` — ` | `Hyper-V Control 0.1.0 — Windows x64 MCP and Plugin` | +| Standalone executable | `--v.exe` | `hyper-v-control-win-x64-v0.1.0.exe` | +| Complete plugin ZIP | `-plugin-v[-].zip` | `hyper-v-control-plugin-v0.1.0-win-x64.zip` | +| Multi-file runtime ZIP, when needed | `-runtime--v.zip` | Use when an EXE requires adjacent runtime files | +| Optional skill archive | `-v.skill` | `rizin-re-toolkit-v0.4.1.skill` | +| Checksums | `-v-SHA256SUMS.txt` | `hyper-v-control-v0.1.0-SHA256SUMS.txt` | +| Asset inventory | `-v-release.json` | `hyper-v-control-v0.1.0-release.json` | + +Use .NET-style runtime identifiers such as `win-x64`, `win-arm64`, `linux-x64`, and `osx-arm64`. A filename should stay unambiguous when copied into a shared Downloads directory. An installed executable may have a stable local name such as `HyperVControl.exe`; the installer maps the namespaced download to that name inside the plugin's own versioned directory. + +For a genuinely independent runtime version, use a separate `-runtime-v` stream and pin the relationship from the consuming plugin. A plugin may update documentation without rebuilding a large, unchanged runtime. Existing Rizin runtime tags (`rizin-v0.3.1`) and asset names are legacy compatibility contracts and are not renamed. + +## Download and upgrade isolation + +- Never use repository-wide `/releases/latest` or `/releases/latest/download/...` from an installer. The newest release may belong to a different plugin. +- Prefer an exact release tag, platform, asset filename and SHA-256 pin in the consuming plugin. Hyper-V's `runtime.json` contains all four; its installer rejects a mismatched plugin, tag, platform or asset URL before downloading. +- When latest-in-stream discovery is necessary, filter both tag family and asset name, skip drafts/prereleases, paginate until the stream is found, and verify the selected asset checksum. Rizin's installer follows this path and retains its legacy `SHA256SUMS` lookup. +- Keep install directories separate and versioned. Reuse a matching verified installation; do not overwrite a different checksum in place or alter a global PATH implicitly. Rizin's user PATH change remains opt-in; Hyper-V uses an absolute executable path. +- Keep old tags/assets immutable. Fix a published build with a new patch version. Draft assets may be replaced before publication; published assets are never replaced by this workflow. +- Publish independent plugin releases with `--latest=false`. The global GitHub Latest badge is not a plugin index. This preserves existing legacy behavior; the table above is the entrypoint for choosing a component. + +## Packaging and publication + +1. Build/test the component and its installer in an isolated directory. For Hyper-V, use .NET SDK `10.0.401` and `scripts/build-hyperv-control.ps1`. The runtime patch is pinned to `10.0.12`; no compiler or .NET installation is needed by end users. +2. Commit the runtime pin together with the implementation and packaging. CI verifies that rebuilding the EXE does not change the committed pin. Source paths and Git revision metadata are excluded from the binary identity; GitHub's release tag records the source commit. This avoids a circular dependency between a commit ID and the binary checksum committed within it. +3. Open and validate the PR, merge it, and tag the exact merged commit. Include only the component's assets in its release. Do not upload all files in a shared `build` directory. +4. Prepare a draft release with explicit tag/title/target, upload the named EXE/ZIP, asset inventory and checksum file, then publish it without moving the repository-wide Latest badge. +5. Download the published assets, verify their hashes against the committed pin and inventory, and exercise the real installer in a temporary directory before reporting completion. + +For Hyper-V, upload these four files from `build/hyperv-control/release`: + +```text +hyper-v-control-win-x64-v0.1.0.exe +hyper-v-control-plugin-v0.1.0-win-x64.zip +hyper-v-control-v0.1.0-release.json +hyper-v-control-v0.1.0-SHA256SUMS.txt +``` + +The ZIP contains the EXE, manifests, English skill/documentation, launcher, installer, and licenses. The EXE-only download is for direct stdio MCP configuration. Local convenience aliases (`HyperVControl.exe`, `SHA256SUMS`) are not additional public Hyper-V assets. The inventory records primary asset names, roles, platforms, sizes and hashes; the checksum file also covers the inventory and does not hash itself. + +Rizin RE Toolkit 0.4.1 is an installer-only maintenance release; its runtime remains 0.3.1. Its release retains a `SHA256SUMS` compatibility asset and additionally publishes the namespaced checksum filename. The existing `rizin-v0.3.1` release is left untouched. Future Rizin runtime releases retain the existing runtime asset family and `SHA256SUMS` while older installers are supported. + +## Validation + +`scripts/test-release-isolation.ps1` substitutes only HTTP responses while performing real hash checks, extraction, version execution and installation. It tests a full page of unrelated releases before Rizin, a misleading tag with a Rizin-looking asset, drafts, prereleases, corrupt downloads, idempotent Hyper-V installation and coexistence of installation directories. The small synthetic Rizin version probe validates installer behavior; the actual published Rizin runtime is separately downloaded and executed for release verification. diff --git a/plugins/hyper-v-control/.codex-plugin/plugin.json b/plugins/hyper-v-control/.codex-plugin/plugin.json new file mode 100644 index 0000000..0ea1f53 --- /dev/null +++ b/plugins/hyper-v-control/.codex-plugin/plugin.json @@ -0,0 +1,15 @@ +{ + "name": "hyper-v-control", + "version": "0.1.0", + "description": "Hyper-V lifecycle, graphical guest control, and Windows debugging with a compiled C# stdio MCP.", + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "Hyper-V Control", + "shortDescription": "VM lifecycle, computer use, and Windows debugging.", + "developerName": "JerryLinLinLin", + "category": "Developer Tools", + "capabilities": ["Read", "Write", "Execute"], + "logo": "./assets/icon.svg" + } +} diff --git a/plugins/hyper-v-control/.mcp.json b/plugins/hyper-v-control/.mcp.json new file mode 100644 index 0000000..a26e8f6 --- /dev/null +++ b/plugins/hyper-v-control/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "hyper-v-control": { + "command": "powershell.exe", + "args": ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", "${PLUGIN_ROOT}/scripts/start.ps1"] + } + } +} diff --git a/plugins/hyper-v-control/LICENSE b/plugins/hyper-v-control/LICENSE new file mode 100644 index 0000000..85989eb --- /dev/null +++ b/plugins/hyper-v-control/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JerryLinLinLin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/hyper-v-control/README.md b/plugins/hyper-v-control/README.md new file mode 100644 index 0000000..eec40da --- /dev/null +++ b/plugins/hyper-v-control/README.md @@ -0,0 +1,53 @@ +# Hyper-V Control + +A Windows x64 C# MCP server over standard input/output for Hyper-V lifecycle, checkpoints, graphical guest control, file transfer and user/kernel debugging. The distributed `HyperVControl.exe` is self-contained: end users need neither Python nor a .NET SDK/runtime installation. + +## Run + +Get the [Windows x64 EXE](https://github.com/JerryLinLinLin/jerry-plugin-marketplace/releases/download/hyper-v-control-v0.1.0/hyper-v-control-win-x64-v0.1.0.exe) or [complete plugin ZIP](https://github.com/JerryLinLinLin/jerry-plugin-marketplace/releases/download/hyper-v-control-v0.1.0/hyper-v-control-plugin-v0.1.0-win-x64.zip) from [release 0.1.0](https://github.com/JerryLinLinLin/jerry-plugin-marketplace/releases/tag/hyper-v-control-v0.1.0). The release includes a namespaced checksum file and machine-readable asset inventory. It shares no release tag or installation directory with Rizin. + +Extract the prebuilt plugin ZIP and install this plugin from the marketplace/local plugin directory. It contains `bin/HyperVControl.exe`, manifests, launcher and `$hyper-v-control` skill. A source-only marketplace installation can run `scripts/install.ps1` after the matching release has been published; downloads are pinned by SHA-256 in `runtime.json`. + +For any stdio MCP client: + +```json +{ + "mcpServers": { + "hyper-v-control": { + "command": "C:\\Tools\\HyperVControl\\hyper-v-control-win-x64-v0.1.0.exe", + "args": ["--elevate"] + } + } +} +``` + +`--elevate` keeps the parent connected to the MCP client and opens a same-user elevated worker over a protected named pipe. The client itself need not be elevated. With UAC enabled, Windows may request consent; cancellation exits without operating on VMs. With sufficient existing privileges, the EXE can run without this option. Alternate-user UAC credentials are intentionally rejected by the same-user boundary. See the [permission model](skills/hyper-v-control/references/runtime.md). + +## Capabilities + +- VM inventory, create/configure/remove, power states, import/export/clone, virtual switches and adapters, VHD/ISO operations, Secure Boot. +- Checkpoint tree/current-parent discovery, create, restore, rename and delete. +- Basic and Enhanced Session consoles using Hyper-V RDP ActiveX; native framebuffer screenshots in Basic, guarded screen capture in Enhanced; keyboard/chords/text, pointer/buttons/scroll/drag, Enhanced clipboard. +- Credential Manager profiles; PowerShell Direct commands and processes with exit codes, timeouts and separate output streams; bidirectional files and folders, directory listing and bounded binary reads. +- Microsoft's winapp guest UI Automation through an interactive scheduled task. +- KDNET and two-phase named-pipe KDCOM configuration with checked `bcdedit` results. +- Persistent KD/CDB sessions: commands, break, poll, detach; symbols, memory, registers, stacks, breakpoints, stepping and dump analysis through normal debugger commands. +- Persistent in-guest CDB workers over PowerShell Direct, without guest networking or a second MCP server. + +Use `hyperv_capabilities` to inspect the actual environment. Windows guests need credentials for PowerShell Direct; semantic UI also needs an unlocked interactive desktop. Basic console can control boot and non-Windows guests without PowerShell Direct. Enhanced requires guest support and host policy. Host management is local; the graphical guest sessions are remote through Hyper-V, not through an HTTP MCP listener. + +Current Microsoft WinDbg is preferred. Set `HYPERV_CONTROL_DEBUGGER_DIR` to the complete `amd64` directory to select an engine explicitly. Modern MSIX packages are discovered ahead of legacy Windows Kits. The EXE returns the path and version used. Downloading the MCP EXE does not implicitly install WinDbg or alter Secure Boot, BitLocker, firewall or UAC policy. + +## Build and validate + +From the repository root with .NET 10 SDK: + +```powershell +./scripts/build-hyperv-control.ps1 +dotnet run --project tests/HyperVControl.Tests -c Release -- unit . +dotnet run --project tests/HyperVControl.Tests -c Release -- smoke . +``` + +The build creates the namespaced EXE, complete plugin ZIP, `hyper-v-control-v0.1.0-release.json` and `hyper-v-control-v0.1.0-SHA256SUMS.txt` under `build/hyperv-control/release`. Local `HyperVControl.exe` and `SHA256SUMS` aliases are for development only. Source lives under `src/HyperVControl`, independent of the Rizin plugin. Runtime binaries/build caches remain outside Git. The [extraction inventory](docs/extraction.md) maps RaivenX and reference-MCP features to their implementations. The [validation report](docs/validation.md) distinguishes measured results from untested environments. + +See [source/runtime notices](THIRD-PARTY-NOTICES.md). Release publication is separate from building local artifacts. diff --git a/plugins/hyper-v-control/THIRD-PARTY-NOTICES.md b/plugins/hyper-v-control/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000..c975b75 --- /dev/null +++ b/plugins/hyper-v-control/THIRD-PARTY-NOTICES.md @@ -0,0 +1,11 @@ +# Source and runtime notices + +Hyper-V Control's new integration code is covered by the repository MIT license. + +The five files under `src/HyperVControl/Extracted` were extracted and adapted at the owner's request from the supplied RaivenX checkout, revision `b5971dda1fa2050dde36b8b22fda9726df92cb48`: `HyperVService.cs`, `HyperVModels.cs`, `HyperVConsoleManager.cs`, `HyperVGuestUiService.cs`, and `Rgb565Pixels.cs`. Their source comments identify that provenance. The extraction excludes RaivenX chat, WPF app shell, provider integrations, SSH and WSL features. No claim is made that this project's MIT notice replaces separately applicable upstream terms. + +The supplied [originsec/hyperv-mcp](https://github.com/originsec/hyperv-mcp) checkout, revision `89d90ee529f150236249a0a7a9b72f14ebed6426`, was reviewed as a behavioral reference for its 19 tools and KDNET/KDCOM workflows. Its Python implementation is not bundled or executed. Upstream is Apache-2.0. + +The self-contained EXE includes Microsoft .NET runtime/libraries (MIT and component notices) and the [official Model Context Protocol C# SDK](https://github.com/modelcontextprotocol/csharp-sdk) (Apache-2.0). Exact NuGet packages and integrity hashes are recorded in `src/HyperVControl/packages.lock.json`. Upstream notices: [.NET runtime](https://github.com/dotnet/runtime/blob/main/THIRD-PARTY-NOTICES.TXT), [Windows Desktop](https://github.com/dotnet/winforms/blob/main/LICENSE.TXT), [MCP SDK license](https://github.com/modelcontextprotocol/csharp-sdk/blob/main/LICENSE). + +Microsoft WinDbg/KD/CDB and winapp are separate Microsoft products, not included in the Hyper-V Control release. Debugger discovery uses the user's installation or explicitly supplied engine directory. The optional guest-setup operations copy locally available tools into a user-selected VM or download official winapp release assets, preserving the full tool directory and its notices. Microsoft software remains subject to its own license; this plugin does not relicense it. diff --git a/plugins/hyper-v-control/assets/icon.svg b/plugins/hyper-v-control/assets/icon.svg new file mode 100644 index 0000000..e780388 --- /dev/null +++ b/plugins/hyper-v-control/assets/icon.svg @@ -0,0 +1 @@ + diff --git a/plugins/hyper-v-control/docs/extraction.md b/plugins/hyper-v-control/docs/extraction.md new file mode 100644 index 0000000..eec466e --- /dev/null +++ b/plugins/hyper-v-control/docs/extraction.md @@ -0,0 +1,43 @@ +# Hyper-V Control extraction inventory + +Sources inspected: local `C:\projects\raivenx` at `b5971dda1fa2050dde36b8b22fda9726df92cb48`, and `C:\projects\hyperv-mcp` at `89d90ee529f150236249a0a7a9b72f14ebed6426`. The scope is the entire Hyper-V control surface, not RaivenX's unrelated chat, browser, WSL or SSH products. + +| RaivenX command / workflow | Hyper-V Control implementation | +| --- | --- | +| Capability probe, local inventory, VM IDs/state | `hyperv_capabilities`, `hyperv_list`, `hyperv_vm_info`; extracted HyperVService | +| Start, shutdown, turn off, graceful restart, pause/resume/save | `hyperv_power`; preserves bounded graceful restart and no hard-power fallback | +| Firmware Secure Boot | `hyperv_firmware`; Off/Generation 2 checks and read-back | +| Checkpoint tree/current branch | `hyperv_checkpoints`; current parent retained independently of timestamp order | +| Checkpoint create/restore/delete/name/ID/current | `hyperv_checkpoint`; rename added | +| Multiple Basic/Enhanced consoles, open/close/list | `hyperv_console`; standalone owned WinForms windows replace the WPF chat panels | +| Basic ActiveX configuration | Extracted `HyperVConsoleManager`; port 2179, VM GUID PCB, no invented EnhancedMode=0 | +| Enhanced session, fallback, scaling, dynamic resolution | Same manager; actual mode and fallback reason returned; no RaivenX preference database dependency | +| Basic native framebuffer + RGB565 conversion | `hyperv_screenshot`; inline MCP image and native pixel dimensions | +| Enhanced capture | Guarded foreground/window-occlusion checks before copying the RDP control pixels | +| Click/type/key/scroll, Ctrl+Alt+Delete | `hyperv_input`; synthetic WMI input in Basic, RDP input sink in Enhanced; move/down/up added for drag | +| Saved credential, verify, sign in | `hyperv_credential`, `hyperv_input(login)`; Windows Credential Manager replaces RaivenX's plaintext file | +| Enhanced host↔guest clipboard | `hyperv_clipboard`; explicit shared host clipboard semantics | +| Guest PowerShell Direct | `hyperv_guest_run`, `hyperv_guest_process`; child timeout, exit code, independent stdout/stderr | +| Copy file/folder in both directions | `hyperv_copy`; extracted literal/base64-safe copy scripts, parent directory creation and folder recursion | +| winapp guest download/install/checksum | `hyperv_guest_setup`; extracted release digest verification | +| winapp status/inspect/search/list-windows/invoke/click/focus/get-value/set-value/get-property/wait-for/scroll | `hyperv_ui`; extracted interactive task bridge, lock/session checks, optional elevation and per-VM serialization | +| UAC broker and chat console ownership | Same-user stdio elevation bridge with PID/SID authentication; standalone MCP ownership replaces chat handoff UI | + +RaivenX-specific WPF tabs, tool permission cards, settings persistence and chat handoff confirmations are app-shell behavior, not bundled dependencies. A power transition can disconnect an ActiveX session; reopen the owned console after confirming VM state. The MCP reports this condition rather than relying on RaivenX's panel reconnection event handlers. + +| Reference Python MCP tools | Coverage and changes | +| --- | --- | +| list_vms / get_vm_info / start_vm / stop_vm / reset_vm | Inventory/detail/power tools, with additional create/configure/import/export/remove | +| checkpoint_create/list/restore/remove | Checkpoint tools, including current branch and ambiguous-name rejection | +| configure_kdnet | Checked guest BCD exit codes, 256-bit generated key material, explicit reboot and host IP/port | +| configure_kdcom | Split `serial_host` (Off) and `serial_guest` (Running); fixes impossible PowerShell Direct invocation while Off/Saved | +| guest_run / guest_run_ps | `guest_process` / `guest_run`; Unicode argv handling and separate output streams | +| guest_put/get/read_file/list_dir | `copy` / `guest_files`; bounded reads and byte counts | +| victim_run / victim_run_ps | Separate standard-account credential profiles; token is determined by the actual guest account, not an unverified "victim" label | +| External kd-mcp dependency | Replaced with C# persistent KD/CDB session management and in-guest user-debug workers | + +Additional gaps addressed: explicit debugger version/source discovery, current WinDbg preference, dump sessions, command continuation/polling, debugger interrupt and detach, guest-session isolation, single-file EXE packaging and plugin routing skill. + +Authoritative references used: [Hyper-V PowerShell Direct](https://learn.microsoft.com/windows-server/virtualization/hyper-v/powershell-direct), [current WinDbg distribution](https://learn.microsoft.com/windows-hardware/drivers/debugger/), [DbgEng SetInterrupt](https://learn.microsoft.com/windows-hardware/drivers/ddi/dbgeng/nf-dbgeng-idebugcontrol-setinterrupt), [DebugConnectWide](https://learn.microsoft.com/windows-hardware/drivers/ddi/dbgeng/nf-dbgeng-debugconnectwide), [VM KDNET](https://learn.microsoft.com/windows-hardware/drivers/debugger/setting-up-network-debugging-of-a-virtual-machine-host), [winapp](https://github.com/microsoft/winappCli), [official MCP C# SDK](https://github.com/modelcontextprotocol/csharp-sdk), [OpenAI plugin packaging](https://developers.openai.com/plugins/build/plugins). + +Each KD/CDB session runs through a C# helper owning a private hidden Windows console while command/output streams remain redirected. This supports KD's console requirements and limits Ctrl+Break to that session. The MCP process and other debugger sessions do not share the console. `kd/cdb -wake` only exits the special user-debugger sleep mode and is not a general target-break mechanism. No debugger remoting server or firewall-sharing ports are enabled. See [AllocConsole](https://learn.microsoft.com/windows/console/allocconsole) and [GenerateConsoleCtrlEvent](https://learn.microsoft.com/windows/console/generateconsolectrlevent). diff --git a/plugins/hyper-v-control/docs/validation.md b/plugins/hyper-v-control/docs/validation.md new file mode 100644 index 0000000..c77a3c4 --- /dev/null +++ b/plugins/hyper-v-control/docs/validation.md @@ -0,0 +1,55 @@ +# Validation + +Validation date: October 1, 2026 (America/Chicago). Local Windows x64 build, .NET SDK 10.0.401, self-contained .NET runtime 10.0.12, official MCP C# SDK 2.2.0. + +The designated live target was `Windows 11 AV Test`, a Generation 2 Windows 11 guest (build 22621), initially Off. A dedicated baseline checkpoint was created before mutations; existing checkpoint branches were retained. The user explicitly authorized temporarily changing Secure Boot and guest BCD for kernel debugging, with restoration afterward. + +## Measured behavior + +| Area | Evidence | +| --- | --- | +| Build | C# compilation and single-file publish succeeded with zero warnings/errors | +| MCP protocol | Official C# client initialized both the EXE and packaged PowerShell launcher over stdio and discovered 36 unique tools; invalid path/host access failures returned structured errors | +| Elevation | Same-user medium-token parent connected to an elevated worker; authenticated named-pipe bridge carried normal tool responses and PNG images | +| VM/checkpoints | Inventory/detail, checkpoint creation, start, graceful shutdown/restart, pause/resume and save/start exercised on the designated VM | +| Guest execution | Correct zero/nonzero exit codes, distinct stdout/stderr, UTF-8 content; native executable with empty argv and explicit argv/exit-code cases | +| Files | Host→guest→host transfer with spaces and non-ASCII names; returned bytes matched exactly; bounded base64 read exercised | +| Basic console | Native 1024×768 PNG delivered through MCP; mouse clicked the password field, saved credentials signed in, keyboard opened Notepad | +| Semantic UI | Official winapp 0.7.1 downloaded with release SHA-256 verification; window listing and JSON inspection returned Notepad's actual element tree | +| Enhanced console | Actual Enhanced connection established; background capture rejected as designed instead of returning host pixels | +| Modern debugging tools | Official Microsoft WinDbg package 1.2606.22001.0, KD/CDB file version 10.0.29617.1000; Microsoft signature validated | +| User-mode debugging | CDB launch, consecutive commands, registers, stack, modules, continue/break and detach; target PID remained alive after detach; final in-guest persistent scheduled worker accessed through PowerShell Direct | +| KDCOM | Off-state COM mapping followed by running-guest BCD configuration; real kernel connection, `vertarget`, registers/stack, detach, and successful guest command after detach | +| KDNET | Configuration with generated key, virtual-switch IPv4 and UDP port 50009; real kernel connection, commands, detach, and successful guest command after detach | +| Skill | Bundled skill creator validator passed | +| Packaging | Portable plugin and MCP manifests passed their declared official JSON schemas; launcher hash validation and PowerShell parsing passed | +| Release isolation | Rizin selects its runtime beyond 100 unrelated releases; tag/asset filtering rejects other plugin streams, drafts and prereleases. Hyper-V downloads an exact pinned asset, reuses matching installations and leaves Rizin/PATH intact; corrupt bytes are rejected | +| Reproducibility | Publishing from a separate checkout path produced the same pinned EXE SHA-256; CI checks the runtime pin after rebuilding with the fixed SDK/runtime | +| Language | Plugin metadata, skill, documentation, MCP descriptions and implementation messages are English | + +Raw local results and screenshots are written under ignored `build/hyperv-control/validation`; guest credentials are never logged. Connection keys in diagnostic results are test data and are invalidated when the baseline is restored. These logs are not packaged into the plugin. + +Cleanup completed: the VM is Off, Secure Boot was read back as enabled, both COM port paths are empty, all 27 original checkpoint IDs are preserved, and the original current-parent branch was restored. The dedicated validation checkpoint was removed. Guest tool installations and debugging BCD changes were rolled back with the checkpoint. + +## Practical limits + +- This host did not present an interactive UAC consent dialog during testing. The split-token elevation path ran successfully, including the owner-SID mismatch fix; UAC-on consent/cancellation and alternate-admin-account behavior still require separate interactive testing. The bridge intentionally supports the same Windows user only. +- Enhanced connection and rejection of obscured/background capture were measured. A successful foreground Enhanced screenshot, clipboard roundtrip and its full input matrix were not certified by this run. Basic screenshot/input and guest UI Automation provide independently tested channels. +- Live create/remove/import/export, large disk resizing, multi-host management, every guest OS version, and every debugger command/extension were not exhaustively exercised. The local host is the management boundary; unsupported guest services return errors. +- KD needs a real Windows console even when its streams are redirected. The final backend uses a private hidden console per session and Ctrl+Break scoped to it. The earlier `-wake`/remote-interrupt experiments are not the shipped backend. +- Symbol download and first attachment can outlast a single call. Pending commands remain in the same session for poll/break; a launched process or successful BCD update is not reported as a verified attachment. +- No GitHub Release is published by the local build. `scripts/install.ps1` requires the matching published asset; the prebuilt plugin ZIP contains the EXE and works without downloading it. + +## Review regression checks + +The three review findings are covered by `tests/HyperVControl.Tests/ReviewRegressionTests.cs`, included in the normal `unit` phase: + +- Both host and guest UI quoting preserve ASCII apostrophes and U+2018/U+2019/U+201A/U+201B exactly. Embedded command text remains data under the real Windows PowerShell parser. +- The exact guest child-process wrapper runs 13,001- and 200,000-character scripts with multibyte Unicode content through stdin. The tests check execution at the script tail, separate UTF-8 stdout/stderr, nonzero exit codes, empty-input EOF, timeout termination and over-limit rejection. Only a fixed bootstrap remains on the command line. +- Guest debugger startup failures using either `success=false` or `ok=false` produce an MCP error while preserving the worker ID, task name and diagnostic payload. Contradictory flags cannot override an explicit failure; successful startup remains successful. + +These checks ran locally under Windows PowerShell without connecting to or modifying a VM. The child-process timeout case required execution outside the restricted development sandbox so `taskkill` could terminate the test's own process tree. + +## Reproduction + +Build with `scripts/build-hyperv-control.ps1`; run the C# harness in `unit` and `smoke` modes. `launcher` exercises the packaged PowerShell entrypoint. Live phases are explicit: `prepare`, `guest`, `ui`, `debug`, `kernel-serial`, `kernel-net`, and `cleanup`. They require a dedicated Off test VM and authorization for the changes involved. `HYPERV_CONTROL_TEST_VM` selects it; `HYPERV_CONTROL_TEST_CREDENTIAL_FILE` can supply a per-VM credential JSON for preparation. Archive prior validation output before another prepare: the harness refuses to overwrite its recovery checkpoint record. Always complete cleanup after an interrupted live run. diff --git a/plugins/hyper-v-control/licenses/dotnet-MIT.txt b/plugins/hyper-v-control/licenses/dotnet-MIT.txt new file mode 100644 index 0000000..984713a --- /dev/null +++ b/plugins/hyper-v-control/licenses/dotnet-MIT.txt @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) .NET Foundation and Contributors + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/hyper-v-control/licenses/dotnet-third-party.txt b/plugins/hyper-v-control/licenses/dotnet-third-party.txt new file mode 100644 index 0000000..38d05d2 --- /dev/null +++ b/plugins/hyper-v-control/licenses/dotnet-third-party.txt @@ -0,0 +1,1417 @@ +.NET Runtime uses third-party libraries or other resources that may be +distributed under licenses different than the .NET Runtime software. + +In the event that we accidentally failed to list a required notice, please +bring it to our attention. Post an issue or email us: + + dotnet@microsoft.com + +The attached notices are provided for information only. + +License notice for ASP.NET +------------------------------- + +Copyright (c) .NET Foundation. All rights reserved. +Licensed under the Apache License, Version 2.0. + +Available at +https://github.com/dotnet/aspnetcore/blob/main/LICENSE.txt + +License notice for Slicing-by-8 +------------------------------- + +http://sourceforge.net/projects/slicing-by-8/ + +Copyright (c) 2004-2006 Intel Corporation - All Rights Reserved + + +This software program is licensed subject to the BSD License, available at +http://www.opensource.org/licenses/bsd-license.html. + + +License notice for Unicode data +------------------------------- + +https://www.unicode.org/license.html + +Copyright © 1991-2024 Unicode, Inc. All rights reserved. +Distributed under the Terms of Use in https://www.unicode.org/copyright.html. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +License notice for zlib-ng +----------------------- + +https://github.com/zlib-ng/zlib-ng/blob/d54e3769be0c522015b784eca2af258b1c026107/LICENSE.md + +(C) 1995-2024 Jean-loup Gailly and Mark Adler + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + +3. This notice may not be removed or altered from any source distribution. + +License notice for opentelemetry-dotnet +--------------------------------------- + +https://github.com/open-telemetry/opentelemetry-dotnet/blob/805dd6b4abfa18ef2706d04c30d0ed28dbc2955e/LICENSE.TXT#L1 + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +Copyright The OpenTelemetry Authors + + +License notice for LinuxTracepoints +----------------------------------- + +https://github.com/microsoft/LinuxTracepoints/blob/main/LICENSE + +Copyright (c) Microsoft Corporation. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE + +License notice for Mono +------------------------------- + +http://www.mono-project.com/docs/about-mono/ + +Copyright (c) .NET Foundation Contributors + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the Software), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +License notice for International Organization for Standardization +----------------------------------------------------------------- + +Portions (C) International Organization for Standardization 1986: + Permission to copy in any form is granted for use with + conforming SGML systems and applications as defined in + ISO 8879, provided this notice is included in all copies. + +License notice for Intel +------------------------ + +"Copyright (c) 2004-2006 Intel Corporation - All Rights Reserved + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +License notice for Xamarin and Novell +------------------------------------- + +Copyright (c) 2015 Xamarin, Inc (http://www.xamarin.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +Copyright (c) 2011 Novell, Inc (http://www.novell.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +Third party notice for W3C +-------------------------- + +"W3C SOFTWARE AND DOCUMENT NOTICE AND LICENSE +Status: This license takes effect 13 May, 2015. +This work is being provided by the copyright holders under the following license. +License +By obtaining and/or copying this work, you (the licensee) agree that you have read, understood, and will comply with the following terms and conditions. +Permission to copy, modify, and distribute this work, with or without modification, for any purpose and without fee or royalty is hereby granted, provided that you include the following on ALL copies of the work or portions thereof, including modifications: +The full text of this NOTICE in a location viewable to users of the redistributed or derivative work. +Any pre-existing intellectual property disclaimers, notices, or terms and conditions. If none exist, the W3C Software and Document Short Notice should be included. +Notice of any changes or modifications, through a copyright statement on the new code or document such as "This software or document includes material copied from or derived from [title and URI of the W3C document]. Copyright © [YEAR] W3C® (MIT, ERCIM, Keio, Beihang)." +Disclaimers +THIS WORK IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE OR DOCUMENT WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS. +COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF ANY USE OF THE SOFTWARE OR DOCUMENT. +The name and trademarks of copyright holders may NOT be used in advertising or publicity pertaining to the work without specific, written prior permission. Title to copyright in this work will at all times remain with copyright holders." + +License notice for Bit Twiddling Hacks +-------------------------------------- + +Bit Twiddling Hacks + +By Sean Eron Anderson +seander@cs.stanford.edu + +Individually, the code snippets here are in the public domain (unless otherwise +noted) — feel free to use them however you please. The aggregate collection and +descriptions are © 1997-2005 Sean Eron Anderson. The code and descriptions are +distributed in the hope that they will be useful, but WITHOUT ANY WARRANTY and +without even the implied warranty of merchantability or fitness for a particular +purpose. + +License notice for Brotli +-------------------------------------- + +Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +compress_fragment.c: +Copyright (c) 2011, Google Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +""AS IS"" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +decode_fuzzer.c: +Copyright (c) 2015 The Chromium Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +""AS IS"" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." + +License notice for Json.NET +------------------------------- + +https://github.com/JamesNK/Newtonsoft.Json/blob/master/LICENSE.md + +The MIT License (MIT) + +Copyright (c) 2007 James Newton-King + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +License notice for vectorized base64 encoding / decoding +-------------------------------------------------------- + +Copyright (c) 2005-2007, Nick Galbreath +Copyright (c) 2013-2017, Alfred Klomp +Copyright (c) 2015-2017, Wojciech Mula +Copyright (c) 2016-2017, Matthieu Darbois +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +License notice for vectorized hex parsing +-------------------------------------------------------- + +Copyright (c) 2022, Geoff Langdale +Copyright (c) 2022, Wojciech Mula +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +License notice for RFC 3492 +--------------------------- + +The punycode implementation is based on the sample code in RFC 3492 + +Copyright (C) The Internet Society (2003). All Rights Reserved. + +This document and translations of it may be copied and furnished to +others, and derivative works that comment on or otherwise explain it +or assist in its implementation may be prepared, copied, published +and distributed, in whole or in part, without restriction of any +kind, provided that the above copyright notice and this paragraph are +included on all such copies and derivative works. However, this +document itself may not be modified in any way, such as by removing +the copyright notice or references to the Internet Society or other +Internet organizations, except as needed for the purpose of +developing Internet standards in which case the procedures for +copyrights defined in the Internet Standards process must be +followed, or as required to translate it into languages other than +English. + +The limited permissions granted above are perpetual and will not be +revoked by the Internet Society or its successors or assigns. + +This document and the information contained herein is provided on an +"AS IS" basis and THE INTERNET SOCIETY AND THE INTERNET ENGINEERING +TASK FORCE DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING +BUT NOT LIMITED TO ANY WARRANTY THAT THE USE OF THE INFORMATION +HEREIN WILL NOT INFRINGE ANY RIGHTS OR ANY IMPLIED WARRANTIES OF +MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + +Copyright(C) The Internet Society 1997. All Rights Reserved. + +This document and translations of it may be copied and furnished to others, +and derivative works that comment on or otherwise explain it or assist in +its implementation may be prepared, copied, published and distributed, in +whole or in part, without restriction of any kind, provided that the above +copyright notice and this paragraph are included on all such copies and +derivative works.However, this document itself may not be modified in any +way, such as by removing the copyright notice or references to the Internet +Society or other Internet organizations, except as needed for the purpose of +developing Internet standards in which case the procedures for copyrights +defined in the Internet Standards process must be followed, or as required +to translate it into languages other than English. + +The limited permissions granted above are perpetual and will not be revoked +by the Internet Society or its successors or assigns. + +This document and the information contained herein is provided on an "AS IS" +basis and THE INTERNET SOCIETY AND THE INTERNET ENGINEERING TASK FORCE +DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO +ANY WARRANTY THAT THE USE OF THE INFORMATION HEREIN WILL NOT INFRINGE ANY +RIGHTS OR ANY IMPLIED WARRANTIES OF MERCHANTABILITY OR FITNESS FOR A +PARTICULAR PURPOSE. + +License notice for Algorithm from RFC 4122 - +A Universally Unique IDentifier (UUID) URN Namespace +---------------------------------------------------- + +Copyright (c) 1990- 1993, 1996 Open Software Foundation, Inc. +Copyright (c) 1989 by Hewlett-Packard Company, Palo Alto, Ca. & +Digital Equipment Corporation, Maynard, Mass. +Copyright (c) 1998 Microsoft. +To anyone who acknowledges that this file is provided "AS IS" +without any express or implied warranty: permission to use, copy, +modify, and distribute this file for any purpose is hereby +granted without fee, provided that the above copyright notices and +this notice appears in all source code copies, and that none of +the names of Open Software Foundation, Inc., Hewlett-Packard +Company, Microsoft, or Digital Equipment Corporation be used in +advertising or publicity pertaining to distribution of the software +without specific, written prior permission. Neither Open Software +Foundation, Inc., Hewlett-Packard Company, Microsoft, nor Digital +Equipment Corporation makes any representations about the +suitability of this software for any purpose." + +License notice for The LLVM Compiler Infrastructure +--------------------------------------------------- + +Developed by: + + LLVM Team + + University of Illinois at Urbana-Champaign + + http://llvm.org + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal with +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimers. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimers in the + documentation and/or other materials provided with the distribution. + + * Neither the names of the LLVM Team, University of Illinois at + Urbana-Champaign, nor the names of its contributors may be used to + endorse or promote products derived from this Software without specific + prior written permission. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE +SOFTWARE. + +License notice for Bob Jenkins +------------------------------ + +By Bob Jenkins, 1996. bob_jenkins@burtleburtle.net. You may use this +code any way you wish, private, educational, or commercial. It's free. + +License notice for Greg Parker +------------------------------ + +Greg Parker gparker@cs.stanford.edu December 2000 +This code is in the public domain and may be copied or modified without +permission. + +License notice for libunwind based code +---------------------------------------- + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +License notice for Printing Floating-Point Numbers (Dragon4) +------------------------------------------------------------ + +/****************************************************************************** + Copyright (c) 2014 Ryan Juckett + http://www.ryanjuckett.com/ + + This software is provided 'as-is', without any express or implied + warranty. In no event will the authors be held liable for any damages + arising from the use of this software. + + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + + 3. This notice may not be removed or altered from any source + distribution. +******************************************************************************/ + +License notice for Printing Floating-point Numbers (Grisu3) +----------------------------------------------------------- + +Copyright 2012 the V8 project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +License notice for xxHash +------------------------- + +xxHash - Extremely Fast Hash algorithm +Header File +Copyright (C) 2012-2021 Yann Collet + +BSD 2-Clause License (https://www.opensource.org/licenses/bsd-license.php) + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer + in the documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +You can contact the author at: + - xxHash homepage: https://www.xxhash.com + - xxHash source repository: https://github.com/Cyan4973/xxHash + +License notice for Berkeley SoftFloat Release 3e +------------------------------------------------ + +https://github.com/ucb-bar/berkeley-softfloat-3 +https://github.com/ucb-bar/berkeley-softfloat-3/blob/master/COPYING.txt + +License for Berkeley SoftFloat Release 3e + +John R. Hauser +2018 January 20 + +The following applies to the whole of SoftFloat Release 3e as well as to +each source file individually. + +Copyright 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018 The Regents of the +University of California. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions, and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions, and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. Neither the name of the University nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS "AS IS", AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, ARE +DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +License notice for xoshiro RNGs +-------------------------------- + +Written in 2018 by David Blackman and Sebastiano Vigna (vigna@acm.org) + +To the extent possible under law, the author has dedicated all copyright +and related and neighboring rights to this software to the public domain +worldwide. This software is distributed without any warranty. + +See . + +License for fastmod (https://github.com/lemire/fastmod), ibm-fpgen (https://github.com/nigeltao/parse-number-fxx-test-data) and fastrange (https://github.com/lemire/fastrange) +-------------------------------------- + + Copyright 2018 Daniel Lemire + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +License for sse4-strstr (https://github.com/WojciechMula/sse4-strstr) +-------------------------------------- + + Copyright (c) 2008-2016, Wojciech Mula + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS + IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +License notice for The C++ REST SDK +----------------------------------- + +C++ REST SDK + +The MIT License (MIT) + +Copyright (c) Microsoft Corporation + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +License notice for MessagePack-CSharp +------------------------------------- + +MessagePack for C# + +MIT License + +Copyright (c) 2017 Yoshifumi Kawai + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +License notice for lz4net +------------------------------------- + +lz4net + +Copyright (c) 2013-2017, Milosz Krajewski + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +License notice for Nerdbank.Streams +----------------------------------- + +The MIT License (MIT) + +Copyright (c) Andrew Arnott + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +License notice for RapidJSON +---------------------------- + +Tencent is pleased to support the open source community by making RapidJSON available. + +Copyright (C) 2015 THL A29 Limited, a Tencent company, and Milo Yip. All rights reserved. + +Licensed under the MIT License (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + +http://opensource.org/licenses/MIT + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +License notice for DirectX Math Library +--------------------------------------- + +https://github.com/microsoft/DirectXMath/blob/master/LICENSE + + The MIT License (MIT) + +Copyright (c) 2011-2020 Microsoft Corp + +Permission is hereby granted, free of charge, to any person obtaining a copy of this +software and associated documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be included in all copies +or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +License notice for ldap4net +--------------------------- + +The MIT License (MIT) + +Copyright (c) 2018 Alexander Chermyanin + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +License notice for vectorized sorting code +------------------------------------------ + +MIT License + +Copyright (c) 2020 Dan Shechter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +License notice for musl +----------------------- + +musl as a whole is licensed under the following standard MIT license: + +Copyright © 2005-2020 Rich Felker, et al. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +License notice for "Faster Unsigned Division by Constants" +------------------------------ + +Reference implementations of computing and using the "magic number" approach to dividing +by constants, including codegen instructions. The unsigned division incorporates the +"round down" optimization per ridiculous_fish. + +This is free and unencumbered software. Any copyright is dedicated to the Public Domain. + + +License notice for mimalloc +----------------------------------- + +MIT License + +Copyright (c) 2019 Microsoft Corporation, Daan Leijen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +License for remote stack unwind (https://github.com/llvm/llvm-project/blob/main/lldb/source/Symbol/CompactUnwindInfo.cpp) +-------------------------------------- + +Copyright 2019 LLVM Project + +Licensed under the Apache License, Version 2.0 (the "License") with LLVM Exceptions; +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +https://llvm.org/LICENSE.txt + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +License notice for Apple header files +------------------------------------- + +Copyright (c) 1980, 1986, 1993 + The Regents of the University of California. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. All advertising materials mentioning features or use of this software + must display the following acknowledgement: + This product includes software developed by the University of + California, Berkeley and its contributors. +4. Neither the name of the University nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. + +License notice for JavaScript queues +------------------------------------- + +CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER. + +Statement of Purpose +The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). +Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. +For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: +the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; +moral rights retained by the original author(s) and/or performer(s); +publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; +rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; +rights protecting the extraction, dissemination, use and reuse of data in a Work; +database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and +other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. +2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. +3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. +4. Limitations and Disclaimers. +a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. +b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. +c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. +d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. + + +License notice for FastFloat algorithm +------------------------------------- +MIT License +Copyright (c) 2021 csFastFloat authors +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +License notice for MsQuic +-------------------------------------- + +Copyright (c) Microsoft Corporation. +Licensed under the MIT License. + +Available at +https://github.com/microsoft/msquic/blob/main/LICENSE + +License notice for m-ou-se/floatconv +------------------------------- + +Copyright (c) 2020 Mara Bos +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +License notice for code from The Practice of Programming +------------------------------- + +Copyright (C) 1999 Lucent Technologies + +Excerpted from 'The Practice of Programming +by Brian W. Kernighan and Rob Pike + +You may use this code for any purpose, as long as you leave the copyright notice and book citation attached. + +Notice for Euclidean Affine Functions and Applications to Calendar +Algorithms +------------------------------- + +Aspects of Date/Time processing based on algorithm described in "Euclidean Affine Functions and Applications to Calendar +Algorithms", Cassio Neri and Lorenz Schneider. https://arxiv.org/pdf/2102.06959.pdf + +License notice for amd/aocl-libm-ose +------------------------------- + +Copyright (C) 2008-2020 Advanced Micro Devices, Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +License notice for fmtlib/fmt +------------------------------- + +Formatting library for C++ + +Copyright (c) 2012 - present, Victor Zverovich + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +License for Jb Evain +--------------------- + +Copyright (c) 2006 Jb Evain (jbevain@gmail.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--- Optional exception to the license --- + +As an exception, if, as a result of your compiling your source code, portions +of this Software are embedded into a machine-executable object form of such +source code, you may redistribute such embedded portions in such object form +without including the above copyright and permission notices. + + +License for MurmurHash3 +-------------------------------------- + +https://github.com/aappleby/smhasher/blob/master/src/MurmurHash3.cpp + +MurmurHash3 was written by Austin Appleby, and is placed in the public +domain. The author hereby disclaims copyright to this source + +License for Fast CRC Computation +-------------------------------------- + +https://github.com/intel/isa-l/blob/33a2d9484595c2d6516c920ce39a694c144ddf69/crc/crc32_ieee_by4.asm +https://github.com/intel/isa-l/blob/33a2d9484595c2d6516c920ce39a694c144ddf69/crc/crc64_ecma_norm_by8.asm + +Copyright(c) 2011-2015 Intel Corporation All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of Intel Corporation nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +License for C# Implementation of Fast CRC Computation +----------------------------------------------------- + +https://github.com/SixLabors/ImageSharp/blob/f4f689ce67ecbcc35cebddba5aacb603e6d1068a/src/ImageSharp/Formats/Png/Zlib/Crc32.cs + +Copyright (c) Six Labors. +Licensed under the Apache License, Version 2.0. + +Available at +https://github.com/SixLabors/ImageSharp/blob/f4f689ce67ecbcc35cebddba5aacb603e6d1068a/LICENSE + +License for the Teddy multi-substring searching implementation +-------------------------------------- + +https://github.com/BurntSushi/aho-corasick + +The MIT License (MIT) + +Copyright (c) 2015 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +License notice for Avx512Vbmi base64 encoding / decoding +-------------------------------------------------------- + +Copyright (c) 2015-2018, Wojciech Muła +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------- + +Aspects of base64 encoding / decoding are based on algorithm described in "Base64 encoding and decoding at almost the speed of a memory +copy", Wojciech Muła and Daniel Lemire. https://arxiv.org/pdf/1910.05109.pdf + +License for FormatJS Intl.Segmenter grapheme segmentation algorithm +-------------------------------------------------------------------------- +Available at https://github.com/formatjs/formatjs/blob/58d6a7b398d776ca3d2726d72ae1573b65cc3bef/packages/intl-segmenter/LICENSE.md + +MIT License + +Copyright (c) 2022 FormatJS + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +License for SharpFuzz and related samples +-------------------------------------- + +https://github.com/Metalnem/sharpfuzz +https://github.com/Metalnem/dotnet-fuzzers +https://github.com/Metalnem/libfuzzer-dotnet + +MIT License + +Copyright (c) 2018 Nemanja Mijailovic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +License for National Institute of Standards and Technology ACVP Data +-------------------------------------------------------------------- +Available at https://github.com/usnistgov/ACVP-Server/blob/85f8742965b2691862079172982683757d8d91db/README.md#License + +NIST-developed software is provided by NIST as a public service. You may use, copy, and distribute copies of the software in any medium, provided that you keep intact this entire notice. You may improve, modify, and create derivative works of the software or any portion of the software, and you may copy and distribute such modifications or works. Modified works should carry a notice stating that you changed the software and should note the date and nature of any such change. Please explicitly acknowledge the National Institute of Standards and Technology as the source of the software. + +NIST-developed software is expressly provided "AS IS." NIST MAKES NO WARRANTY OF ANY KIND, EXPRESS, IMPLIED, IN FACT, OR ARISING BY OPERATION OF LAW, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND DATA ACCURACY. NIST NEITHER REPRESENTS NOR WARRANTS THAT THE OPERATION OF THE SOFTWARE WILL BE UNINTERRUPTED OR ERROR-FREE, OR THAT ANY DEFECTS WILL BE CORRECTED. NIST DOES NOT WARRANT OR MAKE ANY REPRESENTATIONS REGARDING THE USE OF THE SOFTWARE OR THE RESULTS THEREOF, INCLUDING BUT NOT LIMITED TO THE CORRECTNESS, ACCURACY, RELIABILITY, OR USEFULNESS OF THE SOFTWARE. + +You are solely responsible for determining the appropriateness of using and distributing the software and you assume all risks associated with its use, including but not limited to the risks and costs of program errors, compliance with applicable laws, damage to or loss of data, programs or equipment, and the unavailability or interruption of operation. This software is not intended to be used in any situation where a failure could cause risk of injury or damage to property. The software developed by NIST employees is not subject to copyright protection within the United States. diff --git a/plugins/hyper-v-control/licenses/mcp-Apache-2.0.txt b/plugins/hyper-v-control/licenses/mcp-Apache-2.0.txt new file mode 100644 index 0000000..185054b --- /dev/null +++ b/plugins/hyper-v-control/licenses/mcp-Apache-2.0.txt @@ -0,0 +1,216 @@ +The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. + +Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. + +No rights beyond those granted by the applicable original license are conveyed for such contributions. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright + owner or by an individual or Legal Entity authorized to submit on behalf + of the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +--- + +MIT License + +Copyright (c) 2024-2026 Model Context Protocol a Series of LF Projects, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Creative Commons Attribution 4.0 International (CC-BY-4.0) + +Documentation in this project (excluding specifications) is licensed under +CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for +the full license text. diff --git a/plugins/hyper-v-control/licenses/windows-forms-MIT.txt b/plugins/hyper-v-control/licenses/windows-forms-MIT.txt new file mode 100644 index 0000000..984713a --- /dev/null +++ b/plugins/hyper-v-control/licenses/windows-forms-MIT.txt @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) .NET Foundation and Contributors + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/hyper-v-control/mcp.json b/plugins/hyper-v-control/mcp.json new file mode 100644 index 0000000..68547d7 --- /dev/null +++ b/plugins/hyper-v-control/mcp.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "hyper-v-control": { + "type": "stdio", + "command": "powershell.exe", + "args": ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", "${PLUGIN_ROOT}/scripts/start.ps1"] + } + } +} diff --git a/plugins/hyper-v-control/plugin.json b/plugins/hyper-v-control/plugin.json new file mode 100644 index 0000000..0149556 --- /dev/null +++ b/plugins/hyper-v-control/plugin.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "hyper-v-control", + "version": "0.1.0", + "description": "Control Hyper-V VMs, graphical consoles, guest automation and user/kernel debugging through a native C# stdio MCP server.", + "author": {"name": "JerryLinLinLin", "url": "https://github.com/JerryLinLinLin"}, + "repository": "https://github.com/JerryLinLinLin/jerry-plugin-marketplace", + "license": "MIT", + "keywords": ["hyper-v", "windows", "virtual-machine", "debugger", "windbg", "mcp"], + "extensions": { + "com.openai": { + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "Hyper-V Control", + "shortDescription": "VM lifecycle, computer use, and Windows debugging.", + "longDescription": "A compiled Windows x64 C# MCP server over stdio. Manage VM lifecycle and checkpoints, operate Basic and Enhanced consoles, transfer files, automate guest UI with winapp, and debug user processes or a guest kernel with current WinDbg engines.", + "developerName": "JerryLinLinLin", + "category": "Developer Tools", + "capabilities": ["Read", "Write", "Execute"], + "logo": "./assets/icon.svg", + "brandColor": "#1674D1", + "defaultPrompt": ["Use Hyper-V Control to inspect my VMs and choose the right management, automation, or debugging workflow."] + } + } + } +} diff --git a/plugins/hyper-v-control/runtime.json b/plugins/hyper-v-control/runtime.json new file mode 100644 index 0000000..98951e6 --- /dev/null +++ b/plugins/hyper-v-control/runtime.json @@ -0,0 +1,11 @@ +{ + "plugin": "hyper-v-control", + "version": "0.1.0", + "tag": "hyper-v-control-v0.1.0", + "asset": "hyper-v-control-win-x64-v0.1.0.exe", + "rid": "win-x64", + "framework": "net10.0-windows", + "selfContained": true, + "sha256": "c58492c6711e0448b47019092ca47624f0b71f5b3fd762053f13606409cefbc6", + "url": "https://github.com/JerryLinLinLin/jerry-plugin-marketplace/releases/download/hyper-v-control-v0.1.0/hyper-v-control-win-x64-v0.1.0.exe" +} diff --git a/plugins/hyper-v-control/scripts/install.ps1 b/plugins/hyper-v-control/scripts/install.ps1 new file mode 100644 index 0000000..831a457 --- /dev/null +++ b/plugins/hyper-v-control/scripts/install.ps1 @@ -0,0 +1,37 @@ +#Requires -Version 5.1 +[CmdletBinding()] +param([string]$Destination = (Join-Path $env:LOCALAPPDATA 'Programs\HyperVControl')) +$ErrorActionPreference = 'Stop' +function Get-RuntimeHash([string]$Path) { + $stream = [IO.File]::OpenRead($Path) + $hash = [Security.Cryptography.SHA256]::Create() + try { return [BitConverter]::ToString($hash.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() } + finally { $stream.Dispose(); $hash.Dispose() } +} +$pluginRoot = Split-Path -Parent $PSScriptRoot +$runtime = Get-Content -LiteralPath (Join-Path $pluginRoot 'runtime.json') -Raw | ConvertFrom-Json +if ($runtime.version -notmatch '^\d+\.\d+\.\d+$' -or $runtime.sha256 -notmatch '^[0-9a-fA-F]{64}$') { throw 'Invalid pinned runtime manifest.' } +if ($runtime.plugin -ne 'hyper-v-control' -or $runtime.rid -ne 'win-x64' -or + $runtime.tag -ne "hyper-v-control-v$($runtime.version)" -or + $runtime.asset -ne "hyper-v-control-win-x64-v$($runtime.version).exe" -or + $runtime.url -ne "https://github.com/JerryLinLinLin/jerry-plugin-marketplace/releases/download/$($runtime.tag)/$($runtime.asset)") { + throw 'Runtime manifest does not identify the expected Hyper-V Control release.' +} +$root = [IO.Path]::GetFullPath($Destination) +$install = Join-Path $root $runtime.version +$exe = Join-Path $install 'HyperVControl.exe' +if (Test-Path -LiteralPath $exe) { + if ((Get-RuntimeHash $exe) -eq $runtime.sha256) { Write-Output $exe; return } + throw 'An existing executable has a different checksum. Choose a fresh destination.' +} +New-Item -ItemType Directory -Force -Path $install | Out-Null +$temporary = Join-Path $install ('download-' + [guid]::NewGuid().ToString('N') + '.tmp') +try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -UseBasicParsing -Uri $runtime.url -OutFile $temporary + if ((Get-RuntimeHash $temporary) -ne $runtime.sha256) { throw 'Downloaded EXE SHA-256 verification failed.' } + Move-Item -LiteralPath $temporary -Destination $exe + $version = & $exe --version + if ($LASTEXITCODE -ne 0 -or $version -ne ('Hyper-V Control ' + $runtime.version)) { throw 'Runtime version check failed.' } + Write-Output $exe +} finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force } } diff --git a/plugins/hyper-v-control/scripts/start.ps1 b/plugins/hyper-v-control/scripts/start.ps1 new file mode 100644 index 0000000..492da38 --- /dev/null +++ b/plugins/hyper-v-control/scripts/start.ps1 @@ -0,0 +1,24 @@ +#Requires -Version 5.1 +$ErrorActionPreference = 'Stop' +$pluginRoot = Split-Path -Parent $PSScriptRoot +$runtime = Get-Content -LiteralPath (Join-Path $pluginRoot 'runtime.json') -Raw | ConvertFrom-Json +$exe = Join-Path $pluginRoot 'bin\HyperVControl.exe' +if (-not (Test-Path -LiteralPath $exe)) { + $exe = Join-Path $env:LOCALAPPDATA ('Programs\HyperVControl\' + $runtime.version + '\HyperVControl.exe') +} +if (-not (Test-Path -LiteralPath $exe)) { + [Console]::Error.WriteLine('Hyper-V Control EXE is not installed. Run this plugin''s scripts\install.ps1, or extract the prebuilt plugin ZIP. No compiler is required.') + exit 1 +} +$hashStream = [IO.File]::OpenRead($exe) +$hasher = [Security.Cryptography.SHA256]::Create() +try { $actualHash = [BitConverter]::ToString($hasher.ComputeHash($hashStream)).Replace('-', '').ToLowerInvariant() } +finally { $hashStream.Dispose(); $hasher.Dispose() } +if ($actualHash -ne $runtime.sha256) { + [Console]::Error.WriteLine('Hyper-V Control EXE checksum does not match runtime.json. Reinstall the matching release.') + exit 1 +} +# An elevation-capable EXE bridges its own stdio; ShellExecute never receives MCP stdin. +# Native stdin/out pass directly through this invocation. Diagnostics stay on stderr. +& $exe --elevate +exit $LASTEXITCODE diff --git a/plugins/hyper-v-control/skills/hyper-v-control/SKILL.md b/plugins/hyper-v-control/skills/hyper-v-control/SKILL.md new file mode 100644 index 0000000..15c430a --- /dev/null +++ b/plugins/hyper-v-control/skills/hyper-v-control/SKILL.md @@ -0,0 +1,28 @@ +--- +name: hyper-v-control +description: Manage Hyper-V VM lifecycle and checkpoints, operate Basic or Enhanced guest consoles, transfer files, automate Windows guest UI, and debug user processes or guest kernels using the Hyper-V Control MCP. Use for Hyper-V VM operations and Windows VM debugging, not general host desktop automation. +--- + +# Hyper-V Control + +Use this plugin's `hyperv_*` MCP tools. The runtime is a compiled Windows x64 C# EXE speaking stdio. It requires no Python or user-side SDK. If unavailable, read [runtime and permissions](references/runtime.md). + +Start with `hyperv_capabilities` and `hyperv_list` when host or target state is unknown. Resolve the exact VM name/ID and re-read state before dependent operations. An existing authorization to operate a test VM carries through the requested workflow; ask only for missing credentials or materially new/destructive scope. + +Choose the channel that fits the task: + +| Task | Channel | +| --- | --- | +| Inventory, creation, hardware, start/stop, export/import | VM tools; no console needed | +| Reproduce and roll back a test | Checkpoint tools; retain the checkpoint ID and initial power state | +| Boot, firmware, sign-in, locked desktop, visual clicks | Basic console + inline screenshot + input | +| Resizable remote desktop and clipboard | Enhanced console; check the actual mode returned | +| Guest scripts, installers, files, process diagnostics | PowerShell Direct (`guest_run`, `guest_process`, `copy`) | +| Find controls by name, inspect elements, invoke/set value | `guest_setup` then `ui` in an unlocked interactive guest session | +| Debug a process inside the VM | Guest debug worker; guest PIDs belong in `guest_debug_open` | +| Debug the VM's kernel | KDNET or two-phase KDCOM, then host KD session | +| Analyze an existing dump | `debug_open` with `mode=dump`; copy guest dumps to host first | + +For lifecycle/rollback details read [VM operations](references/operations.md). For console, keyboard/mouse, clipboard and winapp read [guest interaction](references/interaction.md). For live debugging read [debugging](references/debugging.md). + +Read the actual MCP schema for parameter names. Tool failures arrive as `isError` with an explanation; do not interpret process startup, BCD configuration or a timeout as successful attachment/execution. After a mutation, verify through the channel that observes its result. Report unsupported states rather than substituting host input or changing guest security settings silently. diff --git a/plugins/hyper-v-control/skills/hyper-v-control/agents/openai.yaml b/plugins/hyper-v-control/skills/hyper-v-control/agents/openai.yaml new file mode 100644 index 0000000..60a0c08 --- /dev/null +++ b/plugins/hyper-v-control/skills/hyper-v-control/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Hyper-V Control" + short_description: "Manage, automate, and debug Hyper-V virtual machines." + default_prompt: "Use $hyper-v-control to manage or debug a Hyper-V VM with the appropriate control channel." diff --git a/plugins/hyper-v-control/skills/hyper-v-control/references/debugging.md b/plugins/hyper-v-control/skills/hyper-v-control/references/debugging.md new file mode 100644 index 0000000..add719e --- /dev/null +++ b/plugins/hyper-v-control/skills/hyper-v-control/references/debugging.md @@ -0,0 +1,25 @@ +# Debugging + +Keep target scope explicit. `debug_open` PIDs/paths belong to the HOST; `guest_debug_open` PIDs/paths belong to the VM. Kernel tools attach to a remote VM only, never the host kernel. + +## User mode inside a VM + +Use `guest_debug_setup` to copy the published EXE and selected current WinDbg engine directory into the guest. This needs a guest administrator credential. Then `guest_debug_open` with mode `launch`, `attach`, or `dump`, and the returned guest debugger directory. It starts a bounded scheduled worker that survives the PowerShell Direct call. Save workerId and taskName. Poll its status before commands. + +Use `guest_debug` action `command` for `bp/bu/bl/bc`, `g/t/p`, `k`, `r`, `db/dq`, `lm`, `.sympath/.reload`, `!analyze` and ordinary CDB commands. A launch under the worker uses the selected account's elevated task token: do not label it an unprivileged reproduction. For a standard-user trigger, use that account's credential profile with `guest_process`; attach to its PID from the debugging account. Record the observed token when privilege level matters. + +## Kernel mode + +Record state/checkpoints first. Prefer KDNET when the guest can reach the host on a vSwitch. Pick a unique UDP port, then `kernel_configure` action `kdnet` with the reachable host IPv4 address. It verifies bcdedit exit codes, creates a full-strength key if omitted, and returns `kernelAttachString`. Preserve this key as sensitive connection material. Host firewall, guest Secure Boot/BitLocker and adapter support are separate prerequisites: inspect and make only changes authorized by the task. BCD success alone is not a debugger connection. + +For KDCOM, use two phases: while Off call `serial_host` with a simple pipeName and COM port; start the VM and wait for PowerShell Direct; call `serial_guest` with the same values. Never attempt PowerShell Direct while Off/Saved. Guest BCD changes require a restart. Use `debug_open` mode `kernel` and the returned attach string, and verify KD's actual connection/initial break output. Enable restart only when within the requested experiment; configuration tools do not reboot by default. + +Kernel break suspends the guest OS; PowerShell Direct and UI will stop responding. While broken, use KD tools, then `g` before trying guest commands again. A pending `g` is normal: use `debug_poll` or `debug_break`, not another queued debugger command. For early boot or repeated reboot debugging, named-pipe reconnect and KDNET remain persistent sessions. + +## Session handling + +Host `debug_command` and guest `guest_debug` return bounded output with pending state and cursor. Poll after the returned cursor to avoid repeated output. Timeout does not kill the debugger. A command still pending blocks another command; break and poll to complete it. Break signals only the session's private hidden Windows console; `-wake` is not a general target interrupt. `.shell` is disabled. Local symbol cache defaults to `%LOCALAPPDATA%\HyperVControl\symbols` with Microsoft's symbol server; `_NT_SYMBOL_PATH` or `debug_open(symbolPath=...)` can override it. First kernel symbol loading can take longer than a tool call: retain the same session and poll. An existing local-only path is useful for checking connectivity without waiting on symbol downloads. + +Close by detaching using `debug_close` or guest `close`. Do not send bare `q` to a user-mode debugger: it may terminate the target. If detach times out, the session remains inspectable; resolve it before rollback. Export needed dumps/logs, close workers/consoles, and restore the intended VM state. Guest workers have a one-hour lifetime and unregister their task on normal exit; after an interrupted run inspect the returned taskName before removing it. + +Microsoft references: [current WinDbg](https://learn.microsoft.com/windows-hardware/drivers/debugger/), [KD command line](https://learn.microsoft.com/windows-hardware/drivers/debugger/kd-command-line-options), [VM KDNET setup](https://learn.microsoft.com/windows-hardware/drivers/debugger/setting-up-network-debugging-of-a-virtual-machine-host), [PowerShell Direct](https://learn.microsoft.com/windows-server/virtualization/hyper-v/powershell-direct). diff --git a/plugins/hyper-v-control/skills/hyper-v-control/references/interaction.md b/plugins/hyper-v-control/skills/hyper-v-control/references/interaction.md new file mode 100644 index 0000000..4bab08d --- /dev/null +++ b/plugins/hyper-v-control/skills/hyper-v-control/references/interaction.md @@ -0,0 +1,13 @@ +# Guest interaction + +Open a running VM using `hyperv_console` with `session=default` (Basic), unless the task asks for Enhanced or needs its clipboard/display features. Inspect returned `sessionMode` and fallback note. Each MCP process owns its consoles; close them when finished. An Enhanced connection may require the guest's RDP sign-in. Opening it does not change a saved VM preference. + +Basic screenshots come from Hyper-V's guest framebuffer and work in the background. Use the returned native pixel coordinates for click/move/scroll. Keyboard goes to the VM's synthetic keyboard; Enhanced uses its RDP input sink. Neither sends global host keystrokes. `type` sends text, `key` sends chords such as `WIN+R`, `CTRL+A`, `CTRL+ALT+DELETE`; click supports left/right/double. For drag: button_down, move, button_up, and release in recovery even if an intermediate operation fails. Scroll deltas use 120 units per notch. + +Enhanced screenshots capture its actual remote desktop window and must fail if that window is minimized, off-screen or obscured. Bring the specific window forward with the user's cooperation if needed; do not report another host window as the VM. The inline PNG is the observation; do not load an unrelated old screenshot. After navigation/input, capture again before choosing new coordinates. + +`login` only enters the stored password into the currently focused guest sign-in password field and presses Enter. Inspect the sign-in screen first and select the intended guest user. Never call it at an unknown focus. This does not configure automatic login. Clipboard read/write affects the host clipboard redirected into Enhanced sessions; it is not isolated per VM. Prefer `type` or file copy when shared clipboard is unnecessary. + +PowerShell Direct works without network/RDP but does not enter an interactive desktop. Use `guest_run` for shell work and `copy` for bidirectional files/folders. Paths are absolute, `..` is rejected, and guest→host copies cannot target Windows or Program Files. Check exitCode/timedOut/truncated; process timeout is not rollback. `guest_process` accepts an argument array without shell concatenation. + +For semantic UI, call `guest_setup` once, then `ui` with `status`, `list-windows`, `inspect` or `search`. Use live element identities and supported `invoke`, `set-value`, `focus` etc. Microsoft's winapp CLI is installed from a release with a verified published digest. The bridge runs it in the signed-in guest session through a task; an unlocked interactive sign-in is required in either console mode. `elevated=true` is for elevated guest apps; ordinary desktop and AppContainer apps normally need the default token. Multiple interactive guest sessions require verifying the window/session selected. Errors such as locked desktop, missing session or stale element need a fresh observation, not repeated clicks. diff --git a/plugins/hyper-v-control/skills/hyper-v-control/references/operations.md b/plugins/hyper-v-control/skills/hyper-v-control/references/operations.md new file mode 100644 index 0000000..31bff3d --- /dev/null +++ b/plugins/hyper-v-control/skills/hyper-v-control/references/operations.md @@ -0,0 +1,9 @@ +# VM operations + +Inventory uses local Hyper-V, not network discovery. Exact names resolve to stable GUIDs; ambiguous names fail. Creation leaves VMs Off. CPU/memory, media, firmware and serial-port changes require Off. Export plus import with a new ID supports cloning; specify separate VM/VHD destination directories. + +Use graceful `shutdown` or `restart` for ordinary work. `restart` waits for Off before Start. `turn_off` and `reset` are hard power operations, used only when the task calls for them. No automatic fallback from graceful to hard power. Pause/resume and save are distinct operations. Reopen a console after a power transition or checkpoint restore if the transport disconnected. + +Before experiments that require rollback, record the current VM state and checkpoint IDs, then create a named task checkpoint. Restore/delete by ID. `current` means the VM's parent checkpoint, not the newest timestamp anywhere in its branching tree. Restoring replaces guest state and discards subsequent changes; save needed output to the host first. Delete only the task's checkpoints unless broader cleanup was requested. At the end, verify the intended power state and checkpoint tree. + +`vm_remove` unregisters an Off VM by GUID; Hyper-V may merge its checkpoints, but standalone disk files are not deleted. Disk detach likewise leaves its file. Switch removal can affect other VMs; inspect connections and keep it within the requested scope. diff --git a/plugins/hyper-v-control/skills/hyper-v-control/references/runtime.md b/plugins/hyper-v-control/skills/hyper-v-control/references/runtime.md new file mode 100644 index 0000000..83428e6 --- /dev/null +++ b/plugins/hyper-v-control/skills/hyper-v-control/references/runtime.md @@ -0,0 +1,11 @@ +# Runtime and permissions + +The prebuilt plugin ZIP contains `bin/HyperVControl.exe`. A source-only marketplace install uses `scripts/install.ps1` to download the version/checksum pinned in `runtime.json`; then reconnect the MCP. The release must exist before this download path works. Development builds use the repository build script, never a Python MCP server. + +The launcher verifies the EXE hash and starts `--elevate`. The parent keeps MCP stdin/stdout; a same-user worker receives them over a private named pipe. Only the current Windows user's SID has access, and both ends verify process IDs. UAC consent can appear once when a split-token administrator starts it. Cancellation is an error with no VM mutation. Do not disable UAC or grant permanent group membership to make a call work. + +An ordinary account may run the EXE directly when granted the required Hyper-V rights. PowerShell Direct and some host operations require administrator rights beyond Hyper-V inventory access. The UAC bridge supports the same account's elevated token. Entering another administrator's credentials does not satisfy that identity boundary: run the MCP client/server under the intended administrator account instead. A locked or noninteractive host cannot display a console/UAC UI. + +Guest credentials are separate from host elevation. `hyperv_credential` verifies them against the running VM, then stores them in Windows Credential Manager. Separate profiles can hold administrator and standard guest accounts. Environment fallback uses `HYPERV_CONTROL_GUEST_USERNAME/PASSWORD`, or `HYPERV_CONTROL__USERNAME/PASSWORD`. Never echo passwords. No credentials are bundled with the plugin. + +Prefer current Microsoft WinDbg. `hyperv_debugger_discover` reports paths and file versions. `HYPERV_CONTROL_DEBUGGER_DIR` explicitly selects a full engine directory containing `kd.exe`, `cdb.exe`, dependent DLLs and extensions; do not copy just KD. Installed Microsoft.WinDbg MSIX is preferred over legacy Windows Kits. Install/update through Microsoft's documented WinDbg installer or `winget install Microsoft.WinDbg` when needed for the authorized task. diff --git a/plugins/rizin-re-toolkit/.codex-plugin/plugin.json b/plugins/rizin-re-toolkit/.codex-plugin/plugin.json index f529a75..ba16a24 100644 --- a/plugins/rizin-re-toolkit/.codex-plugin/plugin.json +++ b/plugins/rizin-re-toolkit/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "rizin-re-toolkit", - "version": "0.4.0", + "version": "0.4.1", "description": "Default native reverse engineering and dump analysis toolkit, routing Ghidra/IDA-style workflows through portable Rizin.", "author": { "name": "JerryLinLinLin", diff --git a/plugins/rizin-re-toolkit/README.md b/plugins/rizin-re-toolkit/README.md index ee1eb7f..4b862ad 100644 --- a/plugins/rizin-re-toolkit/README.md +++ b/plugins/rizin-re-toolkit/README.md @@ -1,6 +1,6 @@ # Rizin RE Toolkit -A portable Windows x64 CLI and Codex skill serving as the default entrypoint for native binary and memory-dump analysis. Plugin/skill version: **0.4.0**. Plugin ID and skill invocation: `rizin-re-toolkit` / `$rizin-re-toolkit`. +A portable Windows x64 CLI and Codex skill serving as the default entrypoint for native binary and memory-dump analysis. Plugin/skill version: **0.4.1**. Plugin ID and skill invocation: `rizin-re-toolkit` / `$rizin-re-toolkit`. Download the runtime ZIP and SHA256SUMS from [release rizin-v0.3.1](https://github.com/JerryLinLinLin/jerry-plugin-marketplace/releases/tag/rizin-v0.3.1), verify the archive hash, and extract the whole rizin folder. Run `rizin\bin\rizin.exe -v` by absolute path, or add its bin directory to the current process PATH. Non-system runtime DLLs are included. Windows 10 (1903+) or Windows 11 x64 is required; administrator installation is unnecessary. @@ -21,7 +21,7 @@ Install **Rizin RE Toolkit** from **Jerry's Plugin Marketplace** in Codex. The [ The skill automatically applies to ordinary RE requests, including tasks phrased with Ghidra, IDA Pro/Hex-Rays, Binary Ninja, Cutter, radare2, or related tool names. Equivalent work uses the Rizin CLI; Ghidra decompilation uses bundled `pdg`, while general/IDA-style PE analysis starts with Rizin triage and `pdz`. The skill reports the actual engine used and checks native-project or unsupported-tool requirements before substituting workflows. -Download the independent [plugin and skill packages](https://github.com/JerryLinLinLin/jerry-plugin-marketplace/releases/tag/rizin-re-toolkit-v0.4.0) if needed. The runtime continues to use the verified `rizin-v0.3.1` release. If the former `rizin-windows-re` plugin is installed, refresh the marketplace, install the new plugin, and remove the old installation to avoid duplicate skills. +Download the independent [plugin and skill packages](https://github.com/JerryLinLinLin/jerry-plugin-marketplace/releases/tag/rizin-re-toolkit-v0.4.1) if needed. The runtime continues to use the verified `rizin-v0.3.1` release. If the former `rizin-windows-re` plugin is installed, refresh the marketplace, install the new plugin, and remove the old installation to avoid duplicate skills. The [PowerShell installer](skills/rizin-re-toolkit/scripts/install.ps1) supports a user-level install, an explicit destination, a fixed release tag, and optional user PATH registration. It verifies SHA-256 before running the extracted CLI. The large runtime is a separate release asset. @@ -46,3 +46,7 @@ Use a real function/address instead of assuming a main symbol exists. For dumps, - VC++ runtime DLLs ship beside the executables. YARA uses OpenSSL 3.5.9; the unchanged upstream RetDec SDK requires legacy OpenSSL 1.1.1w, included separately. The runtime includes licenses/ and THIRD-PARTY.md. The plugin/skill is MIT; redistributed components retain their own licenses. + +## Installer maintenance in 0.4.1 + +Runtime discovery now checks the Rizin tag family and asset name together and paginates past unrelated plugin releases. Drafts and prereleases are skipped. Hyper-V Control uses an exact tagged EXE and a separate installation directory. The Rizin 0.3.1 runtime assets and existing download URLs are unchanged. diff --git a/plugins/rizin-re-toolkit/plugin.json b/plugins/rizin-re-toolkit/plugin.json index 0db95d6..8d944b5 100644 --- a/plugins/rizin-re-toolkit/plugin.json +++ b/plugins/rizin-re-toolkit/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "rizin-re-toolkit", - "version": "0.4.0", + "version": "0.4.1", "description": "Default native reverse engineering and dump analysis toolkit, routing Ghidra/IDA-style workflows through portable Rizin.", "author": { "name": "JerryLinLinLin", diff --git a/plugins/rizin-re-toolkit/skills/rizin-re-toolkit/scripts/install.ps1 b/plugins/rizin-re-toolkit/skills/rizin-re-toolkit/scripts/install.ps1 index 959d7c5..a4a2d5e 100644 --- a/plugins/rizin-re-toolkit/skills/rizin-re-toolkit/scripts/install.ps1 +++ b/plugins/rizin-re-toolkit/skills/rizin-re-toolkit/scripts/install.ps1 @@ -12,18 +12,24 @@ if ($Repository -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') { throw 'Invalid [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $headers = @{ 'User-Agent' = 'rizin-re-toolkit-installer'; 'Accept' = 'application/vnd.github+json' } $api = "https://api.github.com/repos/$Repository/releases" -if ($Tag) { - $releases = Invoke-RestMethod -Uri "$api/tags/$([Uri]::EscapeDataString($Tag))" -Headers $headers -} else { - # Other marketplace plugins may release independently; select by asset. - $releases = Invoke-RestMethod -Uri "${api}?per_page=100" -Headers $headers -} $assetPattern = '^rizin-windows-x64-bundle-v[0-9][A-Za-z0-9.-]*\.zip$' -$release = @($releases) | Where-Object { - -not $_.draft -and -not $_.prerelease -and - @($_.assets | Where-Object { $_.name -match $assetPattern }).Count -eq 1 -and - @($_.assets | Where-Object { $_.name -eq 'SHA256SUMS' }).Count -eq 1 -} | Select-Object -First 1 +$page = 1 +while ($true) { + if ($Tag) { + $releases = Invoke-RestMethod -Uri "$api/tags/$([Uri]::EscapeDataString($Tag))" -Headers $headers + } else { + # Releases are repository-wide. Keep looking when newer plugins fill a page. + $releases = Invoke-RestMethod -Uri "${api}?per_page=100&page=$page" -Headers $headers + } + $release = @($releases) | Where-Object { + -not $_.draft -and -not $_.prerelease -and + $_.tag_name -match '^rizin-(?:runtime-)?v\d+\.\d+\.\d+$' -and + @($_.assets | Where-Object { $_.name -match $assetPattern }).Count -eq 1 -and + @($_.assets | Where-Object { $_.name -eq 'SHA256SUMS' }).Count -eq 1 + } | Select-Object -First 1 + if ($release -or $Tag -or @($releases).Count -lt 100) { break } + $page++ +} if (-not $release) { throw 'No stable Rizin bundle with SHA256SUMS was found. Check the releases page.' } $asset = $release.assets | Where-Object { $_.name -match $assetPattern } $checksums = $release.assets | Where-Object { $_.name -eq 'SHA256SUMS' } diff --git a/scripts/build-hyperv-control.ps1 b/scripts/build-hyperv-control.ps1 new file mode 100644 index 0000000..0684230 --- /dev/null +++ b/scripts/build-hyperv-control.ps1 @@ -0,0 +1,58 @@ +[CmdletBinding()] +param([string]$DotNet = 'dotnet', [switch]$NoRestore) +$ErrorActionPreference = 'Stop' +$repo = Split-Path -Parent $PSScriptRoot +$project = Join-Path $repo 'src\HyperVControl\HyperVControl.csproj' +$plugin = Join-Path $repo 'plugins\hyper-v-control' +$out = Join-Path $repo 'build\hyperv-control\publish' +$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' +$arguments = @('publish', $project, '-c', 'Release', '-r', 'win-x64', '--self-contained', 'true', '-o', $out) +if ($NoRestore) { $arguments += '--no-restore' } +& $DotNet @arguments +if ($LASTEXITCODE -ne 0) { throw 'Hyper-V Control publish failed.' } +$exe = Join-Path $out 'HyperVControl.exe' +$metadata = Get-Content -LiteralPath (Join-Path $plugin 'plugin.json') -Raw | ConvertFrom-Json +$version = $metadata.version +if ($version -notmatch '^\d+\.\d+\.\d+$') { throw 'Invalid plugin version.' } +$tag = "hyper-v-control-v$version" +$exeName = "hyper-v-control-win-x64-v$version.exe" +$zipName = "hyper-v-control-plugin-v$version-win-x64.zip" +$manifestName = "$tag-release.json" +$checksumsName = "$tag-SHA256SUMS.txt" +if ((& $exe --version) -ne "Hyper-V Control $version") { throw 'Published version does not match plugin.' } +$sha = (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash.ToLowerInvariant() +New-Item -ItemType Directory -Force -Path (Join-Path $plugin 'bin') | Out-Null +Copy-Item -LiteralPath $exe -Destination (Join-Path $plugin 'bin\HyperVControl.exe') -Force +$manifest = [ordered]@{ + plugin='hyper-v-control'; version=$version; tag=$tag; asset=$exeName + rid='win-x64'; framework='net10.0-windows'; selfContained=$true + sha256=$sha + url="https://github.com/JerryLinLinLin/jerry-plugin-marketplace/releases/download/$tag/$exeName" +} +$manifestJson = ($manifest | ConvertTo-Json).Replace("`r`n", "`n") + "`n" +[IO.File]::WriteAllText((Join-Path $plugin 'runtime.json'), $manifestJson, [Text.UTF8Encoding]::new($false)) +$release = Join-Path $repo 'build\hyperv-control\release' +New-Item -ItemType Directory -Force -Path $release | Out-Null +# Retain this local convenience path; public assets always use the namespaced name. +Copy-Item -LiteralPath $exe -Destination (Join-Path $release 'HyperVControl.exe') -Force +Copy-Item -LiteralPath $exe -Destination (Join-Path $release $exeName) -Force +Copy-Item -LiteralPath (Join-Path $repo 'LICENSE'),(Join-Path $plugin 'THIRD-PARTY-NOTICES.md') -Destination $release -Force +Copy-Item -LiteralPath (Join-Path $plugin 'licenses') -Destination $release -Recurse -Force +$zip = Join-Path $release $zipName +Add-Type -AssemblyName System.IO.Compression.FileSystem +if(Test-Path -LiteralPath $zip){Remove-Item -LiteralPath $zip -Force} +[IO.Compression.ZipFile]::CreateFromDirectory($plugin, $zip, [IO.Compression.CompressionLevel]::Optimal, $false) +$zipSha=(Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant() +$assets = @( + [ordered]@{name=$exeName;kind='executable';rid='win-x64';sha256=$sha;size=(Get-Item $exe).Length}, + [ordered]@{name=$zipName;kind='plugin';rid='win-x64';sha256=$zipSha;size=(Get-Item $zip).Length} +) +$releaseManifest = [ordered]@{schemaVersion=1;repository='JerryLinLinLin/jerry-plugin-marketplace';plugin='hyper-v-control';version=$version;tag=$tag;assets=$assets} +[IO.File]::WriteAllText((Join-Path $release $manifestName), (($releaseManifest | ConvertTo-Json -Depth 6).Replace("`r`n", "`n") + "`n"), [Text.UTF8Encoding]::new($false)) +$manifestSha=(Get-FileHash -LiteralPath (Join-Path $release $manifestName) -Algorithm SHA256).Hash.ToLowerInvariant() +$sums=@("$sha $exeName", "$zipSha $zipName", "$manifestSha $manifestName") +[IO.File]::WriteAllLines((Join-Path $release $checksumsName),$sums,[Text.Encoding]::ASCII) +# Local compatibility only. Upload the four namespaced files, never this alias. +@("$sha HyperVControl.exe", "$zipSha $zipName") | Set-Content -LiteralPath (Join-Path $release 'SHA256SUMS') -Encoding ASCII +Write-Output "Public assets: $exeName, $zipName, $manifestName, $checksumsName" +Write-Output "Release artifacts: $release" diff --git a/scripts/test-installer.ps1 b/scripts/test-installer.ps1 index 2ad9f6e..4e3aa5b 100644 --- a/scripts/test-installer.ps1 +++ b/scripts/test-installer.ps1 @@ -4,7 +4,7 @@ $ErrorActionPreference = 'Stop' $repoRoot = Split-Path -Parent $PSScriptRoot if (-not $ReleaseDir) { $ReleaseDir = Join-Path $repoRoot 'build\release' } if (-not $ScratchDir) { $ScratchDir = Join-Path $repoRoot ('build\installer-test-' + [Guid]::NewGuid().ToString('N')) } -$rizinFixtureState = [pscustomobject]@{files=(Resolve-Path -LiteralPath $ReleaseDir).Path; corrupt=$false; release=$null; archiveName=''} +$rizinFixtureState = [pscustomobject]@{files=(Resolve-Path -LiteralPath $ReleaseDir).Path; corrupt=$false; release=$null; archiveName=''; pages=@()} $archives = @(Get-ChildItem -LiteralPath $ReleaseDir -Filter 'rizin-windows-x64-bundle-v*.zip') if ($archives.Count -ne 1) { throw 'Expected exactly one runtime archive' } $archive = $archives[0] @@ -21,6 +21,9 @@ $rizinFixtureState.release = [pscustomobject]@{ Set-Item Function:Invoke-RestMethod -Value ({ param($Uri, $Headers) if ($Uri -match '/tags/') { return $rizinFixtureState.release } + $pageMatch = [regex]::Match($Uri, '[?&]page=(\d+)') + $page = if ($pageMatch.Success) { [int]$pageMatch.Groups[1].Value } else { 1 } + $rizinFixtureState.pages += $page # Invoke-RestMethod emits a JSON array as one pipeline object. Include an # newer plugin-only release to exercise independent version selection too. $other = [pscustomobject]@{ @@ -30,7 +33,21 @@ Set-Item Function:Invoke-RestMethod -Value ({ [pscustomobject]@{name='SHA256SUMS'} ) } - Write-Output -NoEnumerate @($other, $rizinFixtureState.release) + if ($page -eq 1) { + # A full page of other plugins must not hide the runtime on page two. + $noise = @(1..100 | ForEach-Object { + [pscustomobject]@{ + tag_name="hyper-v-control-v0.1.$_";draft=$false;prerelease=$false + assets=@([pscustomobject]@{name="hyper-v-control-win-x64-v0.1.$_.exe"},[pscustomobject]@{name='SHA256SUMS'}) + } + }) + Write-Output -NoEnumerate $noise + } elseif ($page -eq 2) { + $impostor = [pscustomobject]@{tag_name='unrelated-v9.9.9';draft=$false;prerelease=$false;assets=$rizinFixtureState.release.assets} + $draft = [pscustomobject]@{tag_name='rizin-v9.9.9';draft=$true;prerelease=$false;assets=$rizinFixtureState.release.assets} + $preview = [pscustomobject]@{tag_name='rizin-v9.9.8';draft=$false;prerelease=$true;assets=$rizinFixtureState.release.assets} + Write-Output -NoEnumerate @($other,$impostor,$draft,$preview,$rizinFixtureState.release) + } else { throw 'Unexpected extra release page.' } }.GetNewClosure()) Set-Item Function:Invoke-WebRequest -Value ({ param($Uri, $Headers, $OutFile, [switch]$UseBasicParsing) @@ -45,6 +62,7 @@ Set-Item Function:Invoke-WebRequest -Value ({ $installer = Join-Path $repoRoot 'plugins\rizin-re-toolkit\skills\rizin-re-toolkit\scripts\install.ps1' $result = & $installer -Destination (Join-Path $ScratchDir 'valid install') if (-not (Test-Path -LiteralPath $result.Executable)) { throw 'Installer did not produce an executable' } +if ($result.Tag -ne 'rizin-v0.3.1' -or $rizinFixtureState.pages -notcontains 2) { throw 'Release namespace or pagination selection failed' } $originalHash = (Get-FileHash -LiteralPath $result.Executable).Hash $refusedExisting = $false try { & $installer -Destination (Join-Path $ScratchDir 'valid install') | Out-Null } @@ -55,4 +73,4 @@ $refusedChecksum = $false try { & $installer -Destination (Join-Path $ScratchDir 'invalid checksum') | Out-Null } catch { if ($_.Exception.Message -eq 'SHA-256 verification failed.') { $refusedChecksum = $true } else { throw } } if (-not $refusedChecksum -or (Test-Path -LiteralPath (Join-Path $ScratchDir 'invalid checksum\rizin-v0.3.1'))) { throw 'Checksum failure did not prevent installation' } -[pscustomobject]@{verifiedInstall=$true; existingInstallPreserved=$true; corruptChecksumRejected=$true; executable=$result.Executable} | ConvertTo-Json +[pscustomobject]@{verifiedInstall=$true; namespaceIsolation=$true; paginationVerified=$true; existingInstallPreserved=$true; corruptChecksumRejected=$true; executable=$result.Executable} | ConvertTo-Json diff --git a/scripts/test-release-isolation.ps1 b/scripts/test-release-isolation.ps1 new file mode 100644 index 0000000..f31e44f --- /dev/null +++ b/scripts/test-release-isolation.ps1 @@ -0,0 +1,70 @@ +[CmdletBinding()] +param([string]$ScratchDir = '') +$ErrorActionPreference = 'Stop' +$repo = Split-Path -Parent $PSScriptRoot +if (-not $ScratchDir) { $ScratchDir = Join-Path $repo ('build\release-isolation-' + [guid]::NewGuid().ToString('N')) } +New-Item -ItemType Directory -Force -Path $ScratchDir | Out-Null +$fixture = Join-Path $ScratchDir 'rizin-fixture' +$fixtureRelease = Join-Path $ScratchDir 'rizin-release' +$bin = Join-Path $fixture 'rizin\bin' +New-Item -ItemType Directory -Force -Path $bin,$fixtureRelease | Out-Null +$source = Join-Path $ScratchDir 'VersionProbe.cs' +@' +using System; +class VersionProbe { + static int Main(string[] args) { + if (args.Length == 1 && args[0] == "-v") { Console.WriteLine("rizin 0.9.1"); return 0; } + return 2; + } +} +'@ | Set-Content -LiteralPath $source -Encoding UTF8 +# Synthetic version probe only: tests release selection, hashing and installation, +# not Rizin's analysis engine. The published runtime is checked separately. +$compiler = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe' +& $compiler /nologo /target:exe ("/out:" + (Join-Path $bin 'rizin.exe')) $source +if ($LASTEXITCODE -ne 0) { throw 'Version probe compilation failed.' } +'{"bundleVersion":"0.3.1","rizinVersion":"0.9.1"}' | Set-Content -LiteralPath (Join-Path $fixture 'rizin\bundle-manifest.json') -Encoding UTF8 +$archiveName = 'rizin-windows-x64-bundle-v0.3.1.zip' +$archive = Join-Path $fixtureRelease $archiveName +Add-Type -AssemblyName System.IO.Compression.FileSystem +[IO.Compression.ZipFile]::CreateFromDirectory($fixture,$archive) +$archiveHash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() +"$archiveHash $archiveName" | Set-Content -LiteralPath (Join-Path $fixtureRelease 'SHA256SUMS') -Encoding ASCII +$rizinResult = & (Join-Path $PSScriptRoot 'test-installer.ps1') -ReleaseDir $fixtureRelease -ScratchDir (Join-Path $ScratchDir 'Programs\Rizin') | ConvertFrom-Json +$rizinHash = (Get-FileHash -LiteralPath $rizinResult.executable -Algorithm SHA256).Hash + +$plugin = Join-Path $repo 'plugins\hyper-v-control' +$runtime = Get-Content -LiteralPath (Join-Path $plugin 'runtime.json') -Raw | ConvertFrom-Json +$hvcFixture = [pscustomobject]@{url=$runtime.url;exe=(Join-Path $plugin 'bin\HyperVControl.exe');calls=0;corrupt=$false} +if (-not (Test-Path -LiteralPath $hvcFixture.exe)) { throw 'Build Hyper-V Control before running installer tests.' } +Set-Item Function:Invoke-WebRequest -Value ({ + param($Uri,$OutFile,[switch]$UseBasicParsing) + if ($Uri -ne $hvcFixture.url) { throw 'Installer requested an unpinned or cross-plugin URL.' } + $hvcFixture.calls++ + if ($hvcFixture.corrupt) { [IO.File]::WriteAllText($OutFile,'corrupt runtime fixture') } + else { Copy-Item -LiteralPath $hvcFixture.exe -Destination $OutFile } +}.GetNewClosure()) +$installer = Join-Path $plugin 'scripts\install.ps1' +$destination = Join-Path $ScratchDir 'Programs\HyperVControl' +$pathBefore = $env:PATH +$installed = & $installer -Destination $destination +if (-not (Test-Path -LiteralPath $installed) -or $hvcFixture.calls -ne 1) { throw 'Pinned Hyper-V installation failed.' } +if ((& $installed --version) -ne "Hyper-V Control $($runtime.version)") { throw 'Installed runtime version mismatch.' } +$second = & $installer -Destination $destination +if ($second -ne $installed -or $hvcFixture.calls -ne 1) { throw 'Matching installation should be reused without downloading.' } +if ($env:PATH -ne $pathBefore -or (Get-FileHash -LiteralPath $rizinResult.executable -Algorithm SHA256).Hash -ne $rizinHash) { throw 'Installing Hyper-V modified PATH or the Rizin installation.' } +$hvcFixture.corrupt = $true +$badDestination = Join-Path $ScratchDir 'bad-hyperv-hash' +$rejected = $false +try { & $installer -Destination $badDestination | Out-Null } +catch { if ($_.Exception.Message -like '*SHA-256 verification failed*') { $rejected=$true } else { throw } } +if (-not $rejected -or (Test-Path -LiteralPath (Join-Path $badDestination "$($runtime.version)\HyperVControl.exe"))) { throw 'Corrupt runtime was not rejected.' } +[pscustomobject]@{ + rizinNamespaceAndPagination=$rizinResult.namespaceIsolation -and $rizinResult.paginationVerified + pinnedHyperVDownload=$true + existingHyperVReused=$true + separateInstallDirectories=$true + rizinPreserved=$true + pathPreserved=$true + corruptHyperVRejected=$true +} | ConvertTo-Json diff --git a/src/HyperVControl/Extracted/HyperVConsoleManager.cs b/src/HyperVControl/Extracted/HyperVConsoleManager.cs new file mode 100644 index 0000000..8ce1452 --- /dev/null +++ b/src/HyperVControl/Extracted/HyperVConsoleManager.cs @@ -0,0 +1,2064 @@ +// Extracted and adapted from RaivenX b5971dda1fa2050dde36b8b22fda9726df92cb48. See THIRD-PARTY-NOTICES.md. +using System.ComponentModel; +using System.Collections.Concurrent; +using System.Drawing.Imaging; +using System.Management; +using System.Runtime.InteropServices; +using System.Windows.Forms; +using HyperVControl.Extracted; + +namespace HyperVControl.Extracted; + +/// +/// Owns the elevated RDP ActiveX window used by Hyper-V Basic and Enhanced Session +/// consoles. The window is parented into a native placeholder created by the app. +/// +internal sealed class HyperVConsoleManager : IDisposable +{ + /// + /// Ceiling on simultaneously open consoles for one chat. Each console is a real RDP session + /// into a VM plus an ActiveX instance on the single console thread, so "as many as you like" + /// is an invitation to open every VM on the host. Four covers the real multi-machine + /// workflows (attacker, victim, DC, jump box) with room to spare. + /// + internal const int MaximumConsolesPerOwner = 4; + + private readonly TaskCompletionSource _ready = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Thread _thread; + + /// + /// Keyed by owner AND machine, not owner alone: one chat may keep several machines + /// connected at once, each in its own tab. Under the old owner-only key, opening a second + /// machine silently disposed the first tab's console. + /// + private readonly ConcurrentDictionary _windows = new(); + private bool _disposed; + + private readonly record struct ConsoleKey(string OwnerId, Guid MachineId); + + public HyperVConsoleManager() + { + _thread = new Thread(RunMessageLoop) + { + IsBackground = true, + Name = "Hyper-V console", + }; + _thread.SetApartmentState(ApartmentState.STA); + _thread.Start(); + } + + public bool HasActiveConsole => + _windows.Values.Any(static window => !window.IsDisposed); + + public bool TryGetParentWindow( + string consoleOwnerId, + Guid machineId, + out nint parentWindow) + { + if (_windows.TryGetValue(new ConsoleKey(consoleOwnerId, machineId), out var window) + && !window.IsDisposed) + { + parentWindow = window.ParentWindow; + return true; + } + + parentWindow = nint.Zero; + return false; + } + + public async Task OpenAsync( + string consoleOwnerId, + Guid machineId, + nint parentWindow, + string hostName, + bool useEnhancedSession, + int displayScalePercent, + CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var context = await _ready.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + var completion = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + context.Post( + async state => + { + var key = new ConsoleKey(consoleOwnerId, machineId); + try + { + // Replacing this machine's own console is a reconnect, never counted + // against the cap. Other machines' consoles are left strictly alone. + if (_windows.TryRemove(key, out var previous)) + { + previous.Dispose(); + } + else if (CountLiveConsoles(consoleOwnerId) >= MaximumConsolesPerOwner) + { + completion.TrySetResult(new( + false, + $"This MCP session already has {MaximumConsolesPerOwner} machine consoles " + + "open. Close one before connecting another.")); + return; + } + + var window = new ConsoleWindow( + parentWindow, + machineId, + hostName, + useEnhancedSession, + displayScalePercent); + _windows[key] = window; + var result = await window.ConnectAsync(); + if (!result.Success + && _windows.TryGetValue(key, out var current) + && ReferenceEquals(current, window)) + { + _windows.TryRemove(key, out _); + } + + completion.TrySetResult(result); + } + catch (Exception ex) + { + if (_windows.TryRemove(key, out var failed)) + { + failed.Dispose(); + } + + completion.TrySetResult(new(false, FirstLine(ex.Message))); + } + }, + null); + + return await completion.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task CloseAsync( + string consoleOwnerId, + Guid machineId, + CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var context = await _ready.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + var completion = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + context.Post( + _ => + { + try + { + if (_windows.TryRemove( + new ConsoleKey(consoleOwnerId, machineId), + out var removed)) + { + removed.Dispose(); + } + + completion.TrySetResult(new(true, null)); + } + catch (Exception ex) + { + completion.TrySetResult(new(false, FirstLine(ex.Message))); + } + }, + null); + + return await completion.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task ExecuteAsync( + string consoleOwnerId, + Guid machineId, + HyperVConsoleAction action, + int? x, + int? y, + string? input, + int? delta, + string? userName, + string? password, + CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var context = await _ready.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + var completion = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + context.Post( + async _ => + { + try + { + if (!_windows.TryGetValue( + new ConsoleKey(consoleOwnerId, machineId), + out var window) + || window.IsDisposed) + { + completion.TrySetResult(new( + false, + "The requested virtual machine has no console open in this MCP session. " + + "Run the hyperv_console command with action=open and the machine name first.")); + return; + } + + if (!window.IsConnected) + { + completion.TrySetResult(new( + false, + "The Hyper-V console is not connected yet. Reopen it with the hyperv " + + "console command (action=open, machine=), and check the machine " + + "is running with the hyperv_list command.")); + return; + } + + // An Enhanced Session's input and screen copy belong to the Remote Desktop + // control, so they run here, on its thread. A Basic Session's actions are + // WMI calls into Hyper-V (synthetic keyboard and mouse, the framebuffer) + // that need no window and can take seconds for a long text; they run on + // the pool so this thread keeps pumping. Its window sits inside the app's + // window, which ties the two input queues together: a stall here is a + // stall of the app's own mouse and keyboard. + var result = window.UsesEnhancedSession || action is HyperVConsoleAction.Show or HyperVConsoleAction.Resize + ? window.Execute(action, x, y, input, delta, userName, password) + : await Task.Run(() => window.Execute(action, x, y, input, delta, userName, password)); + completion.TrySetResult(result); + } + catch (Exception ex) + { + completion.TrySetResult(new(false, FirstLine(ex.Message))); + } + }, + null); + + return await completion.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + private int CountLiveConsoles(string consoleOwnerId) => + _windows.Count(pair => + pair.Key.OwnerId == consoleOwnerId && !pair.Value.IsDisposed); + + private void RunMessageLoop() + { + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); + var context = new WindowsFormsSynchronizationContext(); + SynchronizationContext.SetSynchronizationContext(context); + _ready.TrySetResult(context); + Application.Run(); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + if (_ready.Task.IsCompletedSuccessfully) + { + _ready.Task.Result.Post( + _ => + { + foreach (var window in _windows.Values) + { + window.Dispose(); + } + + _windows.Clear(); + Application.ExitThread(); + }, + null); + } + + if (_thread.IsAlive && Thread.CurrentThread != _thread) + { + _thread.Join(TimeSpan.FromSeconds(2)); + } + } + + private static string FirstLine(string? value) => + string.IsNullOrWhiteSpace(value) + ? "The Hyper-V console could not be opened." + : value.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)[0].Trim(); + + private sealed class ConsoleWindow : Form + { + private static readonly TimeSpan ConnectionTimeout = TimeSpan.FromSeconds(30); + private const int GwlStyle = -16; + private const long WsChild = 0x40000000L; + private const long WsVisible = 0x10000000L; + private const long WsCaption = 0x00C00000L; + private const long WsThickFrame = 0x00040000L; + private const long WsPopup = unchecked((long)0x80000000); + private const uint SwpNoActivate = 0x0010; + private const uint SwpShowWindow = 0x0040; + private const int MaximumTextChunkLength = 256; + private const int HyperVConsolePort = 2179; + private const int MinimumInputSendIntervalMilliseconds = 20; + private const int CredentialFailureDisconnectReason = 3848; + private const string AuthenticationServiceClass = "Microsoft Virtual Console Service"; + private const ushort VkControl = 0x11; + private const ushort VkMenu = 0x12; + private const ushort VkShift = 0x10; + private const ushort VkLwin = 0x5B; + private const uint MapVkVkToVscEx = 4; + private static readonly uint[] SupportedDesktopScaleFactors = + [100u, 125u, 150u, 175u, 200u, 250u, 300u, 400u, 500u]; + + private readonly nint _parentWindow; + private readonly Guid _machineId; + private readonly string _hostName; + /// + /// Whether this console is running as an Enhanced Session. Requested by the host, but + /// cleared here when Hyper-V reports the guest cannot support one — see + /// . Every downstream branch (the + /// preconnection blob, clipboard redirection, and the whole input path) reads this + /// field, so clearing it before ConfigureAndConnect reconfigures all of them. + /// + private bool _useEnhancedSession; + /// Why Enhanced Session was declined, when the console fell back to Basic. + private string _enhancedFallbackReason = string.Empty; + + private readonly int _requestedDisplayScalePercent; + private readonly RemoteDesktopAxHost _activeX; + private readonly System.Windows.Forms.Timer _timer; + private ManagementObject? _virtualMachine; + private ManagementObject? _keyboard; + private ManagementObject? _mouse; + private ManagementObject? _videoHead; + private ManagementObject? _managementService; + private ManagementObject? _activeSettingData; + private IMsRdpInputSink? _rdpInputSink; + private TaskCompletionSource? _connection; + private DateTimeOffset _connectionDeadline; + private Size _sessionDisplaySize; + private uint _sessionDisplayScaleFactor; + private bool _dynamicResolutionUnavailable; + + public Guid MachineId => _machineId; + + public nint ParentWindow => _parentWindow; + + public ConsoleWindow( + nint parentWindow, + Guid machineId, + string hostName, + bool useEnhancedSession, + int displayScalePercent) + { + _parentWindow = parentWindow; + _machineId = machineId; + _hostName = hostName; + _useEnhancedSession = useEnhancedSession; + _requestedDisplayScalePercent = displayScalePercent; + FormBorderStyle = parentWindow == 0 ? FormBorderStyle.Sizable : FormBorderStyle.None; + ShowInTaskbar = parentWindow == 0; + StartPosition = parentWindow == 0 ? FormStartPosition.CenterScreen : FormStartPosition.Manual; + ClientSize = new Size(1280, 720); + Text = $"Hyper-V Control — {machineId:D}"; + + _activeX = new RemoteDesktopAxHost + { + Dock = DockStyle.Fill, + BackColor = System.Drawing.Color.Black, + }; + ((ISupportInitialize)_activeX).BeginInit(); + Controls.Add(_activeX); + ((ISupportInitialize)_activeX).EndInit(); + _activeX.Connected += OnRdpConnected; + _activeX.Disconnected += OnRdpDisconnected; + _activeX.FatalError += OnRdpFatalError; + _activeX.LogonError += OnRdpLogonError; + + _timer = new System.Windows.Forms.Timer { Interval = 100 }; + _timer.Tick += OnTimerTick; + } + + public Task ConnectAsync() + { + _connection = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + try + { + CreateControl(); + _activeX.CreateControl(); + EmbedInParent(); + // A guest that is still booting reports "not ready", and Enhanced Session cannot + // show a boot or sign-in screen anyway — that is the Basic console's job. Failing + // the open here would take away the one view that works at exactly the moment the + // user (or the agent's start-then-attach) needs it, so fall back and connect. + if (_useEnhancedSession && !TryEnsureEnhancedSessionAvailable(out var enhancedGap)) + { + _useEnhancedSession = false; + _enhancedFallbackReason = enhancedGap; + } + + ConfigureAndConnect(); + _connectionDeadline = DateTimeOffset.UtcNow + ConnectionTimeout; + _timer.Start(); + } + catch (Exception ex) + { + Complete(false, FirstLine(ex.Message)); + Dispose(); + } + + return _connection.Task; + } + + /// Whether the Remote Desktop control reports a live session. Read on the console thread. + public bool IsConnected => ReadConnectionState() == 1; + + /// + /// The mode this console actually connected with (the requested Enhanced Session may + /// have fallen back to Basic). Fixed once has run. + /// + public bool UsesEnhancedSession => _useEnhancedSession; + + protected override bool ShowWithoutActivation => true; + + /// + /// Runs one console action. An Enhanced Session's actions drive the Remote Desktop + /// control and must run on the console thread; a Basic Session's are WMI calls into + /// Hyper-V that touch no window and may run on any thread — the manager decides. + /// + public HyperVConsoleCommandResult Execute( + HyperVConsoleAction action, + int? x, + int? y, + string? input, + int? delta, + string? userName, + string? password) + { + return action switch + { + HyperVConsoleAction.Capture => CaptureConsole(), + HyperVConsoleAction.Click => + ClickConsole(x!.Value, y!.Value, input!), + HyperVConsoleAction.Type => TypeText(input!), + HyperVConsoleAction.Key => PressKey(input!), + HyperVConsoleAction.Scroll => + ScrollConsole(x!.Value, y!.Value, delta!.Value), + HyperVConsoleAction.SignIn => SignIn(userName!, password!), + HyperVConsoleAction.Move => MoveOrButton(x!.Value, y!.Value, input ?? "left", null), + HyperVConsoleAction.ButtonDown => MoveOrButton(x!.Value, y!.Value, input ?? "left", true), + HyperVConsoleAction.ButtonUp => MoveOrButton(x!.Value, y!.Value, input ?? "left", false), + HyperVConsoleAction.ClipboardRead => ClipboardAction(null), + HyperVConsoleAction.ClipboardWrite => ClipboardAction(input ?? ""), + HyperVConsoleAction.Show => ShowConsole(), + HyperVConsoleAction.Resize => ResizeConsole(x!.Value, y!.Value), + _ => new(false, "The Hyper-V console action is not supported."), + }; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _timer.Stop(); + _timer.Tick -= OnTimerTick; + _activeX.Connected -= OnRdpConnected; + _activeX.Disconnected -= OnRdpDisconnected; + _activeX.FatalError -= OnRdpFatalError; + _activeX.LogonError -= OnRdpLogonError; + Complete(false, "The Hyper-V console was closed."); + + _videoHead?.Dispose(); + _mouse?.Dispose(); + _keyboard?.Dispose(); + _managementService?.Dispose(); + _activeSettingData?.Dispose(); + _virtualMachine?.Dispose(); + _rdpInputSink = null; + _activeX.Dispose(); + _timer.Dispose(); + } + + base.Dispose(disposing); + } + + private void EmbedInParent() + { + if (_parentWindow == 0) { Show(); return; } + if (!IsWindow(_parentWindow)) + { + throw new InvalidOperationException("The Machine panel is no longer available."); + } + + var style = GetWindowLongPtr(Handle, GwlStyle).ToInt64(); + style &= ~(WsCaption | WsThickFrame | WsPopup); + style |= WsChild | WsVisible; + SetLastError(0); + var previous = SetWindowLongPtr(Handle, GwlStyle, new nint(style)); + if (previous == nint.Zero && Marshal.GetLastWin32Error() != 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + SetLastError(0); + var oldParent = SetParent(Handle, _parentWindow); + if (oldParent == nint.Zero && Marshal.GetLastWin32Error() != 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + ResizeToParent(); + } + + private void ConfigureAndConnect() + { + dynamic client = _activeX.ActiveXInstance; + client.Server = string.Equals( + _hostName, + Environment.MachineName, + StringComparison.OrdinalIgnoreCase) + ? "localhost" + : _hostName; + _sessionDisplaySize = NormalizeSessionDisplaySize(ReadParentClientSize()); + _sessionDisplayScaleFactor = ResolveDesktopScaleFactor(); + client.DesktopWidth = _sessionDisplaySize.Width; + client.DesktopHeight = _sessionDisplaySize.Height; + client.ColorDepth = 32; + + dynamic advanced6 = client.AdvancedSettings6; + advanced6.AuthenticationLevel = 0; + + dynamic advanced2 = client.AdvancedSettings2; + advanced2.RDPPort = HyperVConsolePort; + advanced2.minInputSendInterval = MinimumInputSendIntervalMilliseconds; + + dynamic advanced7 = client.AdvancedSettings7; + advanced7.AuthenticationServiceClass = AuthenticationServiceClass; + advanced7.PCB = _useEnhancedSession + ? $"{_machineId:D};EnhancedMode=1" + : _machineId.ToString("D"); + advanced7.RelativeMouseMode = !_useEnhancedSession; + advanced7.SmartSizing = !_useEnhancedSession; + advanced7.GrabFocusOnConnect = false; + if (_useEnhancedSession) + { + advanced7.allowBackgroundInput = 1; + } + + dynamic advanced8 = client.AdvancedSettings8; + advanced8.EnableCredSspSupport = true; + advanced8.NegotiateSecurityLayer = false; + + var extended = (IMsRdpExtendedSettings)_activeX.ActiveXInstance; + object disableCredentialsDelegation = true; + extended.set_Property( + "DisableCredentialsDelegation", + ref disableCredentialsDelegation); + + if (_useEnhancedSession) + { + if (_requestedDisplayScalePercent != 0) + { + object desktopScaleFactor = _sessionDisplayScaleFactor; + extended.set_Property("DesktopScaleFactor", ref desktopScaleFactor); + object deviceScaleFactor = + ResolveDeviceScaleFactor(_sessionDisplayScaleFactor); + extended.set_Property("DeviceScaleFactor", ref deviceScaleFactor); + } + + advanced2.RedirectClipboard = true; + } + + client.SecuredSettings3.KeyboardHookMode = 1; + client.Connect(); + } + + /// + /// Reports whether Hyper-V can give this guest an Enhanced Session right now. + /// + /// Why not, when the answer is no. Empty when it is yes. + /// + /// Every "no" here is either transient (a guest that has not finished booting) or a host + /// configuration the console cannot change, and in both cases a Basic console still shows + /// the guest. So this answers rather than throws: the caller degrades instead of leaving + /// the user with no picture at all. + /// + private bool TryEnsureEnhancedSessionAvailable(out string reason) + { + object? rawState; + try + { + rawState = GetVirtualMachine()["EnhancedSessionModeState"]; + } + catch (Exception ex) + { + reason = FirstLine(ex.Message); + return false; + } + + if (rawState is null) + { + reason = + "Hyper-V did not report Enhanced Session availability for this virtual machine."; + return false; + } + + var state = Convert.ToUInt16(rawState); + if (state == 2) + { + reason = string.Empty; + return true; + } + + reason = state switch + { + 3 => "Enhanced Session is disabled by the Hyper-V host.", + 6 => "Enhanced Session is enabled, but the guest is not ready for it.", + _ => $"Enhanced Session is unavailable for this virtual machine (state {state}).", + }; + return false; + } + + private HyperVConsoleCommandResult CaptureConsole() => + _useEnhancedSession ? CaptureEnhancedScreen() : CaptureBasicFramebuffer(); + + /// + /// Enhanced Session capture: a copy of REAL screen pixels at the RDP control's + /// rectangle. The framebuffer API below does not see an Enhanced session's desktop + /// (verified empirically — the video head shows the console, not the RDP session), + /// so this mode fundamentally requires the console to be visible and unoccluded; + /// the app checks that and tells the agent to ask the user when it is not. + /// + private HyperVConsoleCommandResult CaptureEnhancedScreen() + { + if (!Visible || WindowState == FormWindowState.Minimized || GetForegroundWindow() != Handle) + return new(false, "Enhanced capture requires its Hyper-V Control window in the foreground and unobscured. Bring it forward or use a Basic session for headless capture."); + var size = _activeX.ClientSize; + if (size.Width <= 0 || size.Height <= 0) + { + return new(false, "The Hyper-V console has no visible area."); + } + + var origin = _activeX.PointToScreen(Point.Empty); + var rectangle = new Rectangle(origin, size); + if (!SystemInformation.VirtualScreen.Contains(rectangle)) + return new(false, "The Enhanced console must be entirely on screen."); + var obscured = false; + EnumWindows((window, _) => + { + if (window == Handle) return false; + if (IsWindowVisible(window) && GetWindowRect(window, out var bounds) + && rectangle.IntersectsWith(Rectangle.FromLTRB(bounds.Left, bounds.Top, bounds.Right, bounds.Bottom))) + obscured = true; + return true; + }, 0); + if (obscured) return new(false, "Another host window overlaps the Enhanced console; capture would include host pixels."); + using var bitmap = new Bitmap( + size.Width, + size.Height, + PixelFormat.Format32bppArgb); + using (var graphics = Graphics.FromImage(bitmap)) + { + graphics.CopyFromScreen( + origin, + Point.Empty, + size, + CopyPixelOperation.SourceCopy); + } + + using var stream = new MemoryStream(); + bitmap.Save(stream, ImageFormat.Png); + return new(true, null, stream.ToArray(), size.Width, size.Height, IsEnhanced: true); + } + + /// + /// Basic-session capture: the guest's video framebuffer read straight from Hyper-V + /// (the API Hyper-V Manager's own thumbnails use), at the video head's NATIVE + /// resolution. No window, no host screen, no focus — a backgrounded chat or an + /// overlapping window cannot corrupt it — and the returned image's pixels ARE guest + /// coordinates, which is what fixed hyperv_input clicks: the old screen copy + /// captured the control's padding around a centered guest desktop while the click + /// math assumed a full-control stretch, so clicks landed on the wallpaper. + /// + private HyperVConsoleCommandResult CaptureBasicFramebuffer() + { + var videoHead = GetVideoHead(); + var width = Convert.ToInt32(videoHead["CurrentHorizontalResolution"]); + var height = Convert.ToInt32(videoHead["CurrentVerticalResolution"]); + if (width <= 0 || height <= 0) + { + return new( + false, + "The guest has not initialized its display yet. Wait for the machine " + + "to finish booting, then capture again."); + } + + // One resolved service for both calls: WMI rejects parameters built from a + // different instance than the one the method is invoked on. + var service = GetManagementService(); + using var parameters = + service.GetMethodParameters("GetVirtualSystemThumbnailImage"); + parameters["TargetSystem"] = GetActiveSettingData().Path.Path; + parameters["WidthPixels"] = (ushort)width; + parameters["HeightPixels"] = (ushort)height; + using var result = service.InvokeMethod( + "GetVirtualSystemThumbnailImage", + parameters, + null); + + var returnValue = Convert.ToUInt32(result["ReturnValue"]); + if (returnValue != 0) + { + return new( + false, + $"Hyper-V could not read the virtual machine's framebuffer (code {returnValue}). " + + "Check the machine is running, then capture again."); + } + + if (result["ImageData"] is not byte[] imageData) + { + return new(false, "Hyper-V returned no framebuffer data for this virtual machine."); + } + + byte[] bgra; + try + { + bgra = Rgb565Pixels.ToBgra32(imageData, width, height); + } + catch (ArgumentException ex) + { + return new(false, $"Hyper-V returned a malformed framebuffer: {ex.Message}"); + } + + using var bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb); + var bounds = new Rectangle(0, 0, width, height); + var bits = bitmap.LockBits(bounds, ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb); + try + { + // Stride can exceed width*4; copy row by row so padding never corrupts rows. + for (var row = 0; row < height; row++) + { + System.Runtime.InteropServices.Marshal.Copy( + bgra, + row * width * 4, + bits.Scan0 + (row * bits.Stride), + width * 4); + } + } + finally + { + bitmap.UnlockBits(bits); + } + + using var stream = new MemoryStream(); + bitmap.Save(stream, ImageFormat.Png); + return new(true, null, stream.ToArray(), width, height, IsEnhanced: false); + } + + private HyperVConsoleCommandResult ClickConsole(int x, int y, string button) + { + return _useEnhancedSession + ? ClickEnhancedConsole(x, y, button) + : ClickBasicConsole(x, y, button); + } + + private HyperVConsoleCommandResult ClipboardAction(string? text) + { + if (!_useEnhancedSession) return new(false, "Clipboard redirection requires Enhanced Session. Use type or file copy with Basic Session."); + if (text is not null) { if (text.Length == 0) Clipboard.Clear(); else Clipboard.SetText(text); return new(true, null); } + return new(true, null, Text: Clipboard.ContainsText() ? Clipboard.GetText() : ""); + } + + private HyperVConsoleCommandResult ShowConsole() + { + if (WindowState == FormWindowState.Minimized) WindowState = FormWindowState.Normal; + Show(); Activate(); BringToFront(); + return new(true, null); + } + + private HyperVConsoleCommandResult ResizeConsole(int width, int height) + { + if (width is < 200 or > 8192 || height is < 200 or > 8192) return new(false, "Console dimensions must be 200..8192 pixels."); + ClientSize = new Size(width, height); + ResizeToParent(); + return new(true, null); + } + + private HyperVConsoleCommandResult MoveOrButton(int x, int y, string button, bool? down) + { + if (button is not ("left" or "right" or "middle")) return new(false, "Use left, right, or middle."); + int gx, gy; string? error; + if (_useEnhancedSession) + { + if (!TryResolveRdpPoint(x, y, out gx, out gy, out error)) return new(false, error); + var sink = GetRdpInputSink(); + var move = ToRdpInputResult(sink.SendMouseMoveEvent((uint)gx, (uint)gy), "move pointer"); + if (!move.Success || down is null) return move; + var index = button == "left" ? 0 : button == "right" ? 1 : 2; + return ToRdpInputResult(sink.SendMouseButtonEvent((MouseButtonType)index, down.Value, (uint)gx, (uint)gy), "change mouse button"); + } + if (!TryResolveGuestPoint(x, y, out gx, out gy, out error)) return new(false, error); + var mouse = GetMouse(); + var moved = ToCommandResult(InvokeDeviceMethod(mouse, "SetAbsolutePosition", ("horizontalPosition", gx), ("verticalPosition", gy)), "move pointer"); + if (!moved.Success || down is null) return moved; + return ToCommandResult(InvokeDeviceMethod(mouse, down.Value ? "PressButton" : "ReleaseButton", + ("buttonIndex", (uint)(button == "left" ? 1 : button == "right" ? 2 : 3))), "change mouse button"); + } + + private HyperVConsoleCommandResult ClickBasicConsole(int x, int y, string button) + { + if (!TryResolveGuestPoint(x, y, out var guestX, out var guestY, out var error)) + { + return new(false, error); + } + + var mouse = GetMouse(); + var moveResult = ToCommandResult( + InvokeDeviceMethod( + mouse, + "SetAbsolutePosition", + ("horizontalPosition", guestX), + ("verticalPosition", guestY)), + "move the VM pointer"); + if (!moveResult.Success) + { + return moveResult; + } + + var click = button switch + { + "left" => (Button: 1, Count: 1), + "right" => (Button: 2, Count: 1), + "double" => (Button: 1, Count: 2), + _ => (Button: 0, Count: 0), + }; + if (click.Count == 0) + { + return new(false, "Use left, right, or double for the mouse button."); + } + + for (var count = 0; count < click.Count; count++) + { + var result = ToCommandResult( + InvokeDeviceMethod( + mouse, + "ClickButton", + ("buttonIndex", (uint)click.Button)), + "click in the VM"); + if (!result.Success) + { + return result; + } + } + + return new(true, null); + } + + private HyperVConsoleCommandResult ClickEnhancedConsole(int x, int y, string button) + { + if (!TryResolveRdpPoint(x, y, out var guestX, out var guestY, out var error)) + { + return new(false, error); + } + + var click = button switch + { + "left" => (Button: MouseButtonType.Button1, Count: 1), + "right" => (Button: MouseButtonType.Button2, Count: 1), + "double" => (Button: MouseButtonType.Button1, Count: 2), + _ => (Button: MouseButtonType.Button1, Count: 0), + }; + if (click.Count == 0) + { + return new(false, "Use left, right, or double for the mouse button."); + } + + var sink = GetRdpInputSink(); + var moveResult = ToRdpInputResult( + sink.SendMouseMoveEvent((uint)guestX, (uint)guestY), + "move the pointer in the Enhanced Session"); + if (!moveResult.Success) + { + return moveResult; + } + + for (var count = 0; count < click.Count; count++) + { + var downResult = ToRdpInputResult( + sink.SendMouseButtonEvent( + click.Button, + true, + (uint)guestX, + (uint)guestY), + "press the mouse button in the Enhanced Session"); + if (!downResult.Success) + { + return downResult; + } + + var upResult = ToRdpInputResult( + sink.SendMouseButtonEvent( + click.Button, + false, + (uint)guestX, + (uint)guestY), + "release the mouse button in the Enhanced Session"); + if (!upResult.Success) + { + return upResult; + } + } + + return new(true, null); + } + + private HyperVConsoleCommandResult TypeText(string text) + { + return _useEnhancedSession + ? TypeEnhancedText(text) + : TypeBasicText(text); + } + + private HyperVConsoleCommandResult TypeBasicText(string text) + { + if (text.Length == 0) + { + return new(true, null); + } + + var keyboard = GetKeyboard(); + for (var offset = 0; offset < text.Length;) + { + var length = Math.Min(MaximumTextChunkLength, text.Length - offset); + if (offset + length < text.Length + && char.IsHighSurrogate(text[offset + length - 1])) + { + length--; + } + + if (length == 0) + { + return new(false, "The text contains an incomplete Unicode character."); + } + + var result = ToCommandResult( + InvokeDeviceMethod( + keyboard, + "TypeText", + ("asciiText", text.Substring(offset, length))), + "type text in the VM"); + if (!result.Success) + { + return result; + } + + offset += length; + } + + return new(true, null); + } + + private HyperVConsoleCommandResult TypeEnhancedText(string text) + { + var sink = GetRdpInputSink(); + foreach (var codeUnit in text) + { + var downResult = ToRdpInputResult( + sink.SendKeyboardEvent( + KbdCodeType.Unicode, + codeUnit, + false, + false, + false), + "type text in the Enhanced Session"); + if (!downResult.Success) + { + return downResult; + } + + var upResult = ToRdpInputResult( + sink.SendKeyboardEvent( + KbdCodeType.Unicode, + codeUnit, + true, + false, + false), + "type text in the Enhanced Session"); + if (!upResult.Success) + { + return upResult; + } + } + + return new(true, null); + } + + private HyperVConsoleCommandResult SignIn(string userName, string password) + { + if (string.IsNullOrWhiteSpace(userName) || password.Length == 0) + { + return new(false, "No saved guest credentials are available."); + } + + var typeResult = TypeText(password); + if (!typeResult.Success) + { + return typeResult; + } + + return PressKey("ENTER"); + } + + private HyperVConsoleCommandResult PressKey(string chord) + { + return _useEnhancedSession + ? PressEnhancedKey(chord) + : PressBasicKey(chord); + } + + private HyperVConsoleCommandResult PressBasicKey(string chord) + { + var parts = chord + .Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(static part => part.ToUpperInvariant()) + .ToArray(); + if (parts.Length == 0) + { + return new(false, "A key is required."); + } + + var keyboard = GetKeyboard(); + if (parts is ["CTRL" or "CONTROL", "ALT", "DELETE" or "DEL"]) + { + return ToCommandResult( + InvokeDeviceMethod(keyboard, "TypeCtrlAltDel"), + "send Ctrl+Alt+Delete to the VM"); + } + + var modifiers = new List(4); + foreach (var part in parts[..^1]) + { + var modifier = part switch + { + "CTRL" or "CONTROL" => VkControl, + "ALT" => VkMenu, + "SHIFT" => VkShift, + "WIN" or "WINDOWS" => VkLwin, + _ => (ushort)0, + }; + if (modifier == 0) + { + return new(false, $"Unknown modifier '{part}'."); + } + + modifiers.Add(modifier); + } + + if (!TryResolveVirtualKey(parts[^1], out var key)) + { + return new(false, $"Unknown key '{parts[^1]}'."); + } + + var pressedModifiers = new List(modifiers.Count); + try + { + foreach (var modifier in modifiers) + { + var pressResult = ToCommandResult( + InvokeDeviceMethod( + keyboard, + "PressKey", + ("keyCode", (uint)modifier)), + "press a modifier in the VM"); + if (!pressResult.Success) + { + return pressResult; + } + + pressedModifiers.Add(modifier); + } + + return ToCommandResult( + InvokeDeviceMethod( + keyboard, + "TypeKey", + ("keyCode", (uint)key)), + $"send {parts[^1]} to the VM"); + } + finally + { + for (var index = pressedModifiers.Count - 1; index >= 0; index--) + { + try + { + _ = InvokeDeviceMethod( + keyboard, + "ReleaseKey", + ("keyCode", (uint)pressedModifiers[index])); + } + catch (Exception) + { + } + } + } + } + + private HyperVConsoleCommandResult PressEnhancedKey(string chord) + { + var parts = chord + .Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(static part => part.ToUpperInvariant()) + .ToArray(); + if (parts.Length == 0) + { + return new(false, "A key is required."); + } + + var modifiers = new List(4); + foreach (var part in parts[..^1]) + { + var modifier = part switch + { + "CTRL" or "CONTROL" => VkControl, + "ALT" => VkMenu, + "SHIFT" => VkShift, + "WIN" or "WINDOWS" => VkLwin, + _ => (ushort)0, + }; + if (modifier == 0) + { + return new(false, $"Unknown modifier '{part}'."); + } + + modifiers.Add(modifier); + } + + if (!TryResolveVirtualKey(parts[^1], out var key)) + { + return new(false, $"Unknown key '{parts[^1]}'."); + } + + var pressedModifiers = new List(modifiers.Count); + try + { + foreach (var modifier in modifiers) + { + var pressResult = SendEnhancedVirtualKey(modifier, keyUp: false); + if (!pressResult.Success) + { + return pressResult; + } + + pressedModifiers.Add(modifier); + } + + var downResult = SendEnhancedVirtualKey(key, keyUp: false); + if (!downResult.Success) + { + return downResult; + } + + return SendEnhancedVirtualKey(key, keyUp: true); + } + finally + { + for (var index = pressedModifiers.Count - 1; index >= 0; index--) + { + _ = SendEnhancedVirtualKey(pressedModifiers[index], keyUp: true); + } + } + } + + private HyperVConsoleCommandResult SendEnhancedVirtualKey(ushort virtualKey, bool keyUp) + { + var mapped = MapVirtualKey(virtualKey, MapVkVkToVscEx); + var scanCode = (ushort)(mapped & 0xFF); + if (scanCode == 0) + { + return new( + false, + $"Windows could not map virtual key 0x{virtualKey:X2} to a scan code."); + } + + var extended = (mapped & 0xFF00) != 0; + return ToRdpInputResult( + GetRdpInputSink().SendKeyboardEvent( + KbdCodeType.ScanCode, + scanCode, + keyUp, + false, + extended), + $"send virtual key 0x{virtualKey:X2} to the Enhanced Session"); + } + + private HyperVConsoleCommandResult ScrollConsole(int x, int y, int delta) + { + return _useEnhancedSession + ? ScrollEnhancedConsole(x, y, delta) + : ScrollBasicConsole(x, y, delta); + } + + private HyperVConsoleCommandResult ScrollBasicConsole(int x, int y, int delta) + { + if (!TryResolveGuestPoint(x, y, out var guestX, out var guestY, out var error)) + { + return new(false, error); + } + + var mouse = GetMouse(); + var moveResult = ToCommandResult( + InvokeDeviceMethod( + mouse, + "SetAbsolutePosition", + ("horizontalPosition", guestX), + ("verticalPosition", guestY)), + "move the VM pointer"); + if (!moveResult.Success) + { + return moveResult; + } + + return ToCommandResult( + InvokeDeviceMethod( + mouse, + "SetScrollPosition", + ("scrollPositionDelta", delta)), + "scroll in the VM"); + } + + private HyperVConsoleCommandResult ScrollEnhancedConsole(int x, int y, int delta) + { + if (!TryResolveRdpPoint(x, y, out var guestX, out var guestY, out var error)) + { + return new(false, error); + } + + var sink = GetRdpInputSink(); + var moveResult = ToRdpInputResult( + sink.SendMouseMoveEvent((uint)guestX, (uint)guestY), + "move the pointer in the Enhanced Session"); + if (!moveResult.Success) + { + return moveResult; + } + + return ToRdpInputResult( + sink.SendMouseWheelEvent(unchecked((ushort)(short)delta)), + "scroll in the Enhanced Session"); + } + + private bool TryResolveRdpPoint( + int x, + int y, + out int guestX, + out int guestY, + out string? error) + { + var size = _activeX.ClientSize; + if (x < 0 || y < 0 || x >= size.Width || y >= size.Height) + { + guestX = 0; + guestY = 0; + error = $"The point ({x}, {y}) is outside the {size.Width}×{size.Height} console."; + return false; + } + + guestX = Math.Clamp( + (int)Math.Round( + x * (_sessionDisplaySize.Width - 1d) / Math.Max(1, size.Width - 1)), + 0, + _sessionDisplaySize.Width - 1); + guestY = Math.Clamp( + (int)Math.Round( + y * (_sessionDisplaySize.Height - 1d) / Math.Max(1, size.Height - 1)), + 0, + _sessionDisplaySize.Height - 1); + error = null; + return true; + } + + /// + /// Basic-session coordinates are IDENTITY: the framebuffer capture is exactly the + /// guest display, so screenshot pixels are guest pixels and the only work here is a + /// bounds check against the video head's native resolution. The previous version + /// stretched control-relative coordinates onto the guest resolution — but the Basic + /// console renders the guest 1:1 CENTERED in the control (SmartSizing only ever + /// shrinks), so every click aimed at a screenshot landed offset onto the wallpaper + /// with ok:true. Bounds deliberately use the GUEST resolution, not the control size: + /// a panel smaller than the guest desktop must not reject valid coordinates. + /// + private bool TryResolveGuestPoint( + int x, + int y, + out int guestX, + out int guestY, + out string? error) + { + var videoHead = GetVideoHead(); + var width = Convert.ToInt32(videoHead["CurrentHorizontalResolution"]); + var height = Convert.ToInt32(videoHead["CurrentVerticalResolution"]); + if (width <= 0 || height <= 0) + { + guestX = 0; + guestY = 0; + error = "Hyper-V did not report the VM display resolution."; + return false; + } + + if (x < 0 || y < 0 || x >= width || y >= height) + { + guestX = 0; + guestY = 0; + error = $"The point ({x}, {y}) is outside the {width}×{height} guest display. " + + "Coordinates come from the latest hyperv_screenshot of this machine."; + return false; + } + + guestX = x; + guestY = y; + error = null; + return true; + } + + private static bool TryResolveVirtualKey(string name, out ushort key) + { + if (name.Length == 1) + { + var character = name[0]; + if (character is >= 'A' and <= 'Z' or >= '0' and <= '9') + { + key = character; + return true; + } + } + + key = name switch + { + "CTRL" or "CONTROL" => VkControl, + "ALT" => VkMenu, + "SHIFT" => VkShift, + "WIN" or "WINDOWS" => VkLwin, + "ENTER" or "RETURN" => 0x0D, + "TAB" => 0x09, + "ESC" or "ESCAPE" => 0x1B, + "SPACE" => 0x20, + "BACKSPACE" => 0x08, + "DELETE" or "DEL" => 0x2E, + "INSERT" or "INS" => 0x2D, + "HOME" => 0x24, + "END" => 0x23, + "PAGEUP" or "PGUP" => 0x21, + "PAGEDOWN" or "PGDN" => 0x22, + "LEFT" => 0x25, + "UP" => 0x26, + "RIGHT" => 0x27, + "DOWN" => 0x28, + "F1" => 0x70, + "F2" => 0x71, + "F3" => 0x72, + "F4" => 0x73, + "F5" => 0x74, + "F6" => 0x75, + "F7" => 0x76, + "F8" => 0x77, + "F9" => 0x78, + "F10" => 0x79, + "F11" => 0x7A, + "F12" => 0x7B, + _ => 0, + }; + return key != 0; + } + + private IMsRdpInputSink GetRdpInputSink() + { + if (!_useEnhancedSession) + { + throw new InvalidOperationException( + "The RDP input sink is only available in Enhanced Session."); + } + + try + { + return _rdpInputSink ??= + (IMsRdpInputSink)_activeX.ActiveXInstance; + } + catch (InvalidCastException ex) + { + throw new InvalidOperationException( + "This Windows Remote Desktop control does not expose Enhanced Session input.", + ex); + } + } + + private ManagementObject GetVirtualMachine() + { + if (_virtualMachine is not null) + { + return _virtualMachine; + } + + var localHost = string.Equals( + _hostName, + Environment.MachineName, + StringComparison.OrdinalIgnoreCase) + || string.Equals(_hostName, "localhost", StringComparison.OrdinalIgnoreCase) + || _hostName == "."; + var scope = new ManagementScope( + localHost + ? @"root\virtualization\v2" + : $@"\\{_hostName}\root\virtualization\v2"); + scope.Connect(); + + using var searcher = new ManagementObjectSearcher( + scope, + new ObjectQuery( + $"SELECT * FROM Msvm_ComputerSystem WHERE Name = '{_machineId:D}'")); + using var results = searcher.Get(); + foreach (ManagementObject machine in results) + { + _virtualMachine = machine; + return machine; + } + + throw new InvalidOperationException( + $"Hyper-V could not find virtual machine {_machineId:D}."); + } + + private ManagementObject GetKeyboard() => + _keyboard ??= GetFirstRelated( + GetVirtualMachine(), + "Msvm_Keyboard", + "Msvm_SystemDevice", + "PartComponent", + "GroupComponent"); + + private ManagementObject GetMouse() => + _mouse ??= GetFirstRelated( + GetVirtualMachine(), + "Msvm_SyntheticMouse", + "Msvm_SystemDevice", + "PartComponent", + "GroupComponent"); + + /// + /// The host's one Msvm_VirtualSystemManagementService in the same + /// virtualization scope as the machine — the object that owns + /// GetVirtualSystemThumbnailImage. + /// + private ManagementObject GetManagementService() + { + if (_managementService is not null) + { + return _managementService; + } + + using var searcher = new ManagementObjectSearcher( + GetVirtualMachine().Scope, + new ObjectQuery("SELECT * FROM Msvm_VirtualSystemManagementService")); + using var results = searcher.Get(); + foreach (ManagementObject service in results) + { + _managementService = service; + return service; + } + + throw new InvalidOperationException( + "Hyper-V did not expose its virtual system management service."); + } + + /// + /// The machine's ACTIVE Msvm_VirtualSystemSettingData (its current, realized + /// settings — reached via Msvm_SettingsDefineState, which only the running + /// state has). The framebuffer API addresses the VM by this reference. + /// + private ManagementObject GetActiveSettingData() => + _activeSettingData ??= GetFirstRelated( + GetVirtualMachine(), + "Msvm_VirtualSystemSettingData", + "Msvm_SettingsDefineState", + "SettingData", + "ManagedElement"); + + private ManagementObject GetVideoHead() + { + if (_videoHead is not null) + { + // The cached instance is a client-side property snapshot; the guest's + // resolution changes across its life (boot VGA -> sign-in -> native), and + // both the framebuffer capture size and the identity click bounds read it. + try + { + _videoHead.Get(); + return _videoHead; + } + catch (Exception ex) when ( + ex is ManagementException + or COMException + or UnauthorizedAccessException) + { + // Every way a cached instance can go dead ends here, not just the WMI + // one: a reset VM re-enumerates its display controller, and the RPC + // layer reports that as a COM failure. Dropping the object and + // re-fetching below is the recovery; letting it escape would strand + // every later capture and click on this console. + _videoHead.Dispose(); + _videoHead = null; + } + } + + using var controller = GetFirstRelated( + GetVirtualMachine(), + "Msvm_SyntheticDisplayController", + "Msvm_SystemDevice", + "PartComponent", + "GroupComponent"); + _videoHead = GetFirstRelated( + controller, + "Msvm_VideoHead", + "Msvm_VideoHeadOnController", + "Dependent", + "Antecedent"); + return _videoHead; + } + + private static ManagementObject GetFirstRelated( + ManagementObject source, + string resultClass, + string associationClass, + string resultRole, + string role) + { + using var related = source.GetRelated( + resultClass, + associationClass, + null, + null, + resultRole, + role, + false, + null); + foreach (ManagementObject item in related) + { + return item; + } + + throw new InvalidOperationException( + $"Hyper-V did not expose {resultClass} for this virtual machine."); + } + + private static uint InvokeDeviceMethod( + ManagementObject device, + string methodName, + params (string Name, object Value)[] parameters) + { + using var input = parameters.Length == 0 + ? null + : device.GetMethodParameters(methodName); + if (input is not null) + { + foreach (var parameter in parameters) + { + input[parameter.Name] = parameter.Value; + } + } + + using var output = device.InvokeMethod(methodName, input, null) + ?? throw new InvalidOperationException( + $"Hyper-V returned no result for {methodName}."); + return Convert.ToUInt32(output["ReturnValue"]); + } + + private static HyperVConsoleCommandResult ToCommandResult( + uint returnCode, + string action) + { + if (returnCode == 0) + { + return new(true, null); + } + + var reason = returnCode switch + { + 1 => "the text contains a character the VM keyboard cannot translate", + 32769 => "Hyper-V denied access to the VM input device", + 32770 => "the VM input device does not support this operation", + 32773 => "Hyper-V rejected the input parameters", + 32775 => "the VM is not in a state that accepts input", + 32777 => "the VM input device is unavailable", + _ => $"Hyper-V returned error {returnCode}", + }; + return new(false, $"Could not {action}: {reason}."); + } + + private static HyperVConsoleCommandResult ToRdpInputResult( + int hresult, + string action) + { + if (hresult >= 0) + { + return new(true, null); + } + + var reason = Marshal.GetExceptionForHR(hresult)?.Message; + return new( + false, + string.IsNullOrWhiteSpace(reason) + ? $"Could not {action} (HRESULT 0x{hresult:X8})." + : $"Could not {action}: {FirstLine(reason)}"); + } + + private void OnTimerTick(object? sender, EventArgs e) + { + if (_parentWindow != 0 && !IsWindow(_parentWindow)) + { + Complete(false, "The Machine panel was closed."); + Dispose(); + return; + } + + ResizeToParent(); + if (ReadConnectionState() == 1) + { + Complete(true, null); + return; + } + + if (DateTimeOffset.UtcNow >= _connectionDeadline) + { + // Name the fallback here and nowhere else. A Basic console that also fails to + // connect is the one moment where "why am I not in Enhanced Session?" and "why + // is there no picture?" look like the same problem, and they usually are not. + Complete( + false, + _useEnhancedSession + ? "Enhanced Session did not connect within 30 seconds." + : _enhancedFallbackReason is { Length: > 0 } fallback + ? $"The Hyper-V console did not connect within 30 seconds. " + + $"Enhanced Session was not used: {fallback}" + : "The Hyper-V console did not connect within 30 seconds."); + Dispose(); + } + } + + private void OnRdpConnected() => Complete(true, null); + + private void OnRdpDisconnected(int reason) + { + if (_connection?.Task.IsCompleted != false) + { + return; + } + + Complete(false, DescribeDisconnect(reason)); + Dispose(); + } + + private void OnRdpFatalError(int errorCode) + { + if (_connection?.Task.IsCompleted != false) + { + return; + } + + Complete( + false, + $"The Remote Desktop control could not start the Hyper-V session " + + $"(error {errorCode})."); + Dispose(); + } + + private void OnRdpLogonError(int errorCode) + { + if (_connection?.Task.IsCompleted != false) + { + return; + } + + Complete( + false, + errorCode is 0 or -1 + ? "Windows could not open the Enhanced Session sign-in prompt." + : $"Enhanced Session sign-in failed (RDP logon error {errorCode})."); + Dispose(); + } + + private string DescribeDisconnect(int reason) + { + if (_useEnhancedSession && reason == CredentialFailureDisconnectReason) + { + return "Windows could not authenticate the Hyper-V console connection."; + } + + try + { + dynamic client = _activeX.ActiveXInstance; + var extendedReason = Convert.ToUInt32(client.ExtendedDisconnectReason); + var description = (string?)client.GetErrorDescription( + Convert.ToUInt32(reason), + extendedReason); + if (!string.IsNullOrWhiteSpace(description)) + { + return FirstLine(description); + } + } + catch (Exception) + { + } + + return $"The Hyper-V console disconnected before sign-in (reason {reason})."; + } + + private void ResizeToParent() + { + var parentSize = ReadParentClientSize(); + if (_parentWindow != 0) SetWindowPos( + Handle, + nint.Zero, + 0, + 0, + parentSize.Width, + parentSize.Height, + SwpNoActivate | SwpShowWindow); + + var displaySize = NormalizeSessionDisplaySize(parentSize); + var displayScaleFactor = ResolveDesktopScaleFactor(); + if (!_useEnhancedSession + || _dynamicResolutionUnavailable + || ReadConnectionState() != 1 + || (displaySize == _sessionDisplaySize + && displayScaleFactor == _sessionDisplayScaleFactor)) + { + return; + } + + try + { + var dpi = Math.Max(96u, GetDpiForWindow(_parentWindow)); + var physicalWidth = Math.Max( + 1u, + (uint)Math.Round(displaySize.Width * 25.4 / dpi)); + var physicalHeight = Math.Max( + 1u, + (uint)Math.Round(displaySize.Height * 25.4 / dpi)); + var client = + (IMsRdpClient9Dispatch)_activeX.ActiveXInstance; + client.UpdateSessionDisplaySettings( + (uint)displaySize.Width, + (uint)displaySize.Height, + physicalWidth, + physicalHeight, + 0u, + displayScaleFactor, + ResolveDeviceScaleFactor(displayScaleFactor)); + _sessionDisplaySize = displaySize; + _sessionDisplayScaleFactor = displayScaleFactor; + } + catch (Exception ex) when ( + ex is COMException + or InvalidCastException) + { + _dynamicResolutionUnavailable = true; + } + } + + private Size ReadParentClientSize() + { + if (_parentWindow == 0) return ClientSize; + if (!GetClientRect(_parentWindow, out var rect)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return new( + Math.Max(1, rect.Right - rect.Left), + Math.Max(1, rect.Bottom - rect.Top)); + } + + private static Size NormalizeSessionDisplaySize(Size size) => + new( + Math.Clamp(size.Width, 200, 8192), + Math.Clamp(size.Height, 200, 8192)); + + private uint ResolveDesktopScaleFactor() + { + if (_requestedDisplayScalePercent != 0) + { + return (uint)_requestedDisplayScalePercent; + } + + var hostScale = Math.Max(96u, GetDpiForWindow(_parentWindow == 0 ? Handle : _parentWindow)) * 100d / 96d; + var nearest = SupportedDesktopScaleFactors[0]; + var nearestDistance = Math.Abs(hostScale - nearest); + foreach (var scale in SupportedDesktopScaleFactors[1..]) + { + var distance = Math.Abs(hostScale - scale); + if (distance < nearestDistance) + { + nearest = scale; + nearestDistance = distance; + } + } + + return nearest; + } + + private static uint ResolveDeviceScaleFactor(uint desktopScaleFactor) => + desktopScaleFactor switch + { + < 125u => 100u, + < 175u => 140u, + _ => 180u, + }; + + private short ReadConnectionState() + { + try + { + dynamic client = _activeX.ActiveXInstance; + return (short)client.Connected; + } + catch (Exception) + { + return 0; + } + } + + private void Complete(bool success, string? error) + { + // The app requested a mode but the broker may have silently fallen back to + // Basic; the result carries what actually connected, because capture semantics + // (headless framebuffer vs real-screen copy) differ between the two — and, on a + // connect that succeeded anyway, why Enhanced was not used. + var note = success && _enhancedFallbackReason is { Length: > 0 } fallback + ? $"Enhanced Session was not used: {fallback}" + : null; + if (_connection?.TrySetResult(new(success, error, _useEnhancedSession, note)) == true) + { + if (success) + { + _connectionDeadline = DateTimeOffset.MaxValue; + } + else + { + _timer.Stop(); + } + } + } + + private sealed class RemoteDesktopAxHost : AxHost + { + private readonly RemoteDesktopEventSink _eventSink; + private ConnectionPointCookie? _connectionPoint; + + public RemoteDesktopAxHost() + : base("A0C63C30-F08D-4AB4-907C-34905D770C7D") + { + _eventSink = new(this); + } + + public event Action? Connected; + + public event Action? Disconnected; + + public event Action? FatalError; + + public event Action? LogonError; + + public object ActiveXInstance => + GetOcx() + ?? throw new InvalidOperationException( + "The Remote Desktop control is not initialized."); + + protected override void CreateSink() + { + base.CreateSink(); + _connectionPoint = new( + this, + _eventSink, + typeof(IMsTscAxEvents)); + } + + protected override void DetachSink() + { + _connectionPoint?.Disconnect(); + _connectionPoint = null; + base.DetachSink(); + } + + private sealed class RemoteDesktopEventSink(RemoteDesktopAxHost owner) + : StandardOleMarshalObject, IMsTscAxEvents + { + public void OnConnected() => owner.Connected?.Invoke(); + + public void OnDisconnected(int reason) => + owner.Disconnected?.Invoke(reason); + + public void OnFatalError(int errorCode) => + owner.FatalError?.Invoke(errorCode); + + public void OnLogonError(int errorCode) => + owner.LogonError?.Invoke(errorCode); + } + } + + [Guid("336D5562-EFA8-482E-8CB3-C5C0FC7A7DB6")] + [InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] + private interface IMsTscAxEvents + { + [DispId(2)] + void OnConnected(); + + [DispId(4)] + void OnDisconnected(int discReason); + + [DispId(10)] + void OnFatalError(int errorCode); + + [DispId(22)] + void OnLogonError(int errorCode); + } + + [ComImport] + [Guid("302D8188-0052-4807-806A-362B628F9AC5")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IMsRdpExtendedSettings + { + void set_Property( + [MarshalAs(UnmanagedType.BStr)] string propertyName, + [In, MarshalAs(UnmanagedType.Struct)] ref object value); + + [return: MarshalAs(UnmanagedType.Struct)] + object get_Property( + [MarshalAs(UnmanagedType.BStr)] string propertyName); + } + + [ComImport] + [Guid("28904001-04B6-436C-A55B-0AF1A0883DC9")] + [InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] + private interface IMsRdpClient9Dispatch + { + [DispId(802)] + void UpdateSessionDisplaySettings( + uint desktopWidth, + uint desktopHeight, + uint physicalWidth, + uint physicalHeight, + uint orientation, + uint desktopScaleFactor, + uint deviceScaleFactor); + } + + private enum MouseButtonType + { + Button1 = 0, + Button2 = 1, + Button3 = 2, + XButton1 = 3, + XButton2 = 4, + XButton3 = 5, + } + + private enum KbdCodeType + { + ScanCode = 0, + Unicode = 1, + } + + [ComImport] + [Guid("4606850E-76A7-4E28-A47E-C7174F619351")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IMsRdpInputSink + { + [PreserveSig] + int SendMouseButtonEvent( + MouseButtonType buttonType, + [MarshalAs(UnmanagedType.VariantBool)] bool buttonDown, + uint x, + uint y); + + [PreserveSig] + int SendMouseMoveEvent(uint x, uint y); + + [PreserveSig] + int SendMouseWheelEvent(ushort wheelRotation); + + [PreserveSig] + int SendKeyboardEvent( + KbdCodeType codeType, + ushort keyCode, + [MarshalAs(UnmanagedType.VariantBool)] bool keyUp, + [MarshalAs(UnmanagedType.VariantBool)] bool repeat, + [MarshalAs(UnmanagedType.VariantBool)] bool extended); + + [PreserveSig] + int SendSyncEvent(uint syncFlags); + + [PreserveSig] + int BeginTouchFrame(); + + [PreserveSig] + int AddTouchInput(uint contactId, uint inputEvent, int x, int y); + + [PreserveSig] + int EndTouchFrame(); + } + + [StructLayout(LayoutKind.Sequential)] + private struct Rect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern nint SetParent(nint child, nint newParent); + + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)] + private static extern nint GetWindowLongPtr(nint window, int index); + + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)] + private static extern nint SetWindowLongPtr(nint window, int index, nint value); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetWindowPos( + nint window, + nint insertAfter, + int x, + int y, + int width, + int height, + uint flags); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetClientRect(nint window, out Rect rect); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWindow(nint window); + + private delegate bool EnumWindowsCallback(nint window, nint data); + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsCallback callback, nint data); + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(nint window); + [DllImport("user32.dll")] + private static extern bool GetWindowRect(nint window, out Rect rect); + [DllImport("user32.dll")] + private static extern nint GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern uint GetDpiForWindow(nint window); + + [DllImport("user32.dll", EntryPoint = "MapVirtualKeyW")] + private static extern uint MapVirtualKey(uint code, uint mapType); + + [DllImport("kernel32.dll")] + private static extern void SetLastError(uint errorCode); + } +} diff --git a/src/HyperVControl/Extracted/HyperVGuestUiService.cs b/src/HyperVControl/Extracted/HyperVGuestUiService.cs new file mode 100644 index 0000000..810d9bc --- /dev/null +++ b/src/HyperVControl/Extracted/HyperVGuestUiService.cs @@ -0,0 +1,1365 @@ +// Extracted and adapted from RaivenX b5971dda1fa2050dde36b8b22fda9726df92cb48. See THIRD-PARTY-NOTICES.md. +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using HyperVControl.Services; + +namespace HyperVControl.Extracted; + +/// +/// Installs and runs Microsoft's winapp CLI inside a Windows guest for semantic UI automation. +/// PowerShell Direct processes can never reach the interactive desktop, so every +/// winapp ui invocation is bridged through a reusable scheduled task whose principal is +/// the guest's signed-in user with an Interactive logon. The host downloads the release once, +/// verifies its published SHA-256 digest, and copies it in over PowerShell Direct, so the guest +/// itself never needs internet access. +/// +public sealed class HyperVGuestUiService +{ + /// Stable install root inside the guest. + internal const string GuestInstallRoot = @"C:\ProgramData\HyperVControl\winapp"; + + /// Reusable scheduled task that runs winapp in the interactive session. + internal const string GuestTaskName = "HyperVControl-guest-ui"; + + internal const string EnvelopeBeginMarker = "HYPERVCONTROL-GUEST-UI-BEGIN"; + internal const string EnvelopeEndMarker = "HYPERVCONTROL-GUEST-UI-END"; + + /// Exact GitHub release asset installed into the guest. + internal const string ReleaseAssetName = "winappcli-x64.zip"; + + /// + /// The privileged protocol rejects guest scripts above 32,768 characters, so composed + /// scripts must stay comfortably below that limit. + /// + internal const int MaxGuestScriptChars = 30_000; + + internal const int MaxUiArguments = 32; + internal const int MaxUiArgumentLength = 1_024; + internal const int MaxTotalUiArgumentLength = 6_000; + + internal const string NotInstalledError = + "winapp is not installed in this guest. Run the hyperv_guest_setup command first."; + + internal const string NoInteractiveSessionError = + "No user is signed in to the guest's interactive desktop. Sign in to the VM console or Enhanced session, then retry."; + + internal const string DesktopLockedError = + "The guest desktop is locked. Unlock the guest session, then retry."; + + internal const string GuestUnresponsiveError = + "The guest did not return the winapp result in time. The virtual machine may be busy; retry."; + + internal const string UnreadableResultError = + "The guest returned an unreadable winapp result."; + + internal static string TimeoutErrorText(int timeoutSeconds) => + $"winapp did not finish within {timeoutSeconds} seconds in the guest's interactive session."; + + /// + /// The winapp ui subcommands that may be composed through this service. Keyboard input and + /// image capture are deliberately absent: the console channel already does both natively and + /// more cheaply (see ), so this family stays focused on what + /// only UI Automation can do — finding elements and driving them through their patterns. + /// + internal static readonly string[] UiCommands = + [ + "status", + "inspect", + "search", + "list-windows", + "invoke", + "click", + "focus", + "get-value", + "set-value", + "get-property", + "wait-for", + "scroll", + ]; + + /// + /// winapp subcommands this service refuses on purpose, each answered with the native tool to + /// use instead. A model that reaches for them gets the redirect at the point of failure + /// rather than a bare "unknown command". + /// + internal static readonly IReadOnlyDictionary RedirectedUiCommands = + new Dictionary(StringComparer.Ordinal) + { + ["send-keys"] = + "The hyperv_ui command does not send keyboard input. Put text in a field with " + + "its \"set-value\" subcommand (fastest, no focus needed), or focus the target " + + "with \"focus\" and then use the hyperv_input command with action=type for " + + "text or action=key for a chord.", + ["screenshot"] = + "The hyperv_ui command does not capture images. Use the hyperv_screenshot " + + "command, which captures the whole console to a PNG on the host and returns " + + "its path and dimensions.", + }; + + private static readonly Uri LatestReleaseUri = new( + "https://api.github.com/repos/microsoft/winappCli/releases/latest"); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private static readonly HttpClient SharedHttpClient = CreateHttpClient(); + + /// One gate for the shared per-user archive cache across all panel instances. + private static readonly SemaphoreSlim ArchiveGate = new(1, 1); + + /// + /// Resolves the installed winapp executable. The zip layout is not contractual, so the + /// stable root path is preferred and a recursive search is the fallback. This snippet is + /// the single source reused by the probe, the task action, and the install script. + /// + private static readonly string ResolveWinappSnippet = + $$""" + $installRoot = '{{GuestInstallRoot}}' + $winappExe = Join-Path $installRoot 'winapp.exe' + if (-not (Test-Path -LiteralPath $winappExe)) { + $winappExe = Get-ChildItem -LiteralPath $installRoot -Recurse -File -Filter 'winapp.exe' -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + } + if ([string]::IsNullOrWhiteSpace([string]$winappExe)) { + $winappExe = $null + } + """; + + /// + /// Shared prologue for scripts that run inside the guest through PowerShell Direct. The + /// result is emitted as base64 in short fixed-width lines because the broker renders remote + /// output through Out-String -Width 4096, which would wrap one long JSON line. + /// + private static readonly string GuestScriptPrologue = + $$""" + $ErrorActionPreference = 'Stop' + $ProgressPreference = 'SilentlyContinue' + $WarningPreference = 'SilentlyContinue' + $env:WINAPP_CLI_TELEMETRY_OPTOUT = '1' + + function Send-HyperVControlGuestUiResult([hashtable]$Payload) { + $json = $Payload | ConvertTo-Json -Compress + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)) + Write-Output '{{EnvelopeBeginMarker}}' + for ($index = 0; $index -lt $encoded.Length; $index += 3000) { + Write-Output $encoded.Substring($index, [Math]::Min(3000, $encoded.Length - $index)) + } + Write-Output '{{EnvelopeEndMarker}}' + } + + function Resolve-HyperVControlWinapp { + {{ResolveWinappSnippet}} + return $winappExe + } + """; + + private readonly Func> + _runGuestScript; + private readonly Func> + _installArchive; + private readonly HttpClient _httpClient; + private readonly string _cacheRoot; + + public HyperVGuestUiService( + Func> runGuestScript, + Func> installArchive) + : this( + runGuestScript, + installArchive, + SharedHttpClient, + Path.Combine(ControlPaths.DataRoot, "tools", "winapp")) + { + } + + internal HyperVGuestUiService( + Func> runGuestScript, + Func> installArchive, + HttpClient httpClient, + string cacheRoot) + { + _runGuestScript = runGuestScript + ?? throw new ArgumentNullException(nameof(runGuestScript)); + _installArchive = installArchive + ?? throw new ArgumentNullException(nameof(installArchive)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _cacheRoot = Path.GetFullPath(cacheRoot); + } + + /// + /// Ensures winapp is installed inside the guest. The probe runs through the normal guest + /// command path; when winapp is missing, the verified release archive is copied in from the + /// host over PowerShell Direct and extracted at . + /// + public async Task EnsureWinappAsync( + Guid machineId, + CancellationToken ct = default) + { + ValidateMachineId(machineId); + + var probe = await ProbeAsync(machineId, ct).ConfigureAwait(false); + if (probe.Error is not null) + { + return new(false, false, null, Downloaded: false, probe.Error); + } + + if (probe.Installed) + { + return new(true, true, probe.Version, Downloaded: false, null); + } + + string archivePath; + bool downloaded; + try + { + (archivePath, downloaded) = await EnsureHostArchiveAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) when ( + ex is HttpRequestException + or InvalidDataException + or IOException + or UnauthorizedAccessException + or JsonException) + { + return new( + false, + false, + null, + Downloaded: false, + FirstLine(ex.Message, "The winapp release could not be downloaded.") + + " Check the host's internet access, then retry."); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + // The download's own timeout, not the caller stopping the turn. + return new( + false, + false, + null, + Downloaded: false, + "Downloading the winapp tool from GitHub timed out. Check the host's internet " + + "access, then retry."); + } + + // The copy needs a PowerShell Direct session, which requires Hyper-V administrator + // rights the app process does not hold — it runs in the elevated broker like every + // other guest operation, with the broker's usual credential cache and prompt. + var copyResult = await _installArchive(machineId, archivePath, ct).ConfigureAwait(false); + if (!copyResult.Success) + { + return new( + false, + false, + null, + downloaded, + FirstLine(copyResult.Error, "winapp could not be copied into the guest.")); + } + + var verify = await ProbeAsync(machineId, ct).ConfigureAwait(false); + if (verify.Error is not null) + { + return new(false, false, null, downloaded, verify.Error); + } + + if (!verify.Installed) + { + return new( + false, + false, + null, + downloaded, + "winapp was copied into the guest but winapp.exe could not be found afterwards."); + } + + return new(true, true, verify.Version, downloaded, null); + } + + /// + /// Runs one winapp ui command in the guest's signed-in interactive session and + /// returns its output and exit code. Only allow-listed ui subcommands are composable, so + /// no other guest executable or shell can be reached through this path. + /// + public async Task RunGuestUiAsync( + Guid machineId, + string uiCommand, + IReadOnlyList uiArguments, + TimeSpan timeout, + bool elevated = false, + CancellationToken ct = default) + { + ValidateMachineId(machineId); + ArgumentNullException.ThrowIfNull(uiArguments); + var command = ValidateUiCommand(uiCommand); + ValidateUiArguments(uiArguments); + var timeoutSeconds = ValidateTimeout(timeout); + + var script = BuildRunUiScript( + command, + uiArguments, + timeoutSeconds, + Guid.NewGuid().ToString("N"), + elevated); + + HyperVGuestCommandResult result; + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); + linked.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds + 60)); + try + { + result = await _runGuestScript(machineId, script, linked.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return new(false, null, null, null, GuestUnresponsiveError); + } + + if (!result.Success) + { + return new( + false, + null, + null, + null, + FirstLine(result.Error, "PowerShell Direct could not run winapp in the guest.")); + } + + var envelope = ParseGuestEnvelope(result.Output); + if (envelope is null) + { + return new(false, null, null, null, UnreadableResultError); + } + + return envelope.Status switch + { + "ok" => new( + envelope.ExitCode == 0, + envelope.ExitCode ?? -1, + envelope.Stdout, + envelope.Stderr, + envelope.ExitCode == 0 ? null : FirstLine(envelope.Stderr, "winapp exited with a nonzero code.")), + "notInstalled" => new(false, null, null, null, NotInstalledError), + "noSession" => new(false, null, null, null, NoInteractiveSessionError), + "locked" => new(false, null, null, null, DesktopLockedError), + "timeout" => new(false, null, null, null, TimeoutErrorText(timeoutSeconds)), + _ => new( + false, + null, + null, + null, + FirstLine(envelope.Error, "winapp failed inside the guest.")), + }; + } + + /// Guest-side probe: reports whether winapp.exe exists and its version. + internal static string BuildProbeScript() => + GuestScriptPrologue + + """ + + try { + $winappExe = Resolve-HyperVControlWinapp + if ($null -eq $winappExe) { + Send-HyperVControlGuestUiResult @{ status = 'ok'; installed = $false; version = $null } + } else { + $version = $null + try { + $previousPreference = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $version = [string](& $winappExe --version 2>&1 | Where-Object { $_ -match '^\d+\.\d+\.\d+' } | Select-Object -Last 1) + $ErrorActionPreference = $previousPreference + } catch { + $version = $null + } + Send-HyperVControlGuestUiResult @{ status = 'ok'; installed = $true; version = $version } + } + } catch { + Send-HyperVControlGuestUiResult @{ status = 'error'; error = $_.Exception.Message } + } + """; + + /// + /// Guest-side bridge into the interactive desktop: resolves the signed-in user, refuses a + /// locked or empty desktop with distinct statuses, then registers and starts the reusable + /// scheduled task whose action runs winapp with its output + /// redirected to unique job files. The exit-code file is written last, so its existence is + /// the completion sentinel the poll loop waits for. + /// + internal static string BuildRunUiScript( + string uiCommand, + IReadOnlyList uiArguments, + int timeoutSeconds, + string jobId, + bool elevated = false) + { + var encodedTaskCommand = EncodePowerShellCommand( + BuildTaskScript(uiCommand, uiArguments, jobId)); + // A task registered with Highest gets the signed-in admin's full token with no UAC + // consent, which is the only way to read or drive a guest app that auto-elevates + // (Task Manager). It is opt-in because the reverse also bites: UIPI blocks synthetic + // input from a HIGH-integrity process into an AppContainer target, so an always- + // elevated bridge would break Start, Search, and Settings. + var runLevel = elevated ? " -RunLevel Highest" : string.Empty; + return GuestScriptPrologue + + $$""" + + try { + $winappExe = Resolve-HyperVControlWinapp + $interactiveUser = $null + $interactiveSessionId = $null + $explorer = Get-CimInstance -ClassName Win32_Process -Filter "Name = 'explorer.exe'" -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($null -ne $explorer) { + $interactiveSessionId = [int]$explorer.SessionId + $owner = Invoke-CimMethod -InputObject $explorer -MethodName GetOwner -ErrorAction SilentlyContinue + if ($null -ne $owner -and $owner.ReturnValue -eq 0 -and -not [string]::IsNullOrWhiteSpace($owner.User)) { + if ([string]::IsNullOrWhiteSpace($owner.Domain)) { + $interactiveUser = $owner.User + } else { + $interactiveUser = $owner.Domain + '\' + $owner.User + } + } + } + if ($null -eq $interactiveUser) { + $interactiveUser = (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue).UserName + } + + # The lock check must stay inside the interactive user's own session: under an + # Enhanced Session the desktop is an RDP session while the CONSOLE session sits + # at its lock screen running LogonUI, so a machine-wide LogonUI check reports + # "locked" forever. No known session means no lock verdict — winapp itself + # fast-fails input verbs on a desktop it cannot reach. + $sessionLocked = $false + if ($null -ne $interactiveSessionId) { + $sessionLocked = $null -ne (Get-Process -Name 'LogonUI' -ErrorAction SilentlyContinue | + Where-Object { $_.SessionId -eq $interactiveSessionId }) + } + + if ($null -eq $winappExe) { + Send-HyperVControlGuestUiResult @{ status = 'notInstalled' } + } elseif ([string]::IsNullOrWhiteSpace($interactiveUser)) { + Send-HyperVControlGuestUiResult @{ status = 'noSession' } + } elseif ($sessionLocked) { + Send-HyperVControlGuestUiResult @{ status = 'locked' } + } else { + $jobsRoot = '{{GuestInstallRoot}}\jobs' + New-Item -ItemType Directory -Force -Path $jobsRoot | Out-Null + Get-ChildItem -LiteralPath $jobsRoot -File -ErrorAction SilentlyContinue | + Where-Object { $_.LastWriteTimeUtc -lt [DateTime]::UtcNow.AddHours(-6) } | + Remove-Item -Force -ErrorAction SilentlyContinue + $outFile = Join-Path $jobsRoot '{{jobId}}.out' + $errFile = Join-Path $jobsRoot '{{jobId}}.err' + $codeFile = Join-Path $jobsRoot '{{jobId}}.code' + # powershell.exe is a console program, so Windows creates its console + # window before -WindowStyle Hidden can take effect: every call flashed a + # window that also STOLE FOREGROUND, which is what makes winapp refuse + # input with 'foreground_not_target'. conhost --headless attaches a + # pseudoconsole with no window at all, so nothing ever appears or takes + # focus. The working directory is the jobs folder because winapp writes + # relative output paths there — under the default C:\Windows\system32 an + # unelevated write is denied outright. + $action = New-ScheduledTaskAction -Execute 'conhost.exe' -Argument '--headless powershell.exe -NoProfile -NonInteractive -EncodedCommand {{encodedTaskCommand}}' -WorkingDirectory $jobsRoot + $principal = New-ScheduledTaskPrincipal -UserId $interactiveUser -LogonType Interactive{{runLevel}} + Stop-ScheduledTask -TaskName '{{GuestTaskName}}' -ErrorAction SilentlyContinue + Register-ScheduledTask -TaskName '{{GuestTaskName}}' -Action $action -Principal $principal -Force | Out-Null + Start-ScheduledTask -TaskName '{{GuestTaskName}}' -ErrorAction Stop + $deadline = [DateTime]::UtcNow.AddSeconds({{timeoutSeconds}}) + while (-not (Test-Path -LiteralPath $codeFile) -and [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 250 + } + if (-not (Test-Path -LiteralPath $codeFile)) { + Stop-ScheduledTask -TaskName '{{GuestTaskName}}' -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue + Send-HyperVControlGuestUiResult @{ status = 'timeout' } + } else { + # Get-Content -Raw on an EMPTY file returns $null, and [string] of a + # null pipeline result is $null (not ''), so read then coalesce. The + # 2> redirect always creates a 0-byte .err file on a clean run, which + # made every successful winapp call crash on $stderr.TrimEnd(). + $exitCodeText = [string](Get-Content -LiteralPath $codeFile -Raw) + if ($null -eq $exitCodeText) { $exitCodeText = '' } + $exitCode = 0 + if (-not [int]::TryParse($exitCodeText.Trim(), [ref]$exitCode)) { + $exitCode = -1 + } + $stdout = '' + if (Test-Path -LiteralPath $outFile) { + $rawOut = [string](Get-Content -LiteralPath $outFile -Raw -Encoding UTF8) + if ($null -ne $rawOut) { $stdout = $rawOut } + } + $stderr = '' + if (Test-Path -LiteralPath $errFile) { + $rawErr = [string](Get-Content -LiteralPath $errFile -Raw -Encoding UTF8) + if ($null -ne $rawErr) { $stderr = $rawErr } + } + if ($stdout.Length -gt 400000) { + $stdout = $stdout.Substring(0, 400000) + '[truncated by HyperVControl]' + } + if ($stderr.Length -gt 100000) { + $stderr = $stderr.Substring(0, 100000) + '[truncated by HyperVControl]' + } + Remove-Item -LiteralPath $outFile, $errFile, $codeFile -Force -ErrorAction SilentlyContinue + Send-HyperVControlGuestUiResult @{ status = 'ok'; exitCode = $exitCode; stdout = $stdout.TrimEnd(); stderr = $stderr.TrimEnd() } + } + } + } catch { + $failedAt = '' + if ($null -ne $_.InvocationInfo -and $_.InvocationInfo.ScriptLineNumber -gt 0) { + $failedAt = ' (guest script line ' + $_.InvocationInfo.ScriptLineNumber + ')' + } + Send-HyperVControlGuestUiResult @{ status = 'error'; error = ($_.Exception.Message + $failedAt) } + } + """; + } + + /// + /// The scheduled task action, encoded for powershell.exe -EncodedCommand. Every + /// model-provided token is embedded as a single-quoted PowerShell literal, so arguments can + /// never break out of the fixed winapp ui invocation. + /// + internal static string BuildTaskScript( + string uiCommand, + IReadOnlyList uiArguments, + string jobId) + { + var arguments = new List(uiArguments.Count + 2) { "ui", uiCommand }; + arguments.AddRange(uiArguments); + var argumentLine = QuotePowerShellLiteral(BuildWindowsArgumentLine(arguments)); + + // winapp is launched through Start-Process rather than the call operator with a + // PowerShell redirect: PowerShell turns a native command's stderr into ErrorRecords and + // renders them with NativeCommandError decoration AND console-width word wrapping, which + // split winapp's JSON error envelope mid-string and left it unparseable. Start-Process + // writes both streams as raw bytes, and its working directory is where winapp puts + // relative output such as PNGs — under the task's default C:\Windows\system32 those + // writes are denied outright. Guest scripts pay for every character over the wire, so + // the reasoning lives here and the script itself carries only a one-line reminder. + return $$""" + $ErrorActionPreference = 'Continue' + $env:WINAPP_CLI_TELEMETRY_OPTOUT = '1' + {{ResolveWinappSnippet}} + $jobsRoot = '{{GuestInstallRoot}}\jobs' + $outFile = Join-Path $jobsRoot '{{jobId}}.out' + $errFile = Join-Path $jobsRoot '{{jobId}}.err' + $codeFile = Join-Path $jobsRoot '{{jobId}}.code' + $exitCode = -1 + try { + if ($null -eq $winappExe) { + Set-Content -LiteralPath $errFile -Value 'winapp.exe was not found in the guest.' -Encoding UTF8 + } else { + # Raw stream capture: a PowerShell redirect would reformat winapp's stderr. + $proc = Start-Process -FilePath $winappExe -ArgumentList {{argumentLine}} -WorkingDirectory $jobsRoot -RedirectStandardOutput $outFile -RedirectStandardError $errFile -NoNewWindow -Wait -PassThru + if ($null -ne $proc) { + $exitCode = $proc.ExitCode + } + } + } catch { + $_.Exception.Message | Set-Content -LiteralPath $errFile -Encoding UTF8 + } + Set-Content -LiteralPath $codeFile -Value $exitCode -Encoding UTF8 + """; + } + + /// + /// Joins arguments into one Windows command line using the quoting rules + /// CommandLineToArgvW reverses. Windows PowerShell's Start-Process concatenates + /// -ArgumentList entries with spaces and quotes nothing, so an argument containing a + /// space (a window title such as Task Manager) would otherwise arrive as two. + /// + internal static string BuildWindowsArgumentLine(IReadOnlyList arguments) => + string.Join(' ', arguments.Select(QuoteWindowsArgument)); + + private static string QuoteWindowsArgument(string argument) + { + if (argument.Length > 0 && argument.IndexOfAny([' ', '\t', '"']) < 0) + { + return argument; + } + + var quoted = new StringBuilder("\""); + for (var index = 0; index < argument.Length; index++) + { + var backslashes = 0; + while (index < argument.Length && argument[index] == '\\') + { + backslashes++; + index++; + } + + if (index == argument.Length) + { + // Trailing backslashes precede the closing quote, so they must be doubled. + quoted.Append('\\', backslashes * 2); + break; + } + + if (argument[index] == '"') + { + quoted.Append('\\', (backslashes * 2) + 1).Append('"'); + } + else + { + quoted.Append('\\', backslashes).Append(argument[index]); + } + } + + return quoted.Append('"').ToString(); + } + + /// + /// Host-side install script: opens one PowerShell Direct session with the stored guest + /// credential, copies the verified archive in with Copy-Item -ToSession (the + /// network-free path), and extracts it at . Credential and + /// path values are embedded as base64 so quoting can never break the script. + /// + internal static string BuildHostCopyScript( + Guid machineId, + string userName, + string password, + string archivePath) + { + var encodedUser = Convert.ToBase64String(Encoding.UTF8.GetBytes(userName)); + var encodedPassword = Convert.ToBase64String(Encoding.UTF8.GetBytes(password)); + var encodedArchivePath = Convert.ToBase64String(Encoding.UTF8.GetBytes(archivePath)); + return $$""" + [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) + $ErrorActionPreference = 'Stop' + $ProgressPreference = 'SilentlyContinue' + $WarningPreference = 'SilentlyContinue' + $env:WINAPP_CLI_TELEMETRY_OPTOUT = '1' + function Decode([string]$Value) { + [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value)) + } + try { + $userName = Decode '{{encodedUser}}' + $password = ConvertTo-SecureString (Decode '{{encodedPassword}}') -AsPlainText -Force + $credential = [System.Management.Automation.PSCredential]::new($userName, $password) + $session = New-PSSession -VMId ([Guid]'{{machineId:D}}') -Credential $credential -ErrorAction Stop + try { + Invoke-Command -Session $session -ErrorAction Stop -ScriptBlock { + $ErrorActionPreference = 'Stop' + New-Item -ItemType Directory -Force -Path '{{GuestInstallRoot}}' | Out-Null + } + Copy-Item -LiteralPath (Decode '{{encodedArchivePath}}') -Destination '{{GuestInstallRoot}}\winapp-install.zip' -ToSession $session -Force -ErrorAction Stop + $remoteExe = Invoke-Command -Session $session -ErrorAction Stop -ScriptBlock { + $ErrorActionPreference = 'Stop' + Expand-Archive -LiteralPath '{{GuestInstallRoot}}\winapp-install.zip' -DestinationPath '{{GuestInstallRoot}}' -Force + Remove-Item -LiteralPath '{{GuestInstallRoot}}\winapp-install.zip' -Force -ErrorAction SilentlyContinue + {{ResolveWinappSnippet}} + $winappExe + } + if ([string]::IsNullOrWhiteSpace([string]$remoteExe)) { + throw 'The winapp archive did not contain winapp.exe.' + } + @{ success = $true; exePath = [string]$remoteExe } | ConvertTo-Json -Compress + } finally { + Remove-PSSession -Session $session -ErrorAction SilentlyContinue + } + } catch { + @{ success = $false; error = $_.Exception.Message } | ConvertTo-Json -Compress + } + """; + } + + /// Single-quotes data with the same delimiter escaping as host operations. + internal static string QuotePowerShellLiteral(string value) => + PowerShellRunner.Literal(value); + + /// UTF-16LE base64 for powershell.exe -EncodedCommand. + internal static string EncodePowerShellCommand(string script) => + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); + + internal static string ValidateUiCommand(string? uiCommand) + { + var normalized = uiCommand?.Trim().ToLowerInvariant(); + if (normalized is { Length: > 0 } + && RedirectedUiCommands.TryGetValue(normalized, out var redirect)) + { + throw new ArgumentException(redirect, nameof(uiCommand)); + } + + if (string.IsNullOrEmpty(normalized) + || !UiCommands.Contains(normalized, StringComparer.Ordinal)) + { + throw new ArgumentException( + $"Unknown winapp ui command '{uiCommand}'. Use one of: {string.Join(", ", UiCommands)}.", + nameof(uiCommand)); + } + + return normalized; + } + + internal static void ValidateUiArguments(IReadOnlyList uiArguments) + { + if (uiArguments.Count > MaxUiArguments) + { + throw new ArgumentException( + $"At most {MaxUiArguments} winapp ui arguments are supported.", + nameof(uiArguments)); + } + + var totalLength = 0; + foreach (var argument in uiArguments) + { + if (argument is null) + { + throw new ArgumentException( + "winapp ui arguments must not be null.", + nameof(uiArguments)); + } + + if (argument.Length > MaxUiArgumentLength) + { + throw new ArgumentException( + $"Each winapp ui argument must be at most {MaxUiArgumentLength} characters.", + nameof(uiArguments)); + } + + if (argument.Any(static character => + char.IsControl(character) && character != '\t')) + { + throw new ArgumentException( + "winapp ui arguments must not contain control characters.", + nameof(uiArguments)); + } + + totalLength += argument.Length; + } + + if (totalLength > MaxTotalUiArgumentLength) + { + throw new ArgumentException( + $"winapp ui arguments must total at most {MaxTotalUiArgumentLength} characters.", + nameof(uiArguments)); + } + } + + internal static int ValidateTimeout(TimeSpan timeout) + { + if (timeout <= TimeSpan.Zero || timeout > TimeSpan.FromSeconds(600)) + { + throw new ArgumentOutOfRangeException( + nameof(timeout), + timeout, + "The winapp ui timeout must be between 1 and 600 seconds."); + } + + return Math.Max(1, (int)Math.Ceiling(timeout.TotalSeconds)); + } + + /// + /// Reassembles the chunked base64 result the guest emitted between the envelope markers. + /// Whitespace is stripped first because the broker's Out-String rendering re-wraps + /// lines. + /// + internal static string? ExtractGuestEnvelopeJson(string? output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return null; + } + + var begin = output.LastIndexOf(EnvelopeBeginMarker, StringComparison.Ordinal); + if (begin < 0) + { + return null; + } + + var payloadStart = begin + EnvelopeBeginMarker.Length; + var end = output.IndexOf(EnvelopeEndMarker, payloadStart, StringComparison.Ordinal); + if (end < 0) + { + return null; + } + + var builder = new StringBuilder(end - payloadStart); + for (var index = payloadStart; index < end; index++) + { + if (!char.IsWhiteSpace(output[index])) + { + builder.Append(output[index]); + } + } + + if (builder.Length == 0) + { + return null; + } + + try + { + return Encoding.UTF8.GetString(Convert.FromBase64String(builder.ToString())); + } + catch (FormatException) + { + return null; + } + } + + private async Task ProbeAsync(Guid machineId, CancellationToken ct) + { + var result = await _runGuestScript(machineId, BuildProbeScript(), ct) + .ConfigureAwait(false); + if (!result.Success) + { + return new( + false, + null, + FirstLine(result.Error, "PowerShell Direct could not probe the guest for winapp.")); + } + + var envelope = ParseGuestEnvelope(result.Output); + if (envelope is null) + { + return new(false, null, UnreadableResultError); + } + + if (envelope.Status != "ok") + { + return new( + false, + null, + FirstLine(envelope.Error, "The guest winapp probe failed.")); + } + + return new( + envelope.Installed == true, + NullIfWhiteSpace(envelope.Version), + null); + } + + /// + /// Returns a verified local copy of the release archive. A cached archive whose SHA-256 + /// still matches its recorded digest is reused without touching the network, so setup keeps + /// working offline once the release has been fetched. + /// + private async Task<(string ArchivePath, bool Downloaded)> EnsureHostArchiveAsync( + CancellationToken ct) + { + await ArchiveGate.WaitAsync(ct).ConfigureAwait(false); + try + { + Directory.CreateDirectory(_cacheRoot); + var archivePath = Path.Combine(_cacheRoot, ReleaseAssetName); + var marker = await ReadMarkerAsync(ct).ConfigureAwait(false); + if (marker is not null + && File.Exists(archivePath) + && await HashMatchesAsync(archivePath, marker.Digest, ct).ConfigureAwait(false)) + { + return (archivePath, false); + } + + var release = await GetLatestReleaseAsync(ct).ConfigureAwait(false); + var downloadPath = Path.Combine( + _cacheRoot, + $".winapp-download-{Guid.NewGuid():N}.zip"); + try + { + using (var response = await _httpClient.GetAsync( + release.DownloadUri, + HttpCompletionOption.ResponseHeadersRead, + ct).ConfigureAwait(false)) + { + response.EnsureSuccessStatusCode(); + await using var source = await response.Content.ReadAsStreamAsync(ct) + .ConfigureAwait(false); + await using var destination = new FileStream( + downloadPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 1024 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await source.CopyToAsync(destination, ct).ConfigureAwait(false); + } + + if (!await HashMatchesAsync(downloadPath, release.Digest, ct).ConfigureAwait(false)) + { + throw new InvalidDataException( + "winapp download failed SHA-256 verification against the published digest."); + } + + File.Move(downloadPath, archivePath, overwrite: true); + } + finally + { + TryDeleteFile(downloadPath); + } + + await File.WriteAllTextAsync( + MarkerPath, + JsonSerializer.Serialize(new WinappReleaseMarker( + release.TagName, + release.Digest, + release.DownloadUri.AbsoluteUri)), + ct).ConfigureAwait(false); + return (archivePath, true); + } + finally + { + ArchiveGate.Release(); + } + } + + private string MarkerPath => Path.Combine(_cacheRoot, ".release.json"); + + private async Task ReadMarkerAsync(CancellationToken ct) + { + if (!File.Exists(MarkerPath)) + { + return null; + } + + try + { + await using var stream = File.OpenRead(MarkerPath); + var marker = await JsonSerializer.DeserializeAsync( + stream, + cancellationToken: ct).ConfigureAwait(false); + return IsValidDigest(marker?.Digest) ? marker : null; + } + catch (JsonException) + { + return null; + } + catch (IOException) + { + return null; + } + } + + private async Task GetLatestReleaseAsync(CancellationToken ct) + { + using var response = await _httpClient.GetAsync(LatestReleaseUri, ct) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + await using var body = await response.Content.ReadAsStreamAsync(ct) + .ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(body, cancellationToken: ct) + .ConfigureAwait(false); + var root = document.RootElement; + var tagName = root.GetProperty("tag_name").GetString(); + if (string.IsNullOrWhiteSpace(tagName)) + { + throw new InvalidDataException("The latest winapp release has no tag name."); + } + + JsonElement? selectedAsset = null; + foreach (var asset in root.GetProperty("assets").EnumerateArray()) + { + var name = asset.GetProperty("name").GetString(); + if (string.Equals(name, ReleaseAssetName, StringComparison.OrdinalIgnoreCase)) + { + selectedAsset = asset; + break; + } + } + + if (selectedAsset is not { } selected) + { + throw new InvalidDataException( + $"The latest winapp release has no {ReleaseAssetName} asset."); + } + + var downloadUrl = selected.GetProperty("browser_download_url").GetString(); + var digest = selected.TryGetProperty("digest", out var digestValue) + ? digestValue.GetString() + : null; + if (!Uri.TryCreate(downloadUrl, UriKind.Absolute, out var downloadUri)) + { + throw new InvalidDataException( + "The latest winapp release asset is missing its download URL."); + } + + if (!IsValidDigest(digest)) + { + throw new InvalidDataException( + "The latest winapp release asset has no SHA-256 digest."); + } + + return new WinappRelease(tagName, downloadUri, digest); + } + + private static bool IsValidDigest([NotNullWhen(true)] string? digest) => + digest is not null + && digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) + && digest.Length == "sha256:".Length + 64; + + private static async Task HashMatchesAsync( + string filePath, + string digest, + CancellationToken ct) + { + if (!IsValidDigest(digest)) + { + return false; + } + + await using var stream = new FileStream( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 1024 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + var actualHash = Convert.ToHexString( + await SHA256.HashDataAsync(stream, ct).ConfigureAwait(false)); + var expectedHash = digest["sha256:".Length..]; + return actualHash.Equals(expectedHash, StringComparison.OrdinalIgnoreCase); + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (IOException) + { + // A verified install is usable even if Windows has not released the temp file yet. + } + catch (UnauthorizedAccessException) + { + // Leave the temporary file for a later OS cleanup instead of failing the install. + } + } + + private static GuestUiEnvelope? ParseGuestEnvelope(string? output) + { + var json = ExtractGuestEnvelopeJson(output); + if (json is null) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException) + { + return null; + } + } + + /// + /// The broker's install step: runs the host copy script (a PowerShell Direct session, + /// Copy-Item -ToSession, and the guest-side extract) and maps its envelope. Lives + /// here beside the script builder so the broker and this service can never drift; the + /// caller supplies an already-resolved guest credential. + /// + internal static async Task RunHostCopyAsync( + Guid machineId, + string userName, + string password, + string archivePath, + CancellationToken ct) + { + var script = BuildHostCopyScript(machineId, userName, password, archivePath); + var result = await RunHostPowerShellAsync(script, ct).ConfigureAwait(false); + var envelope = ParseHostCopyEnvelope(result.StandardOutput); + if (envelope?.Success == true) + { + return new(true, envelope.ExePath, null); + } + + return new( + false, + null, + FirstLine( + envelope?.Error ?? FirstHostError(result), + "winapp could not be copied into the guest.")); + } + + /// + /// The broker's general file-copy step (hyperv_copy): one PowerShell Direct session, + /// Copy-Item -ToSession/-FromSession in either direction, with the destination + /// directory pre-created (Copy-Item has no -CreateFullPath). Returns the copied byte + /// count in the envelope so the tool can report it. The caller supplies an + /// already-resolved guest credential; both paths are caller-supplied (unlike the fixed + /// install path) and validated for shape at the protocol boundary. + /// + internal static async Task RunHostFileCopyAsync( + Guid machineId, + string userName, + string password, + string source, + string destination, + bool fromGuest, + CancellationToken ct) + { + var script = BuildHostFileCopyScript(machineId, userName, password, source, destination, fromGuest); + var result = await RunHostPowerShellAsync(script, ct).ConfigureAwait(false); + var envelope = ParseHostCopyEnvelope(result.StandardOutput); + if (envelope?.Success == true) + { + return new(true, envelope.ExePath, null); + } + + return new( + false, + null, + FirstLine( + envelope?.Error ?? FirstHostError(result), + "The file could not be copied.")); + } + + /// + /// Host-side copy script: one PowerShell Direct session with the guest credential, a + /// direction-appropriate Copy-Item, and a size probe of the SOURCE so the tool can + /// report bytes and a directive fires when the source is missing. The exePath + /// envelope field carries the byte count (reused rather than widening the shared + /// envelope). All values are base64-embedded so quoting can never break the script. + /// + internal static string BuildHostFileCopyScript( + Guid machineId, + string userName, + string password, + string source, + string destination, + bool fromGuest) + { + var encodedUser = Convert.ToBase64String(Encoding.UTF8.GetBytes(userName)); + var encodedPassword = Convert.ToBase64String(Encoding.UTF8.GetBytes(password)); + var encodedSource = Convert.ToBase64String(Encoding.UTF8.GetBytes(source)); + var encodedDestination = Convert.ToBase64String(Encoding.UTF8.GetBytes(destination)); + + // The two directions differ only in which side owns each path: for to_guest the + // source is probed and the destination directory created HOST-side (inline .NET), + // and Copy-Item pushes with -ToSession; for from_guest both are done GUEST-side via + // Invoke-Command and Copy-Item pulls with -FromSession. + var probeAndPrepare = fromGuest + ? """ + $meta = Invoke-Command -Session $session -ErrorAction Stop -ScriptBlock { + param($src) + $ErrorActionPreference = 'Stop' + $item = Get-Item -LiteralPath $src -ErrorAction Stop + if ($item.PSIsContainer) { $bytes = (Get-ChildItem -LiteralPath $src -Recurse -File | Measure-Object -Property Length -Sum).Sum } + else { $bytes = $item.Length } + [pscustomobject]@{ IsDir = [bool]$item.PSIsContainer; Bytes = [int64]$bytes } + } -ArgumentList $sourcePath + $destDir = Split-Path -Parent $destinationPath + if ($destDir) { New-Item -ItemType Directory -Force -Path $destDir | Out-Null } + """ + : """ + $item = Get-Item -LiteralPath $sourcePath -ErrorAction Stop + if ($item.PSIsContainer) { $bytes = (Get-ChildItem -LiteralPath $sourcePath -Recurse -File | Measure-Object -Property Length -Sum).Sum } + else { $bytes = $item.Length } + $meta = [pscustomobject]@{ IsDir = [bool]$item.PSIsContainer; Bytes = [int64]$bytes } + Invoke-Command -Session $session -ErrorAction Stop -ScriptBlock { + param($dest) + $ErrorActionPreference = 'Stop' + $destDir = Split-Path -Parent $dest + if ($destDir) { New-Item -ItemType Directory -Force -Path $destDir | Out-Null } + } -ArgumentList $destinationPath + """; + + var copyLine = fromGuest + ? "Copy-Item -LiteralPath $sourcePath -Destination $destinationPath -FromSession $session -Force -ErrorAction Stop @recurse" + : "Copy-Item -LiteralPath $sourcePath -Destination $destinationPath -ToSession $session -Force -ErrorAction Stop @recurse"; + + return $$""" + [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) + $ErrorActionPreference = 'Stop' + $ProgressPreference = 'SilentlyContinue' + $WarningPreference = 'SilentlyContinue' + $env:WINAPP_CLI_TELEMETRY_OPTOUT = '1' + function Decode([string]$Value) { + [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value)) + } + try { + $userName = Decode '{{encodedUser}}' + $password = ConvertTo-SecureString (Decode '{{encodedPassword}}') -AsPlainText -Force + $credential = [System.Management.Automation.PSCredential]::new($userName, $password) + $sourcePath = Decode '{{encodedSource}}' + $destinationPath = Decode '{{encodedDestination}}' + $session = New-PSSession -VMId ([Guid]'{{machineId:D}}') -Credential $credential -ErrorAction Stop + try { + {{probeAndPrepare}} + $recurse = @{} + if ($meta.IsDir) { $recurse['Recurse'] = $true } + {{copyLine}} + @{ success = $true; exePath = [string]$meta.Bytes } | ConvertTo-Json -Compress + } finally { + Remove-PSSession -Session $session -ErrorAction SilentlyContinue + } + } catch { + @{ success = $false; error = $_.Exception.Message } | ConvertTo-Json -Compress + } + """; + } + + private static HostCopyEnvelope? ParseHostCopyEnvelope(string output) + { + var json = output.Trim(); + if (json.Length == 0) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException) + { + return null; + } + } + + private static string FirstHostError(HyperVService.PowerShellResult result) + { + var error = result.StandardError + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .LastOrDefault(); + return error ?? $"Windows PowerShell exited with code {result.ExitCode}."; + } + + private static async Task RunHostPowerShellAsync( + string script, + CancellationToken ct) + { + if (!OperatingSystem.IsWindows()) + { + return new(-1, string.Empty, "Hyper-V is only available on Windows."); + } + + var executable = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + var startInfo = new ProcessStartInfo + { + FileName = executable, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + startInfo.ArgumentList.Add("-NoLogo"); + startInfo.ArgumentList.Add("-NoProfile"); + startInfo.ArgumentList.Add("-NonInteractive"); + startInfo.ArgumentList.Add("-EncodedCommand"); + startInfo.ArgumentList.Add(EncodePowerShellCommand(script)); + + using var process = new Process { StartInfo = startInfo }; + try + { + if (!process.Start()) + { + return new(-1, string.Empty, "Could not start Windows PowerShell."); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(ct); + var stderrTask = process.StandardError.ReadToEndAsync(ct); + try + { + await process.WaitForExitAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + } + + throw; + } + + return new( + process.ExitCode, + await stdoutTask.ConfigureAwait(false), + await stderrTask.ConfigureAwait(false)); + } + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException) + { + return new(-1, string.Empty, ex.Message); + } + } + + private static void ValidateMachineId(Guid machineId) + { + if (machineId == Guid.Empty) + { + throw new ArgumentException("A Hyper-V machine ID is required.", nameof(machineId)); + } + } + + private static string? NullIfWhiteSpace(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string FirstLine(string? value, string fallback) => + string.IsNullOrWhiteSpace(value) + ? fallback + : value.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)[0].Trim(); + + private static HttpClient CreateHttpClient() + { + // Generous for a release zip on a slow line, but finite: an infinite timeout let one + // hung GitHub download stall the tool call until the user gave up and stopped the turn. + var client = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + client.DefaultRequestHeaders.UserAgent.ParseAdd("HyperVControl/0.1"); + client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json"); + return client; + } + + private readonly record struct ProbeResult(bool Installed, string? Version, string? Error); + + private sealed record WinappRelease(string TagName, Uri DownloadUri, string Digest); + + private sealed record WinappReleaseMarker(string TagName, string Digest, string DownloadUrl); + + private sealed class GuestUiEnvelope + { + public string? Status { get; init; } + + public int? ExitCode { get; init; } + + public string? Stdout { get; init; } + + public string? Stderr { get; init; } + + public string? Error { get; init; } + + public bool? Installed { get; init; } + + public string? Version { get; init; } + } + + private sealed class HostCopyEnvelope + { + public bool Success { get; init; } + + public string? ExePath { get; init; } + + public string? Error { get; init; } + } +} + +/// One stored Windows guest credential handed to the setup step's file copy. +/// Result of ensuring winapp is installed inside a guest. +public sealed record HyperVGuestUiSetupResult( + bool Success, + bool Installed, + string? Version, + bool Downloaded, + string? Error); + +/// Output of one winapp ui command executed in the guest's interactive session. +public sealed record HyperVGuestUiRunResult( + bool Success, + int? ExitCode, + string? Output, + string? ErrorOutput, + string? Error); diff --git a/src/HyperVControl/Extracted/HyperVModels.cs b/src/HyperVControl/Extracted/HyperVModels.cs new file mode 100644 index 0000000..d938604 --- /dev/null +++ b/src/HyperVControl/Extracted/HyperVModels.cs @@ -0,0 +1,164 @@ +// Extracted and adapted from RaivenX b5971dda1fa2050dde36b8b22fda9726df92cb48. See THIRD-PARTY-NOTICES.md. +namespace HyperVControl.Extracted; + +/// Whether the current Windows account can manage the local Hyper-V host. +public enum HyperVCapabilityState +{ + Available, + NotInstalled, + AccessDenied, + Unavailable, +} + +/// +/// Host capability plus a user-facing explanation when it is unavailable, or, when it is +/// available, a note about machines Hyper-V itself could not read. +/// +public sealed record HyperVCapability(HyperVCapabilityState State, string? Detail) +{ + public bool IsAvailable => State == HyperVCapabilityState.Available; +} + +/// A normalized subset of the states exposed by the Hyper-V PowerShell module. +public enum HyperVMachineState +{ + Unknown, + Off, + Running, + Paused, + Saved, + Starting, + Stopping, + Saving, + Pausing, + Resuming, + Reset, + FastSaved, + FastSaving, + Critical, +} + +/// Read-only inventory data for one local Hyper-V virtual machine. +public sealed record HyperVMachine( + Guid Id, + string Name, + HyperVMachineState State, + int? Generation, + long? MemoryAssignedBytes, + int? ProcessorCount, + TimeSpan? Uptime, + string? OperatingSystem, + string? Version); + +/// A point-in-time view of the local Hyper-V host. +public sealed record HyperVInventory( + string HostName, + HyperVCapability Capability, + IReadOnlyList Machines); + +/// Explicit lifecycle transitions supported for a local Hyper-V virtual machine. +public enum HyperVPowerAction +{ + Start, + Shutdown, + TurnOff, + Restart, + Suspend, + Resume, + Save, +} + +/// Result of one requested power transition. +public sealed record HyperVPowerOperationResult( + bool Success, + HyperVCapability Capability, + HyperVMachine? Machine, + string? Error); + +/// Result of changing one Generation 2 virtual machine's firmware. +public sealed record HyperVFirmwareOperationResult( + bool Success, + HyperVCapability Capability, + HyperVMachine? Machine, + bool? SecureBootEnabled, + string? Error); + +/// Read-only metadata for one Hyper-V checkpoint. +public sealed record HyperVCheckpoint( + Guid Id, + Guid MachineId, + string Name, + DateTimeOffset CreationTime, + Guid? ParentCheckpointId, + string? Type); + +/// Result of listing the checkpoints that belong to one virtual machine. +public sealed record HyperVCheckpointListResult( + bool Success, + HyperVCapability Capability, + IReadOnlyList Checkpoints, + string? Error, + Guid? CurrentParentCheckpointId = null); + +/// Result of creating, restoring, or deleting one virtual machine checkpoint. +public sealed record HyperVCheckpointOperationResult( + bool Success, + HyperVCapability Capability, + HyperVCheckpoint? Checkpoint, + string? Error); + +/// Result of attaching an elevated Basic VM Console to an app-owned host window. +/// +/// The session mode the console ACTUALLY connected with. The app requests a mode, but the +/// broker silently falls back to Basic when Enhanced Session is unavailable — and the two +/// modes have different capture semantics (Basic reads the guest framebuffer headlessly; +/// Enhanced copies real screen pixels and needs the console visible), so the app must know +/// which one it got. +/// +/// The session type the console actually connected with. +/// +/// What a successful open still wants said — an Enhanced Session that fell back to Basic +/// names why here, so the agent and the user learn it without a failure. +/// +public sealed record HyperVConsoleOperationResult( + bool Success, + string? Error, + bool IsEnhanced = false, + string? Note = null); + +/// One operation performed against the console currently attached to a chat. +public enum HyperVConsoleAction +{ + Capture, + Click, + Type, + Key, + Scroll, + SignIn, + Move, + ButtonDown, + ButtonUp, + ClipboardRead, + ClipboardWrite, + Show, + Resize, +} + +/// +/// Result returned by an elevated console operation. Capture is the only action that +/// populates the PNG, dimensions, and session mode. +/// +public sealed record HyperVConsoleCommandResult( + bool Success, + string? Error, + byte[]? Png = null, + int? Width = null, + int? Height = null, + bool IsEnhanced = false, + string? Text = null); + +/// Output from one PowerShell Direct command executed inside a Windows guest. +public sealed record HyperVGuestCommandResult( + bool Success, + string? Output, + string? Error); diff --git a/src/HyperVControl/Extracted/HyperVService.cs b/src/HyperVControl/Extracted/HyperVService.cs new file mode 100644 index 0000000..48bfcb1 --- /dev/null +++ b/src/HyperVControl/Extracted/HyperVService.cs @@ -0,0 +1,900 @@ +// Extracted and adapted from RaivenX b5971dda1fa2050dde36b8b22fda9726df92cb48. See THIRD-PARTY-NOTICES.md. +using System.ComponentModel; +using System.Diagnostics; +using System.Text; +using System.Text.Json; + +namespace HyperVControl.Extracted; + +/// +/// Enumerates and controls virtual machines on the local Hyper-V host through the Microsoft +/// Hyper-V PowerShell module. The service never requests elevation; callers receive an explicit +/// capability state when the current account is not authorized. +/// +public sealed class HyperVService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private const string ScriptPreamble = + """ + [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) + $ErrorActionPreference = 'Stop' + $ProgressPreference = 'SilentlyContinue' + $WarningPreference = 'SilentlyContinue' + $PSModuleAutoLoadingPreference = 'None' + + $systemModuleRoot = [System.IO.Path]::Combine($PSHOME, 'Modules') + $env:PSModulePath = $systemModuleRoot + Import-Module -Name ([System.IO.Path]::Combine( + $systemModuleRoot, + 'Microsoft.PowerShell.Management', + 'Microsoft.PowerShell.Management.psd1')) -ErrorAction Stop + Import-Module -Name ([System.IO.Path]::Combine( + $systemModuleRoot, + 'Microsoft.PowerShell.Utility', + 'Microsoft.PowerShell.Utility.psd1')) -ErrorAction Stop + + function Write-Result( + [string]$Status, + [string]$Detail, + [object[]]$Machines, + [string]$OperationError, + [Nullable[bool]]$SecureBootEnabled = $null) { + [pscustomobject]@{ + hostName = [Environment]::MachineName + status = $Status + detail = $Detail + operationError = $OperationError + secureBootEnabled = $SecureBootEnabled + machines = @($Machines) + } | Microsoft.PowerShell.Utility\ConvertTo-Json -Compress -Depth 6 + } + + function Write-CheckpointResult( + [string]$Status, + [string]$Detail, + [object[]]$Checkpoints, + [string]$OperationError, + [string]$CurrentParentCheckpointId = $null) { + [pscustomobject]@{ + hostName = [Environment]::MachineName + status = $Status + detail = $Detail + operationError = $OperationError + currentParentCheckpointId = $CurrentParentCheckpointId + checkpoints = @($Checkpoints) + } | Microsoft.PowerShell.Utility\ConvertTo-Json -Compress -Depth 6 + } + + function Test-HyperVAuthorization { + try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + $hyperVAdministrators = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-578') + return $principal.IsInRole($hyperVAdministrators) -or + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { + return $false + } + } + + function Write-CapabilityFailure([System.Management.Automation.ErrorRecord]$Record) { + $permissionDenied = $Record.CategoryInfo.Category -eq + [System.Management.Automation.ErrorCategory]::PermissionDenied -or + $Record.Exception -is [UnauthorizedAccessException] -or + ($Record.Exception.GetType().FullName -eq + 'Microsoft.HyperV.PowerShell.VirtualizationException' -and + -not (Test-HyperVAuthorization)) + $status = if ($permissionDenied) { 'accessDenied' } else { 'unavailable' } + Write-Result $status $Record.Exception.Message @() $null + } + + function Convert-Machine([object]$Vm) { + $uptimeTicks = $null + if ($Vm.State.ToString() -ne 'Off' -and $null -ne $Vm.Uptime) { + $uptimeTicks = [long]$Vm.Uptime.Ticks + } + + $operatingSystem = [string]$Vm.GuestOperatingSystem + if ([string]::IsNullOrWhiteSpace($operatingSystem)) { + $operatingSystem = $null + } + + $version = [string]$Vm.Version + if ([string]::IsNullOrWhiteSpace($version)) { + $version = $null + } + + [pscustomobject]@{ + id = $Vm.Id.ToString('D') + name = [string]$Vm.Name + state = $Vm.State.ToString() + generation = if ($null -eq $Vm.Generation) { $null } else { [int]$Vm.Generation } + memoryAssignedBytes = if ($null -eq $Vm.MemoryAssigned) { + $null + } else { + [long]$Vm.MemoryAssigned + } + processorCount = if ($null -eq $Vm.ProcessorCount) { + $null + } else { + [int]$Vm.ProcessorCount + } + uptimeTicks = $uptimeTicks + operatingSystem = $operatingSystem + version = $version + } + } + + function Convert-Checkpoint([object]$Checkpoint) { + $parentCheckpointId = $null + if ($null -ne $Checkpoint.ParentSnapshotId -and + [Guid]$Checkpoint.ParentSnapshotId -ne [Guid]::Empty) { + $parentCheckpointId = ([Guid]$Checkpoint.ParentSnapshotId).ToString('D') + } + + [pscustomobject]@{ + id = ([Guid]$Checkpoint.Id).ToString('D') + machineId = ([Guid]$Checkpoint.VMId).ToString('D') + name = [string]$Checkpoint.Name + creationTime = ([DateTime]$Checkpoint.CreationTime).ToUniversalTime().ToString('O') + parentCheckpointId = $parentCheckpointId + type = [string]$Checkpoint.SnapshotType + } + } + + $hyperVModuleRoot = Join-Path $systemModuleRoot 'Hyper-V' + $hyperVManifests = if ([System.IO.Directory]::Exists($hyperVModuleRoot)) { + @([System.IO.Directory]::GetFiles( + $hyperVModuleRoot, + 'Hyper-V.psd1', + [System.IO.SearchOption]::AllDirectories)) + } else { + @() + } + + if ($hyperVManifests.Count -eq 0) { + Write-Result 'notInstalled' 'The Hyper-V management tools are not installed.' @() $null + exit 0 + } + + $vmms = Microsoft.PowerShell.Management\Get-Service -Name 'vmms' -ErrorAction SilentlyContinue + if ($null -eq $vmms) { + Write-Result 'notInstalled' 'The Hyper-V Virtual Machine Management service is not installed.' @() $null + exit 0 + } + + # A stopped service fails every Hyper-V cmdlet with the same "object was not found" + # record one unreadable machine produces, so it is ruled out here, by name. + if ($vmms.Status -ne [System.ServiceProcess.ServiceControllerStatus]::Running) { + Write-Result 'unavailable' 'The Hyper-V Virtual Machine Management service is not running.' @() $null + exit 0 + } + + try { + $hyperVManifest = $hyperVManifests | + Sort-Object -Descending | + Select-Object -First 1 + Import-Module -Name $hyperVManifest -ErrorAction Stop + } catch { + Write-CapabilityFailure $_ + exit 0 + } + """; + + private readonly Func> _runPowerShell; + + public HyperVService() + : this(RunPowerShellAsync) + { + } + + internal HyperVService(Func> runPowerShell) + { + _runPowerShell = runPowerShell ?? throw new ArgumentNullException(nameof(runPowerShell)); + } + + /// Returns local VM inventory or an explicit host capability failure. + public async Task GetInventoryAsync(CancellationToken ct = default) + { + var result = await _runPowerShell(BuildInventoryScript(), ct).ConfigureAwait(false); + var envelope = ParseEnvelope(result); + return new HyperVInventory( + ResolveHostName(envelope.HostName), + CreateCapability(envelope), + ParseMachines(envelope.Machines)); + } + + /// Applies one explicit VM lifecycle transition and returns its updated state. + public async Task ChangePowerStateAsync( + Guid machineId, + HyperVPowerAction action, + CancellationToken ct = default) + { + ValidateMachineId(machineId); + + var result = await _runPowerShell(BuildPowerScript(machineId, action), ct) + .ConfigureAwait(false); + var envelope = ParseEnvelope(result); + var capability = CreateCapability(envelope); + var machine = ParseMachines(envelope.Machines).SingleOrDefault(); + var success = capability.IsAvailable + && string.IsNullOrWhiteSpace(envelope.OperationError) + && machine is not null; + return new HyperVPowerOperationResult( + success, + capability, + machine, + success ? null : envelope.OperationError ?? capability.Detail); + } + + /// + /// Enables or disables Secure Boot for an Off Generation 2 virtual machine and verifies it. + /// + public async Task SetSecureBootAsync( + Guid machineId, + bool enabled, + CancellationToken ct = default) + { + ValidateMachineId(machineId); + + var result = await _runPowerShell(BuildSecureBootScript(machineId, enabled), ct) + .ConfigureAwait(false); + var envelope = ParseEnvelope(result); + var capability = CreateCapability(envelope); + var machine = ParseMachines(envelope.Machines).SingleOrDefault(); + var success = capability.IsAvailable + && string.IsNullOrWhiteSpace(envelope.OperationError) + && machine is not null + && envelope.SecureBootEnabled == enabled; + var error = success + ? null + : envelope.OperationError + ?? capability.Detail + ?? "Hyper-V did not verify the requested Secure Boot setting."; + return new( + success, + capability, + machine, + success ? envelope.SecureBootEnabled : null, + error); + } + + /// Returns the checkpoints that belong to one virtual machine. + public async Task GetCheckpointsAsync( + Guid machineId, + CancellationToken ct = default) + { + ValidateMachineId(machineId); + + var result = await _runPowerShell(BuildCheckpointListScript(machineId), ct) + .ConfigureAwait(false); + var envelope = ParseEnvelope(result); + var capability = CreateCapability(envelope); + var checkpoints = ParseCheckpoints(envelope.Checkpoints); + var success = capability.IsAvailable + && string.IsNullOrWhiteSpace(envelope.OperationError); + Guid? currentParentCheckpointId = null; + if (Guid.TryParse(envelope.CurrentParentCheckpointId, out var parsedCurrentParent) + && parsedCurrentParent != Guid.Empty) + { + currentParentCheckpointId = parsedCurrentParent; + } + + return new( + success, + capability, + success ? checkpoints : [], + success ? null : envelope.OperationError ?? capability.Detail, + success ? currentParentCheckpointId : null); + } + + /// Creates a named checkpoint for one virtual machine. + public async Task CreateCheckpointAsync( + Guid machineId, + string name, + CancellationToken ct = default) + { + ValidateMachineId(machineId); + if (string.IsNullOrWhiteSpace(name) || name.Length > 256) + { + throw new ArgumentException( + "A checkpoint name between 1 and 256 characters is required.", + nameof(name)); + } + + return await ExecuteCheckpointOperationAsync( + BuildCreateCheckpointScript(machineId, name.Trim()), + ct).ConfigureAwait(false); + } + + /// Restores one checkpoint that belongs to the specified virtual machine. + public async Task RestoreCheckpointAsync( + Guid machineId, + Guid checkpointId, + CancellationToken ct = default) + { + ValidateMachineId(machineId); + ValidateCheckpointId(checkpointId); + return await ExecuteCheckpointOperationAsync( + BuildRestoreCheckpointScript(machineId, checkpointId), + ct).ConfigureAwait(false); + } + + /// Deletes one checkpoint that belongs to the specified virtual machine. + public async Task DeleteCheckpointAsync( + Guid machineId, + Guid checkpointId, + CancellationToken ct = default) + { + ValidateMachineId(machineId); + ValidateCheckpointId(checkpointId); + return await ExecuteCheckpointOperationAsync( + BuildDeleteCheckpointScript(machineId, checkpointId), + ct).ConfigureAwait(false); + } + + internal static string BuildInventoryScript() => + ScriptPreamble + + """ + + try { + # Get-VM reports a machine it cannot read (a corrupted or critical saved state) as a + # non-terminating error and keeps listing the rest. -ErrorAction Stop would turn + # that first record into a failure of the whole inventory, so the errors are + # collected instead and reported beside the listing. With no listing at all there + # is nothing to report them beside, so the first one is the host's failure — which + # is also how a denied token reports. + $inventoryErrors = @() + $vms = @(Hyper-V\Get-VM -ErrorAction SilentlyContinue -ErrorVariable inventoryErrors) + if ($vms.Count -eq 0 -and $inventoryErrors.Count -gt 0) { + Write-CapabilityFailure $inventoryErrors[0] + exit 0 + } + + $machines = @($vms | ForEach-Object { Convert-Machine $_ }) + $detail = $null + if ($inventoryErrors.Count -gt 0) { + $skipped = if ($inventoryErrors.Count -eq 1) { + "1 virtual machine couldn't be read and isn't listed." + } else { + "$($inventoryErrors.Count) virtual machines couldn't be read and aren't listed." + } + $detail = "$skipped $($inventoryErrors[0].Exception.Message)".Trim() + } + + Write-Result 'available' $detail $machines $null + } catch { + Write-CapabilityFailure $_ + } + """; + + internal static string BuildPowerScript(Guid machineId, HyperVPowerAction action) + { + // -Confirm:$false only suppresses ShouldProcess. Stop-VM also calls ShouldContinue, + // which the Hyper-V module gates on -Force alone. Under the broker's -NonInteractive + // host that prompt fails the operation. Start/Suspend/Resume do not define -Force. + var command = action switch + { + HyperVPowerAction.Start => "$vm | Hyper-V\\Start-VM -Confirm:$false", + HyperVPowerAction.Shutdown => "$vm | Hyper-V\\Stop-VM -Force -Confirm:$false", + HyperVPowerAction.TurnOff => "$vm | Hyper-V\\Stop-VM -TurnOff -Force -Confirm:$false", + HyperVPowerAction.Restart => + """ + $shutdownDeadline = [DateTime]::UtcNow.AddMinutes(5) + $vm | Hyper-V\Stop-VM -Force -Confirm:$false + while ($true) { + $vm = Hyper-V\Get-VM -Id $vm.Id -ErrorAction Stop + if ($vm.State.ToString() -eq 'Off') { + break + } + if ([DateTime]::UtcNow -ge $shutdownDeadline) { + throw 'The virtual machine did not shut down within five minutes.' + } + Microsoft.PowerShell.Utility\Start-Sleep -Milliseconds 500 + } + + $vm | Hyper-V\Start-VM -Confirm:$false + $startDeadline = [DateTime]::UtcNow.AddMinutes(1) + while ($true) { + $vm = Hyper-V\Get-VM -Id $vm.Id -ErrorAction Stop + if ($vm.State.ToString() -eq 'Running') { + break + } + if ([DateTime]::UtcNow -ge $startDeadline) { + throw 'The virtual machine did not start within one minute.' + } + Microsoft.PowerShell.Utility\Start-Sleep -Milliseconds 500 + } + """, + HyperVPowerAction.Suspend => "$vm | Hyper-V\\Suspend-VM -Confirm:$false", + HyperVPowerAction.Resume => "$vm | Hyper-V\\Resume-VM -Confirm:$false", + HyperVPowerAction.Save => "$vm | Hyper-V\\Stop-VM -Save -Force -Confirm:$false", + _ => throw new ArgumentOutOfRangeException(nameof(action), action, null), + }; + + return ScriptPreamble + + $$""" + + try { + $vm = Hyper-V\Get-VM -Id '{{machineId:D}}' -ErrorAction Stop + } catch { + Write-CapabilityFailure $_ + exit 0 + } + + try { + {{command}} + $updated = Hyper-V\Get-VM -Id '{{machineId:D}}' -ErrorAction Stop + Write-Result 'available' $null @((Convert-Machine $updated)) $null + } catch { + Write-Result 'available' $null @() $_.Exception.Message + } + """; + } + + internal static string BuildSecureBootScript(Guid machineId, bool enabled) + { + var setting = enabled ? "On" : "Off"; + var expected = enabled ? "$true" : "$false"; + return ScriptPreamble + + $$""" + + try { + $vm = Hyper-V\Get-VM -Id '{{machineId:D}}' -ErrorAction Stop + } catch { + Write-CapabilityFailure $_ + exit 0 + } + + try { + if ([int]$vm.Generation -ne 2) { + throw 'Secure Boot is supported only for Generation 2 virtual machines.' + } + if ($vm.State.ToString() -ne 'Off') { + throw 'The virtual machine must be Off before changing Secure Boot.' + } + + $vm | + Hyper-V\Set-VMFirmware ` + -EnableSecureBoot {{setting}} ` + -Confirm:$false ` + -ErrorAction Stop + $firmware = $vm | Hyper-V\Get-VMFirmware -ErrorAction Stop + $secureBootEnabled = $firmware.SecureBoot.ToString() -eq 'On' + if ($secureBootEnabled -ne {{expected}}) { + throw 'Hyper-V did not apply the requested Secure Boot setting.' + } + + $updated = Hyper-V\Get-VM -Id '{{machineId:D}}' -ErrorAction Stop + Write-Result ` + 'available' ` + $null ` + @((Convert-Machine $updated)) ` + $null ` + $secureBootEnabled + } catch { + Write-Result 'available' $null @() $_.Exception.Message + } + """; + } + + internal static string BuildCheckpointListScript(Guid machineId) => + ScriptPreamble + + $$""" + + try { + $vm = Hyper-V\Get-VM -Id '{{machineId:D}}' -ErrorAction Stop + } catch { + Write-CapabilityFailure $_ + exit 0 + } + + try { + $checkpoints = @( + $vm | + Hyper-V\Get-VMSnapshot -ErrorAction Stop | + Microsoft.PowerShell.Utility\Sort-Object -Property CreationTime -Descending | + Microsoft.PowerShell.Core\ForEach-Object { Convert-Checkpoint $_ } + ) + $currentParent = @( + Hyper-V\Get-VMSnapshot -ParentOf $vm -ErrorAction Stop + ) | Microsoft.PowerShell.Utility\Select-Object -First 1 + $currentParentCheckpointId = if ($null -eq $currentParent) { + $null + } else { + ([Guid]$currentParent.Id).ToString('D') + } + Write-CheckpointResult ` + 'available' ` + $null ` + $checkpoints ` + $null ` + $currentParentCheckpointId + } catch { + Write-CheckpointResult 'available' $null @() $_.Exception.Message + } + """; + + internal static string BuildCreateCheckpointScript(Guid machineId, string name) + { + var encodedName = Convert.ToBase64String(Encoding.UTF8.GetBytes(name)); + return ScriptPreamble + + $$""" + + try { + $vm = Hyper-V\Get-VM -Id '{{machineId:D}}' -ErrorAction Stop + } catch { + Write-CapabilityFailure $_ + exit 0 + } + + try { + $checkpointName = [Text.Encoding]::UTF8.GetString( + [Convert]::FromBase64String('{{encodedName}}')) + $checkpoint = $vm | + Hyper-V\Checkpoint-VM -SnapshotName $checkpointName -Passthru -Confirm:$false -ErrorAction Stop + Write-CheckpointResult 'available' $null @((Convert-Checkpoint $checkpoint)) $null + } catch { + Write-CheckpointResult 'available' $null @() $_.Exception.Message + } + """; + } + + internal static string BuildRestoreCheckpointScript(Guid machineId, Guid checkpointId) => + BuildExistingCheckpointScript( + machineId, + checkpointId, + "$checkpoint | Hyper-V\\Restore-VMSnapshot -Confirm:$false -ErrorAction Stop"); + + internal static string BuildDeleteCheckpointScript(Guid machineId, Guid checkpointId) => + BuildExistingCheckpointScript( + machineId, + checkpointId, + "$checkpoint | Hyper-V\\Remove-VMSnapshot -Confirm:$false -ErrorAction Stop"); + + private static string BuildExistingCheckpointScript( + Guid machineId, + Guid checkpointId, + string command) => + ScriptPreamble + + $$""" + + try { + $vm = Hyper-V\Get-VM -Id '{{machineId:D}}' -ErrorAction Stop + } catch { + Write-CapabilityFailure $_ + exit 0 + } + + try { + $checkpoint = @( + $vm | + Hyper-V\Get-VMSnapshot -ErrorAction Stop | + Microsoft.PowerShell.Core\Where-Object { + $_.Id -eq [Guid]'{{checkpointId:D}}' + } + ) | Microsoft.PowerShell.Utility\Select-Object -First 1 + if ($null -eq $checkpoint) { + throw [InvalidOperationException]::new( + 'The checkpoint was not found for this virtual machine.') + } + + $checkpointData = Convert-Checkpoint $checkpoint + {{command}} + Write-CheckpointResult 'available' $null @($checkpointData) $null + } catch { + Write-CheckpointResult 'available' $null @() $_.Exception.Message + } + """; + + private async Task ExecuteCheckpointOperationAsync( + string script, + CancellationToken ct) + { + var result = await _runPowerShell(script, ct).ConfigureAwait(false); + var envelope = ParseEnvelope(result); + var capability = CreateCapability(envelope); + var checkpoint = ParseCheckpoints(envelope.Checkpoints).SingleOrDefault(); + var success = capability.IsAvailable + && string.IsNullOrWhiteSpace(envelope.OperationError) + && checkpoint is not null; + return new( + success, + capability, + checkpoint, + success ? null : envelope.OperationError ?? capability.Detail); + } + + private static void ValidateMachineId(Guid machineId) + { + if (machineId == Guid.Empty) + { + throw new ArgumentException("A Hyper-V machine ID is required.", nameof(machineId)); + } + } + + private static void ValidateCheckpointId(Guid checkpointId) + { + if (checkpointId == Guid.Empty) + { + throw new ArgumentException( + "A Hyper-V checkpoint ID is required.", + nameof(checkpointId)); + } + } + + private static HyperVEnvelope ParseEnvelope(PowerShellResult result) + { + var json = result.StandardOutput.Trim(); + if (json.Length == 0) + { + return UnavailableEnvelope(FirstError(result)); + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions) + ?? UnavailableEnvelope("Hyper-V returned an empty response."); + } + catch (JsonException) + { + return UnavailableEnvelope( + result.ExitCode == 0 + ? "Hyper-V returned an invalid response." + : FirstError(result)); + } + } + + private static HyperVEnvelope UnavailableEnvelope(string detail) => new() + { + HostName = Environment.MachineName, + Status = "unavailable", + Detail = detail, + Machines = [], + }; + + private static string FirstError(PowerShellResult result) + { + var error = result.StandardError + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .LastOrDefault(); + return error ?? $"Windows PowerShell exited with code {result.ExitCode}."; + } + + private static string ResolveHostName(string? hostName) => + string.IsNullOrWhiteSpace(hostName) ? Environment.MachineName : hostName; + + private static HyperVCapability CreateCapability(HyperVEnvelope envelope) + { + var state = envelope.Status?.Trim().ToLowerInvariant() switch + { + "available" => HyperVCapabilityState.Available, + "notinstalled" => HyperVCapabilityState.NotInstalled, + "accessdenied" => HyperVCapabilityState.AccessDenied, + _ => HyperVCapabilityState.Unavailable, + }; + return new HyperVCapability(state, NullIfWhiteSpace(envelope.Detail)); + } + + private static IReadOnlyList ParseMachines(IReadOnlyList? rows) + { + if (rows is null || rows.Count == 0) + { + return []; + } + + var machines = new List(rows.Count); + foreach (var row in rows) + { + if (!Guid.TryParse(row.Id, out var id) || string.IsNullOrWhiteSpace(row.Name)) + { + continue; + } + + machines.Add(new HyperVMachine( + id, + row.Name.Trim(), + ParseState(row.State), + row.Generation is > 0 ? row.Generation : null, + row.MemoryAssignedBytes is >= 0 ? row.MemoryAssignedBytes : null, + row.ProcessorCount is > 0 ? row.ProcessorCount : null, + row.UptimeTicks is >= 0 ? TimeSpan.FromTicks(row.UptimeTicks.Value) : null, + NullIfWhiteSpace(row.OperatingSystem), + NullIfWhiteSpace(row.Version))); + } + + machines.Sort(static (left, right) => + StringComparer.OrdinalIgnoreCase.Compare(left.Name, right.Name)); + return machines; + } + + private static IReadOnlyList ParseCheckpoints( + IReadOnlyList? rows) + { + if (rows is null || rows.Count == 0) + { + return []; + } + + var checkpoints = new List(rows.Count); + foreach (var row in rows) + { + if (!Guid.TryParse(row.Id, out var id) + || !Guid.TryParse(row.MachineId, out var machineId) + || string.IsNullOrWhiteSpace(row.Name) + || !DateTimeOffset.TryParse( + row.CreationTime, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal + | System.Globalization.DateTimeStyles.AdjustToUniversal, + out var creationTime)) + { + continue; + } + + Guid? parentCheckpointId = null; + if (Guid.TryParse(row.ParentCheckpointId, out var parsedParent) + && parsedParent != Guid.Empty) + { + parentCheckpointId = parsedParent; + } + + checkpoints.Add(new( + id, + machineId, + row.Name.Trim(), + creationTime, + parentCheckpointId, + NullIfWhiteSpace(row.Type))); + } + + checkpoints.Sort(static (left, right) => + right.CreationTime.CompareTo(left.CreationTime)); + return checkpoints; + } + + private static HyperVMachineState ParseState(string? state) + { + if (state?.EndsWith("Critical", StringComparison.OrdinalIgnoreCase) == true) + { + return HyperVMachineState.Critical; + } + + return Enum.TryParse(state, ignoreCase: true, out var parsed) + ? parsed + : HyperVMachineState.Unknown; + } + + private static string? NullIfWhiteSpace(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static async Task RunPowerShellAsync( + string script, + CancellationToken ct) + { + if (!OperatingSystem.IsWindows()) + { + return new PowerShellResult(-1, string.Empty, "Hyper-V is only available on Windows."); + } + + var executable = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + var encodedCommand = Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); + var startInfo = new ProcessStartInfo + { + FileName = executable, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + startInfo.ArgumentList.Add("-NoLogo"); + startInfo.ArgumentList.Add("-NoProfile"); + startInfo.ArgumentList.Add("-NonInteractive"); + startInfo.ArgumentList.Add("-EncodedCommand"); + startInfo.ArgumentList.Add(encodedCommand); + + using var process = new Process { StartInfo = startInfo }; + try + { + if (!process.Start()) + { + return new PowerShellResult(-1, string.Empty, "Could not start Windows PowerShell."); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(ct); + var stderrTask = process.StandardError.ReadToEndAsync(ct); + try + { + await process.WaitForExitAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + } + + throw; + } + + return new PowerShellResult( + process.ExitCode, + await stdoutTask.ConfigureAwait(false), + await stderrTask.ConfigureAwait(false)); + } + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException) + { + return new PowerShellResult(-1, string.Empty, ex.Message); + } + } + + internal sealed record PowerShellResult(int ExitCode, string StandardOutput, string StandardError); + + private sealed class HyperVEnvelope + { + public string? HostName { get; init; } + + public string? Status { get; init; } + + public string? Detail { get; init; } + + public string? OperationError { get; init; } + + public string? CurrentParentCheckpointId { get; init; } + + public bool? SecureBootEnabled { get; init; } + + public IReadOnlyList? Machines { get; init; } + + public IReadOnlyList? Checkpoints { get; init; } + } + + private sealed class HyperVMachineDto + { + public string? Id { get; init; } + + public string? Name { get; init; } + + public string? State { get; init; } + + public int? Generation { get; init; } + + public long? MemoryAssignedBytes { get; init; } + + public int? ProcessorCount { get; init; } + + public long? UptimeTicks { get; init; } + + public string? OperatingSystem { get; init; } + + public string? Version { get; init; } + } + + private sealed class HyperVCheckpointDto + { + public string? Id { get; init; } + + public string? MachineId { get; init; } + + public string? Name { get; init; } + + public string? CreationTime { get; init; } + + public string? ParentCheckpointId { get; init; } + + public string? Type { get; init; } + } +} diff --git a/src/HyperVControl/Extracted/Rgb565Pixels.cs b/src/HyperVControl/Extracted/Rgb565Pixels.cs new file mode 100644 index 0000000..603d50a --- /dev/null +++ b/src/HyperVControl/Extracted/Rgb565Pixels.cs @@ -0,0 +1,52 @@ +// Extracted and adapted from RaivenX b5971dda1fa2050dde36b8b22fda9726df92cb48. See THIRD-PARTY-NOTICES.md. +namespace HyperVControl.Extracted; + +/// +/// Converts the raw RGB565 framebuffer that Hyper-V's GetVirtualSystemThumbnailImage +/// returns into 32-bit BGRA pixels ready for a Format32bppArgb bitmap. +/// +/// +/// Lives here rather than in the broker so the bit math is unit-testable (this assembly is +/// InternalsVisibleTo the test project) and dependency-free — the broker owns the +/// System.Drawing PNG encode. Expansion replicates the high bits into the low ones +/// (5→8: v<<3 | v>>2, 6→8: v<<2 | v>>4) so full white +/// stays 255 rather than 248. +/// +internal static class Rgb565Pixels +{ + /// BGRA byte order matches GDI+'s Format32bppArgb little-endian memory layout. + internal static byte[] ToBgra32(ReadOnlySpan rgb565, int width, int height) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + var pixels = width * height; + + // Hyper-V returns a few surplus bytes past the pixel data (a constant +4 observed + // on Windows 11 26200 at every resolution request). The image starts at offset 0, + // so surplus is a trailer to ignore, not a malformed frame; only missing pixel + // data is unrenderable. + if (rgb565.Length < pixels * 2) + { + throw new ArgumentException( + $"Expected at least {pixels * 2} bytes of RGB565 data for {width}×{height}, got {rgb565.Length}.", + nameof(rgb565)); + } + + var bgra = new byte[pixels * 4]; + for (var i = 0; i < pixels; i++) + { + // Little-endian ushort per pixel: rrrrrggg gggbbbbb. + var value = (ushort)(rgb565[i * 2] | (rgb565[(i * 2) + 1] << 8)); + var red = (value >> 11) & 0x1F; + var green = (value >> 5) & 0x3F; + var blue = value & 0x1F; + + bgra[i * 4] = (byte)((blue << 3) | (blue >> 2)); + bgra[(i * 4) + 1] = (byte)((green << 2) | (green >> 4)); + bgra[(i * 4) + 2] = (byte)((red << 3) | (red >> 2)); + bgra[(i * 4) + 3] = 0xFF; + } + + return bgra; + } +} diff --git a/src/HyperVControl/HyperVControl.csproj b/src/HyperVControl/HyperVControl.csproj new file mode 100644 index 0000000..06fc4c6 --- /dev/null +++ b/src/HyperVControl/HyperVControl.csproj @@ -0,0 +1,33 @@ + + + Exe + net10.0-windows + true + enable + enable + HyperVControl + 0.1.0 + win-x64 + true + true + true + false + app.manifest + true + + 10.0.12 + false + false + false + $(MSBuildProjectDirectory)=/_/src/HyperVControl + + + + + + + + + + diff --git a/src/HyperVControl/HyperVTools.Debug.cs b/src/HyperVControl/HyperVTools.Debug.cs new file mode 100644 index 0000000..d533460 --- /dev/null +++ b/src/HyperVControl/HyperVTools.Debug.cs @@ -0,0 +1,107 @@ +using System.ComponentModel; +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; +using HyperVControl.Services; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using static HyperVControl.Services.ControlService; + +namespace HyperVControl; + +internal sealed partial class HyperVTools +{ + [McpServerTool(Name = "hyperv_debugger_discover", ReadOnly = true)] + [Description("Discover installed KD/CDB, preferring current WinDbg MSIX over legacy Windows Kits. Returns exact paths, origins and versions. Explicit override: HYPERV_CONTROL_DEBUGGER_DIR.")] + public Task DebuggerDiscover(CancellationToken ct) => Run(async () => await service.Debugger.DiscoverAsync(ct)); + + internal static string CreateKdKey() + { + static string Base36(ulong value) + { + const string digits = "0123456789abcdefghijklmnopqrstuvwxyz"; + var text = ""; + do { text = digits[(int)(value % 36)] + text; value /= 36; } while (value != 0); + return text; + } + return string.Join('.', Enumerable.Range(0, 4).Select(_ => Base36(BitConverter.ToUInt64(RandomNumberGenerator.GetBytes(8))))); + } + + [McpServerTool(Name = "hyperv_kernel_configure", Destructive = true)] + [Description("Configure guest kernel debugging. action=kdnet requires running guest+hostIp. KDCOM is TWO phases: serial_host while Off maps COM pipe; serial_guest while Running changes guest BCD. status reads BCD; disable turns debugging off. Checks native exit codes; never silently disables Secure Boot/BitLocker. reboot is opt-in.")] + public Task KernelConfigure(string machine, string action, string? hostIp = null, int port = 50000, + string? key = null, string? pipeName = null, int comPort = 1, bool reboot = false, string credentialProfile = "default", CancellationToken ct = default) => Run(async () => + { + var vm = await service.ResolveAsync(machine, ct); + if (comPort is not (1 or 2)) throw new ArgumentException("comPort must be 1 or 2."); + pipeName ??= "HyperVControl-" + vm.Id.ToString("N"); + if (!Regex.IsMatch(pipeName, "^[A-Za-z0-9_.-]{1,100}$")) throw new ArgumentException("pipeName must be a simple pipe name (no path)."); + var pipePath = @"\\.\pipe\" + pipeName; + var serialAttach = $"com:pipe,port={pipePath},resets=0,reconnect"; + if (action == "serial_host") + { + var result = await service.PowerShell.JsonAsync(Vm(vm.Id) + RequireOff + $"Set-VMComPort -VM $vm -Number {comPort} -Path {L(pipePath)}\nGet-VMComPort -VM $vm -Number {comPort} | Select-Object Name,Path | ConvertTo-Json -Compress", ct); + return new { success = true, phase = "host_serial_configured", result, kernelAttachString = serialAttach, + next = "Start the VM, wait for PowerShell Direct readiness, then call serial_guest with the same COM port. This step has not configured guest BCD." }; + } + if (vm.State != Extracted.HyperVMachineState.Running) throw new InvalidOperationException("Guest BCD configuration requires Running. Use serial_host only while Off."); + string script; + string? attach = null; + switch (action) + { + case "kdnet": + if (!IPAddress.TryParse(hostIp, out var address) || address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) + throw new ArgumentException("hostIp must be an IPv4 address reachable from the guest's vSwitch."); + if (port is < 1024 or > 65535) throw new ArgumentException("port must be 1024..65535; use a unique port per VM (typically 50000..50039)."); + key ??= CreateKdKey(); + if (!Regex.IsMatch(key, "^[0-9a-z]{1,13}(\\.[0-9a-z]{1,13}){3}$", RegexOptions.IgnoreCase)) throw new ArgumentException("KDNET key must be four base36 components."); + script = $"Invoke-Bcd @('/dbgsettings','net','hostip:{address}','port:{port}','key:{key}')\nInvoke-Bcd @('/debug','on')\n"; + attach = $"net:port={port},key={key}"; break; + case "serial_guest": + script = $"Invoke-Bcd @('/dbgsettings','serial','debugport:{comPort}','baudrate:115200')\nInvoke-Bcd @('/debug','on')\n"; + attach = serialAttach; break; + case "disable": script = "Invoke-Bcd @('/debug','off')\n"; break; + case "status": script = ""; break; + default: throw new ArgumentException("Use kdnet/serial_host/serial_guest/status/disable."); + } + script = """ + function Invoke-Bcd([string[]]$tokens) { + $o=& "$env:SystemRoot\System32\bcdedit.exe" @tokens 2>&1 + if ($LASTEXITCODE -ne 0) { throw ('bcdedit failed (exit '+$LASTEXITCODE+'): '+($o|Out-String)) } + $o + } + """ + "\n" + script + "Invoke-Bcd @('/dbgsettings')\nInvoke-Bcd @('/enum','{current}')\n"; + if (reboot && action != "status") script += "& shutdown.exe /r /t 3; if ($LASTEXITCODE -ne 0) { throw 'Guest reboot scheduling failed' }\n"; + var r = await service.Guest.InvokeAsync(vm.Id, script, service.Credentials.Resolve(vm.Id, credentialProfile), ct); + return new { success = true, machineId = vm.Id, action, kernelAttachString = attach, + rebootScheduled = reboot && action != "status", output = r.GetProperty("output"), + note = "BCD configuration is not proof of debugger connectivity. Check the adapter, host firewall, guest security prerequisites and debugger output; a reboot is needed for BCD changes." }; + }); + + [McpServerTool(Name = "hyperv_debug_open", Destructive = true)] + [Description("Open a persistent host debugger: kernel with KDNET/KDCOM connect string, dump with host dump path, attach with HOST pid, launch with HOST executable+arguments. For processes inside a VM use hyperv_guest_debug_open. Returns session ID and initial output, not a false 'attached' claim.")] + public Task DebugOpen(string mode, string? connectString = null, int? processId = null, string? path = null, + string[]? arguments = null, string? debuggerDirectory = null, string? symbolPath = null, CancellationToken ct = default) => Run(async () => + await service.Debugger.StartAsync(mode, connectString, processId, path, arguments, debuggerDirectory, ct, symbolPath)); + + [McpServerTool(Name = "hyperv_debug_sessions", ReadOnly = true)] + [Description("List persistent debugger sessions owned by this MCP process. Guest worker sessions are accessed with hyperv_guest_debug.")] + public Task DebugSessions() => Run(() => Task.FromResult(service.Debugger.List())); + + [McpServerTool(Name = "hyperv_debug_command", Destructive = true)] + [Description("Execute WinDbg/KD/CDB commands in a persistent host session: breakpoints bp/bu/bl/bc, g/t/p, stack k, registers r, memory db/dq/eb, symbols .sympath/.reload, modules lm, !analyze etc. .shell disabled. A timed-out/continuing command returns pending; poll/break before another command.")] + public Task DebugCommand(string sessionId, string command, int waitMs = 10000, CancellationToken ct = default) => Run(async () => await service.Debugger.CommandAsync(sessionId, command, waitMs, ct)); + + [McpServerTool(Name = "hyperv_debug_poll", ReadOnly = true)] + [Description("Read debugger output after a character cursor without blocking or changing target execution. Reports process exit and pending command state.")] + public Task DebugPoll(string sessionId, long cursor = 0) => Run(() => Task.FromResult(service.Debugger.Poll(sessionId, cursor))); + + [McpServerTool(Name = "hyperv_debug_break", Destructive = true)] + [Description("Interrupt target execution with Ctrl+Break sent only to this session's private hidden Windows console. Works while g/step is pending; returns the observed debugger state.")] + public Task DebugBreak(string sessionId, CancellationToken ct) => Run(async () => await service.Debugger.BreakAsync(sessionId, ct)); + + [McpServerTool(Name = "hyperv_debug_close", Destructive = true)] + [Description("Break, detach and close a host debugger session. Does not send q directly to an attached user target (which would terminate it). On detach timeout retain the session for inspection.")] + public Task DebugClose(string sessionId, CancellationToken ct) => Run(async () => await service.Debugger.CloseAsync(sessionId, ct)); +} diff --git a/src/HyperVControl/HyperVTools.Guest.cs b/src/HyperVControl/HyperVTools.Guest.cs new file mode 100644 index 0000000..cbfdd3d --- /dev/null +++ b/src/HyperVControl/HyperVTools.Guest.cs @@ -0,0 +1,142 @@ +using System.ComponentModel; +using System.Text.Json; +using HyperVControl.Extracted; +using HyperVControl.Services; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using static HyperVControl.Services.ControlService; + +namespace HyperVControl; + +internal sealed partial class HyperVTools +{ + [McpServerTool(Name = "hyperv_credential", Destructive = true)] + [Description("Validate and store a VM guest credential in Windows Credential Manager, or remove one. Profiles separate admin and standard-user accounts; no automatic UAC policy changes. Credentials are never returned.")] + public Task Credential(string machine, string action = "set", string profile = "default", string? username = null, string? password = null, CancellationToken ct = default) => Run(async () => + { + var id = (await service.ResolveAsync(machine, ct)).Id; + CredentialStore.ValidateProfile(profile); + if (action == "remove") service.Credentials.Remove(id, profile); + else if (action == "set" && username != null && password != null) + { + await service.Guest.InvokeAsync(id, "'credential verified'", new(username, password), ct); + service.Credentials.Save(id, profile, username, password); + } + else throw new ArgumentException("Use set with username/password, or remove."); + return new { success = true, machineId = id, profile, action }; + }); + + [McpServerTool(Name = "hyperv_guest_run", Destructive = true)] + [Description("Execute PowerShell in a running Windows guest via PowerShell Direct; no network or console needed. Returns separate stdout/stderr, exitCode, timeout and truncation. Runs with the selected guest account's token, never silently disables UAC.")] + public Task GuestRun(string machine, string script, string credentialProfile = "default", int timeoutSeconds = 60, CancellationToken ct = default) => Run(async () => + await service.Guest.RunAsync((await service.ResolveAsync(machine, ct)).Id, script, credentialProfile, timeoutSeconds, ct)); + + [McpServerTool(Name = "hyperv_guest_process", Destructive = true)] + [Description("Execute a guest executable with a true argument array and optional working directory, using Windows command-line escaping. For unprivileged tests select a credential profile containing a standard account.")] + public Task GuestProcess(string machine, string executable, string[]? arguments = null, string? workingDirectory = null, string credentialProfile = "default", int timeoutSeconds = 60, CancellationToken ct = default) => Run(async () => + { + FullPath(executable); + var script = "$psi=[Diagnostics.ProcessStartInfo]::new(" + L(executable) + ")\n" + + "$psi.Arguments=" + L(HyperVGuestUiService.BuildWindowsArgumentLine(arguments ?? [])) + "\n" + + "$psi.UseShellExecute=$false; $psi.CreateNoWindow=$true\n" + + "$psi.RedirectStandardOutput=$true; $psi.RedirectStandardError=$true\n" + + "$psi.StandardOutputEncoding=[Text.Encoding]::UTF8; $psi.StandardErrorEncoding=[Text.Encoding]::UTF8\n" + + (workingDirectory == null ? "" : "$psi.WorkingDirectory=" + L(FullPath(workingDirectory)) + "\n") + + "$p=[Diagnostics.Process]::Start($psi); try {$stdout=$p.StandardOutput.ReadToEndAsync(); $stderr=$p.StandardError.ReadToEndAsync(); $p.WaitForExit(); [Console]::Out.Write($stdout.Result); [Console]::Error.Write($stderr.Result); exit $p.ExitCode} finally {$p.Dispose()}"; + return await service.Guest.RunAsync((await service.ResolveAsync(machine, ct)).Id, script, credentialProfile, timeoutSeconds, ct); + }); + + [McpServerTool(Name = "hyperv_copy", Destructive = true)] + [Description("Copy a file or folder host↔guest with PowerShell Direct. direction is to_guest or from_guest; supports recursive folders and Unicode/space paths. Source and destination must be absolute.")] + public Task Copy(string machine, string direction, string source, string destination, string credentialProfile = "default", CancellationToken ct = default) => Run(async () => + { + if (direction is not ("to_guest" or "from_guest")) throw new ArgumentException("Use to_guest/from_guest."); + return await service.Guest.CopyAsync((await service.ResolveAsync(machine, ct)).Id, source, destination, direction == "from_guest", credentialProfile, ct); + }); + + [McpServerTool(Name = "hyperv_guest_files", ReadOnly = true)] + [Description("List a guest directory or read at most 1 MiB as base64. Use hyperv_copy for large files. Includes explicit truncation.")] + public Task GuestFiles(string machine, string path, string action = "list", int maxBytes = 1048576, string credentialProfile = "default", CancellationToken ct = default) => Run(async () => + { + FullPath(path); + if (maxBytes is < 1 or > 1048576) throw new ArgumentException("maxBytes must be 1..1048576."); + var script = action switch + { + "list" => $"ConvertTo-Json -InputObject @(Get-ChildItem -LiteralPath {L(path)} -Force | Select-Object Name,FullName,Length,PSIsContainer,LastWriteTimeUtc) -Compress", + "read" => $"$f=[IO.File]::OpenRead({L(path)}); try {{$b=New-Object byte[] {maxBytes}; $n=$f.Read($b,0,$b.Length); @{{contentBase64=[Convert]::ToBase64String($b,0,$n); bytesRead=$n; totalBytes=$f.Length; truncated=$f.Length -gt $n}} | ConvertTo-Json -Compress}} finally {{$f.Dispose()}}", + _ => throw new ArgumentException("Use list/read.") + }; + var id = (await service.ResolveAsync(machine, ct)).Id; + var result = await service.Guest.InvokeAsync(id, script, service.Credentials.Resolve(id, credentialProfile), ct); + return JsonDocument.Parse(result.GetProperty("output").GetString()!).RootElement.Clone(); + }); + + [McpServerTool(Name = "hyperv_console")] + [Description("Open/close/list/show/resize owned VM console windows. session: default/basic/enhanced; default=basic. Actual connected mode and fallback reason are returned. show brings the VM window forward; resize takes width/height pixels. Basic covers boot/sign-in; Enhanced offers clipboard and dynamic resolution.")] + public Task ConsoleTool(string action = "list", string? machine = null, string session = "default", int displayScalePercent = 100, int width = 1280, int height = 720, CancellationToken ct = default) => Run(async () => + { + if (action == "list") return service.Console.List(); + var vm = await service.ResolveAsync(machine ?? throw new ArgumentException("machine required."), ct); + if (action == "close") return await service.Console.CloseAsync(vm.Id, ct); + if (action is "show" or "resize") return await service.Console.ExecuteAsync(vm.Id, action == "show" ? HyperVConsoleAction.Show : HyperVConsoleAction.Resize, width, height, null, null, "default", ct); + if (action != "open") throw new ArgumentException("Use list/open/close/show/resize."); + if (vm.State != HyperVMachineState.Running) throw new InvalidOperationException("Start/resume the VM before opening its console."); + return await service.Console.OpenAsync(vm.Id, session, displayScalePercent, ct); + }); + + [McpServerTool(Name = "hyperv_screenshot", ReadOnly = true)] + [Description("Return the owned VM console PNG inline with width/height and actual mode. Basic is a headless guest framebuffer capture. Enhanced requires its visible unobscured foreground window; never substitute host pixels. Coordinates match hyperv_input.")] + public async Task Screenshot(string machine, CancellationToken ct) + { + try + { + var id = (await service.ResolveAsync(machine, ct)).Id; + var frame = await service.Console.ExecuteAsync(id, HyperVConsoleAction.Capture, null, null, null, null, "default", ct); + return new() { Content = [new TextContentBlock { Text = JsonSerializer.Serialize(new { machineId = id, frame.Width, frame.Height, sessionMode = frame.IsEnhanced ? "enhanced" : "basic" }, Program.Json) }, + ImageContentBlock.FromBytes(frame.Png!, "image/png")] }; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) { return Result(new { ok = false, error = ex.Message }); } + } + + [McpServerTool(Name = "hyperv_input", Destructive = true)] + [Description("Send input to the exact VM console: click, move, button_down, button_up, scroll, type, key, login. x/y are latest screenshot pixels. button=left/right/double for click; left/right/middle for down/up. key takes CTRL+ALT+DELETE or WIN+R etc in text. login enters saved password into an already focused guest password field; inspect first.")] + public Task Input(string machine, string action, int? x = null, int? y = null, string? text = null, string button = "left", int? delta = null, string credentialProfile = "default", CancellationToken ct = default) => Run(async () => + { + var kind = action switch { "click" => HyperVConsoleAction.Click, "move" => HyperVConsoleAction.Move, + "button_down" => HyperVConsoleAction.ButtonDown, "button_up" => HyperVConsoleAction.ButtonUp, + "scroll" => HyperVConsoleAction.Scroll, "type" => HyperVConsoleAction.Type, + "key" => HyperVConsoleAction.Key, "login" => HyperVConsoleAction.SignIn, _ => throw new ArgumentException("Unknown input action.") }; + if (action is "type" or "key" && text is null) throw new ArgumentException("text required."); + var input = action is "click" or "move" or "button_down" or "button_up" ? button : text; + var r = await service.Console.ExecuteAsync((await service.ResolveAsync(machine, ct)).Id, kind, x, y, input, delta, credentialProfile, ct); + return new { r.Success, r.Error }; + }); + + [McpServerTool(Name = "hyperv_clipboard", Destructive = true)] + [Description("Read/write the host clipboard redirected into an owned Enhanced session. Requires Enhanced; includes host clipboard data and is not VM-isolated. To paste, send CTRL+V separately after inspecting focus.")] + public Task Clipboard(string machine, string action, string? text = null, CancellationToken ct = default) => Run(async () => + { + var kind = action switch { "read" => HyperVConsoleAction.ClipboardRead, "write" when text != null => HyperVConsoleAction.ClipboardWrite, _ => throw new ArgumentException("Use read/write; write needs text.") }; + return await service.Console.ExecuteAsync((await service.ResolveAsync(machine, ct)).Id, kind, null, null, text, null, "default", ct); + }); + + [McpServerTool(Name = "hyperv_guest_setup")] + [Description("Install/verify Microsoft's winapp CLI for guest UI Automation. Host downloads an official release, verifies its published SHA-256, copies through PowerShell Direct; guest needs no network. Uses default guest credential.")] + public Task GuestSetup(string machine, CancellationToken ct) => Run(async () => await service.GuestUi.EnsureWinappAsync((await service.ResolveAsync(machine, ct)).Id, ct)); + + [McpServerTool(Name = "hyperv_ui", Destructive = true)] + [Description("Run winapp UI Automation in the signed-in guest's interactive desktop: status, inspect, search, list-windows, invoke, click, focus, get-value, set-value, get-property, wait-for, scroll. Arguments are a token array. Requires guest_setup and unlocked sign-in; headless PowerShell alone cannot see UI. elevated=true only for elevated guest apps.")] + public Task Ui(string machine, string command, string[]? arguments = null, bool elevated = false, int timeoutSeconds = 30, CancellationToken ct = default) => Run(async () => + { + var id = (await service.ResolveAsync(machine, ct)).Id; + var gate = service.UiGates.GetOrAdd(id, _ => new(1, 1)); + await gate.WaitAsync(ct); + try + { + using var lease = VmLease.Acquire(id, "ui"); + return await service.GuestUi.RunGuestUiAsync(id, command, arguments ?? [], TimeSpan.FromSeconds(timeoutSeconds), elevated, ct); + } + finally { gate.Release(); } + }); +} diff --git a/src/HyperVControl/HyperVTools.GuestDebug.cs b/src/HyperVControl/HyperVTools.GuestDebug.cs new file mode 100644 index 0000000..488e979 --- /dev/null +++ b/src/HyperVControl/HyperVTools.GuestDebug.cs @@ -0,0 +1,70 @@ +using System.ComponentModel; +using System.Text.Json; +using HyperVControl.Services; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using static HyperVControl.Services.ControlService; + +namespace HyperVControl; + +internal sealed partial class HyperVTools +{ + internal sealed record GuestDebugStartup(string WorkerId, string TaskName, JsonElement Result) + { + public bool Success => !IsFailure(Result); + } + + [McpServerTool(Name = "hyperv_guest_debug_setup")] + [Description("Deploy this published self-contained EXE and the selected host WinDbg amd64 engine directory into C:\\ProgramData\\HyperVControl\\debug in a Windows VM via PowerShell Direct. Enables persistent in-guest CDB without network or Python. Requires a guest admin credential.")] + public Task GuestDebugSetup(string machine, string? debuggerDirectory = null, string credentialProfile = "default", CancellationToken ct = default) => Run(async () => + { + var id = (await service.ResolveAsync(machine, ct)).Id; +#pragma warning disable IL3000 // Deliberately distinguishes development output from the single-file worker. + if (!string.IsNullOrEmpty(typeof(Program).Assembly.Location)) + throw new InvalidOperationException("Guest worker deployment requires the published single-file EXE; run scripts/build-hyperv-control.ps1 first."); +#pragma warning restore IL3000 + var engine = await service.Debugger.ResolveAsync("cdb.exe", debuggerDirectory, ct); + var directory = Path.GetDirectoryName(engine)!; + await service.Guest.CopyAsync(id, Environment.ProcessPath!, @"C:\ProgramData\HyperVControl\debug\HyperVControl.exe", false, credentialProfile, ct); + await service.Guest.InvokeAsync(id, "New-Item -ItemType Directory -Force -Path 'C:\\ProgramData\\HyperVControl\\debug\\engines' | Out-Null", service.Credentials.Resolve(id, credentialProfile), ct); + // Copy the directory into a parent; Copy-Item preserves the final directory name. + await service.Guest.CopyAsync(id, directory, @"C:\ProgramData\HyperVControl\debug\engines\", false, credentialProfile, ct); + return new { success = true, guestExecutable = @"C:\ProgramData\HyperVControl\debug\HyperVControl.exe", + guestDebuggerDirectory = @"C:\ProgramData\HyperVControl\debug\engines\" + Path.GetFileName(directory) }; + }); + + [McpServerTool(Name = "hyperv_guest_debug_open", Destructive = true)] + [Description("Launch or attach CDB INSIDE a VM (mode=launch/attach/dump). guestProcessId and path refer to the guest. First deploy using guest_debug_setup. A bounded one-hour S4U scheduled worker survives PowerShell Direct disconnects; use guest_debug close when finished. Guest admin credentials required to register it.")] + public Task GuestDebugOpen(string machine, string mode, string guestDebuggerDirectory, + int? guestProcessId = null, string? path = null, string[]? arguments = null, string credentialProfile = "default", CancellationToken ct = default) => Run(async () => + { + if (mode is not ("launch" or "attach" or "dump")) throw new ArgumentException("Use launch/attach/dump."); + FullPath(guestDebuggerDirectory); + var vmId = (await service.ResolveAsync(machine, ct)).Id; + var worker = Guid.NewGuid().ToString("N"); + var task = "HyperVControl-Debug-" + worker; + var config = new GuestDebugConfiguration(worker, mode, guestDebuggerDirectory, guestProcessId, path, arguments, task); + var encoded = PowerShellRunner.DecodeUtf8(JsonSerializer.Serialize(config, Program.Json)); + var script = $$""" + $exe='C:\ProgramData\HyperVControl\debug\HyperVControl.exe' + if(-not (Test-Path -LiteralPath $exe)) { throw 'Run hyperv_guest_debug_setup first.' } + $configPath='C:\ProgramData\HyperVControl\debug\{{worker}}.json' + [IO.File]::WriteAllText($configPath, {{encoded}}, [Text.UTF8Encoding]::new($false)) + $principal=New-ScheduledTaskPrincipal -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) -LogonType S4U -RunLevel Highest + $action=New-ScheduledTaskAction -Execute $exe -Argument ('--debug-worker "'+$configPath+'"') + $settings=New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 65) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries + Register-ScheduledTask -TaskName '{{task}}' -Action $action -Principal $principal -Settings $settings -Force | Out-Null + Start-ScheduledTask -TaskName '{{task}}' + 'worker scheduled' + """; + await service.Guest.InvokeAsync(vmId, script, service.Credentials.Resolve(vmId, credentialProfile), ct); + var state = await GuestDebugWorker.RemoteAsync(service, vmId, worker, new { action = "status" }, credentialProfile, ct); + return new GuestDebugStartup(worker, task, state); + }); + + [McpServerTool(Name = "hyperv_guest_debug", Destructive = true)] + [Description("Control a persistent in-guest CDB worker via PowerShell Direct: status, poll(cursor), command(command,waitMs), break, close. For breakpoints, stepping, memory, registers, stack and symbols use normal WinDbg commands. Retains pending commands across calls; no guest TCP service required.")] + public Task GuestDebug(string machine, string workerId, string action, string? command = null, + int waitMs = 10000, long cursor = 0, string credentialProfile = "default", CancellationToken ct = default) => Run(async () => + await GuestDebugWorker.RemoteAsync(service, (await service.ResolveAsync(machine, ct)).Id, workerId, new { action, command, waitMs, cursor }, credentialProfile, ct)); +} diff --git a/src/HyperVControl/HyperVTools.cs b/src/HyperVControl/HyperVTools.cs new file mode 100644 index 0000000..b7509a5 --- /dev/null +++ b/src/HyperVControl/HyperVTools.cs @@ -0,0 +1,179 @@ +using System.ComponentModel; +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; +using HyperVControl.Extracted; +using HyperVControl.Services; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using static HyperVControl.Services.ControlService; + +namespace HyperVControl; + +[McpServerToolType] +internal sealed partial class HyperVTools(ControlService service) +{ + internal static CallToolResult Result(object? value) + { + var json = JsonSerializer.SerializeToElement(value, Program.Json); + return new() { IsError = IsFailure(json), Content = [new TextContentBlock { Text = json.GetRawText() }] }; + } + + internal static bool IsFailure(JsonElement json) => json.ValueKind == JsonValueKind.Object && + ((json.TryGetProperty("success", out var success) && success.ValueKind == JsonValueKind.False) + || (json.TryGetProperty("ok", out var ok) && ok.ValueKind == JsonValueKind.False) + || (json.TryGetProperty("exitCode", out var exitCode) && exitCode.ValueKind == JsonValueKind.Number && exitCode.GetInt32() != 0)); + internal static async Task Run(Func> action) + { + try { return Result(await action()); } + catch (OperationCanceledException) { throw; } + catch (Exception ex) { return Result(new { ok = false, error = ex.Message, errorType = ex.GetType().Name }); } + } + + [McpServerTool(Name = "hyperv_capabilities", ReadOnly = true)] + [Description("Probe local Hyper-V rights, host availability, console and guest prerequisites, and installed debugger versions. No VM mutations.")] + public Task Capabilities(CancellationToken ct) => Run(async () => new + { + elevated = ElevationBridge.IsAdministrator, host = await service.HyperV.GetInventoryAsync(ct), + debuggers = await service.Debugger.DiscoverAsync(ct), + basic = "Hyper-V WMI framebuffer and synthetic input; console window uses RDP ActiveX on port 2179.", + enhanced = "Supported guest, Enhanced Session enabled; RDP ActiveX. Capture requires foreground unobscured window.", + guest = "PowerShell Direct requires a running Windows guest and guest credentials. UI Automation also needs an unlocked interactive sign-in.", + elevation = "Use --elevate to bridge stdio to a same-user UAC worker. Cancelling UAC exits cleanly. Alternate-admin credentials require starting the MCP client in that account." + }); + + [McpServerTool(Name = "hyperv_list", ReadOnly = true)] + [Description("List all local Hyper-V VMs with exact IDs, names, states, memory and CPUs.")] + public Task List(CancellationToken ct) => Run(async () => await service.HyperV.GetInventoryAsync(ct)); + + [McpServerTool(Name = "hyperv_vm_info", ReadOnly = true)] + [Description("Read detailed VM hardware, integration services, disks, network, COM ports and current checkpoint ID.")] + public Task Info(string machine, CancellationToken ct) => Run(async () => await service.InfoAsync((await service.ResolveAsync(machine, ct)).Id, ct)); + + [McpServerTool(Name = "hyperv_power", Destructive = true)] + [Description("Change VM power: start, shutdown (graceful), restart (graceful stop/wait/start), turn_off (hard), reset (hard), pause, resume, save. No automatic hard-power fallback.")] + public Task Power(string machine, string action, CancellationToken ct) => Run(async () => + { + var vm = await service.ResolveAsync(machine, ct); + if (action == "reset") return await service.PowerShell.JsonAsync(Vm(vm.Id) + "Restart-VM -VM $vm -Force -Confirm:$false\n" + Vm(vm.Id) + VmJson, ct); + var op = action switch { "start" => HyperVPowerAction.Start, "shutdown" => HyperVPowerAction.Shutdown, + "turn_off" => HyperVPowerAction.TurnOff, "restart" => HyperVPowerAction.Restart, "pause" => HyperVPowerAction.Suspend, + "resume" => HyperVPowerAction.Resume, "save" => HyperVPowerAction.Save, _ => throw new ArgumentException("Unknown power action.") }; + return await service.HyperV.ChangePowerStateAsync(vm.Id, op, ct); + }); + + [McpServerTool(Name = "hyperv_checkpoints", ReadOnly = true)] + [Description("List a VM's checkpoint tree with IDs and its current parent checkpoint. Current is not necessarily newest.")] + public Task Checkpoints(string machine, CancellationToken ct) => Run(async () => await service.HyperV.GetCheckpointsAsync((await service.ResolveAsync(machine, ct)).Id, ct)); + + [McpServerTool(Name = "hyperv_checkpoint", Destructive = true)] + [Description("Create, restore, remove, or rename one VM checkpoint. Resolve by exact name/ID; 'current' means current parent. Restore discards changes. Deletion does not implicitly include children.")] + public Task Checkpoint(string machine, string action, string checkpoint, string? newName = null, CancellationToken ct = default) => Run(async () => + { + var id = (await service.ResolveAsync(machine, ct)).Id; + if (action == "create") return await service.HyperV.CreateCheckpointAsync(id, checkpoint, ct); + var cid = await service.CheckpointIdAsync(id, checkpoint, ct); + return action switch + { + "restore" => await service.HyperV.RestoreCheckpointAsync(id, cid, ct), + "remove" => await service.HyperV.DeleteCheckpointAsync(id, cid, ct), + "rename" when !string.IsNullOrWhiteSpace(newName) => await service.PowerShell.JsonAsync(Vm(id) + $"Get-VMSnapshot -VM $vm | Where-Object Id -EQ ([guid]'{cid:D}') | Rename-VMSnapshot -NewName {L(newName)}\n@{{success=$true}} | ConvertTo-Json -Compress", ct), + _ => throw new ArgumentException("Use create/restore/remove/rename; rename needs newName.") + }; + }); + + [McpServerTool(Name = "hyperv_firmware", Destructive = true)] + [Description("Set and verify Secure Boot on an Off Generation 2 VM. Change only for the requested guest task; debugging tools never disable it automatically.")] + public Task Firmware(string machine, bool secureBoot, CancellationToken ct) => Run(async () => await service.HyperV.SetSecureBootAsync((await service.ResolveAsync(machine, ct)).Id, secureBoot, ct)); + + [McpServerTool(Name = "hyperv_vm_create")] + [Description("Create a Generation 1/2 VM with existing VHD or new VHDX. All paths are absolute host paths. Returns VM configuration; leaves VM Off.")] + public Task CreateVm(string name, string path, string vhdPath, int generation = 2, int memoryMb = 4096, + int processors = 2, int newDiskGb = 0, string? switchName = null, CancellationToken ct = default) => Run(async () => + { + FullPath(path); FullPath(vhdPath); + if (string.IsNullOrWhiteSpace(name) || generation is < 1 or > 2 || memoryMb is < 32 or > 1048576 || processors is < 1 or > 2048 || newDiskGb < 0) + throw new ArgumentException("Invalid VM name, generation, memory, processors or disk size."); + var disk = newDiskGb == 0 ? $"-VHDPath {L(vhdPath)}" : $"-NewVHDPath {L(vhdPath)} -NewVHDSizeBytes ([long]{newDiskGb}*1GB)"; + var net = switchName == null ? "" : "-SwitchName " + L(switchName); + return await service.PowerShell.JsonAsync($"$vm=New-VM -Name {L(name)} -Path {L(path)} -Generation {generation} -MemoryStartupBytes ([long]{memoryMb}*1MB) {disk} {net}\nSet-VMProcessor -VM $vm -Count {processors}\n" + VmJson, ct); + }); + + [McpServerTool(Name = "hyperv_vm_remove", Destructive = true)] + [Description("Unregister exactly one Off VM by GUID. Hyper-V checkpoint merges may occur. Leaves standalone VHD files on disk.")] + public Task RemoveVm(string machineId, CancellationToken ct) => Run(async () => + { + var id = Guid.Parse(machineId); + return await service.PowerShell.JsonAsync(Vm(id) + RequireOff + "Remove-VM -VM $vm -Force\n@{success=$true} | ConvertTo-Json -Compress", ct, 300); + }); + + [McpServerTool(Name = "hyperv_vm_configure", Destructive = true)] + [Description("Configure an Off VM's CPU, memory, dynamic memory, checkpoint type or name. Omitted values remain unchanged.")] + public Task ConfigureVm(string machine, int? processors = null, int? memoryMb = null, + bool? dynamicMemory = null, string? checkpointType = null, string? newName = null, CancellationToken ct = default) => Run(async () => + { + var id = (await service.ResolveAsync(machine, ct)).Id; + var script = Vm(id) + RequireOff; + if (processors is not null) { if (processors is < 1 or > 2048) throw new ArgumentException("Invalid processors."); script += $"Set-VMProcessor -VM $vm -Count {processors}\n"; } + if (memoryMb is not null) { if (memoryMb is < 32 or > 1048576) throw new ArgumentException("Invalid memoryMb."); script += $"Set-VMMemory -VM $vm -StartupBytes ([long]{memoryMb}*1MB)\n"; } + if (dynamicMemory is not null) script += $"Set-VMMemory -VM $vm -DynamicMemoryEnabled:${dynamicMemory.ToString()!.ToLowerInvariant()}\n"; + if (checkpointType is not null) + { + if (checkpointType is not ("Standard" or "Production" or "ProductionOnly" or "Disabled")) throw new ArgumentException("Invalid checkpointType."); + script += $"Set-VM -VM $vm -CheckpointType {checkpointType}\n"; + } + if (newName is not null) script += "Rename-VM -VM $vm -NewName " + L(newName) + "\n"; + return await service.PowerShell.JsonAsync(script + Vm(id) + VmJson, ct); + }); + + [McpServerTool(Name = "hyperv_vm_export")] + [Description("Export a VM including its checkpoint chain to an absolute host directory.")] + public Task Export(string machine, string destination, CancellationToken ct) => Run(async () => + await service.PowerShell.JsonAsync(Vm((await service.ResolveAsync(machine, ct)).Id) + $"Export-VM -VM $vm -Path {L(FullPath(destination))}\n@{{success=$true}} | ConvertTo-Json -Compress", ct, 1800)); + + [McpServerTool(Name = "hyperv_vm_import")] + [Description("Import an exported .vmcx by copying it and generating a new VM ID. This is also the clone path. Supply fresh VM and VHD destination directories.")] + public Task Import(string configurationPath, string vmDestination, string vhdDestination, CancellationToken ct) => Run(async () => + await service.PowerShell.JsonAsync($"$vm=Import-VM -Path {L(FullPath(configurationPath))} -Copy -GenerateNewId -VirtualMachinePath {L(FullPath(vmDestination))} -VhdDestinationPath {L(FullPath(vhdDestination))}\n" + VmJson, ct, 1800)); + + [McpServerTool(Name = "hyperv_network", Destructive = true)] + [Description("List virtual switches, create private/internal switches, remove an exact switch, or connect/disconnect a named VM adapter. External switch creation is deliberately explicit via host administration.")] + public Task Network(string action = "list", string? machine = null, string? switchName = null, string adapterName = "Network Adapter", string switchType = "Private", CancellationToken ct = default) => Run(async () => + { + if (action == "list") return await service.PowerShell.JsonAsync("ConvertTo-Json -InputObject @(Get-VMSwitch | Select-Object Id,Name,SwitchType,NetAdapterInterfaceDescription) -Compress", ct); + if (action == "create") + { + if (switchType is not ("Private" or "Internal") || string.IsNullOrWhiteSpace(switchName)) throw new ArgumentException("Name and Private/Internal switchType required."); + return await service.PowerShell.JsonAsync($"New-VMSwitch -Name {L(switchName)} -SwitchType {switchType} | Select-Object Id,Name,SwitchType | ConvertTo-Json -Compress", ct); + } + if (action == "remove") + return await service.PowerShell.JsonAsync($"$s=@(Get-VMSwitch | Where-Object Name -EQ {L(switchName ?? throw new ArgumentException("switchName required."))}); if($s.Count -ne 1){{throw 'Switch missing or ambiguous'}}; $s[0] | Remove-VMSwitch -Force\n@{{success=$true}} | ConvertTo-Json -Compress", ct); + var id = (await service.ResolveAsync(machine ?? throw new ArgumentException("machine required."), ct)).Id; + var select = Vm(id) + $"$a=@(Get-VMNetworkAdapter -VM $vm | Where-Object Name -EQ {L(adapterName)}); if($a.Count -ne 1){{throw 'Adapter missing or ambiguous'}}\n"; + var op = action switch { "connect" => $"Connect-VMNetworkAdapter -VMNetworkAdapter $a[0] -SwitchName {L(switchName ?? throw new ArgumentException("switchName required."))}", + "disconnect" => "Disconnect-VMNetworkAdapter -VMNetworkAdapter $a[0]", _ => throw new ArgumentException("Unknown network action.") }; + return await service.PowerShell.JsonAsync(select + op + "\n@{success=$true} | ConvertTo-Json -Compress", ct); + }); + + [McpServerTool(Name = "hyperv_disk", Destructive = true)] + [Description("Manage VM media: list, attach_vhd, detach_vhd by exact path, attach_iso, eject_iso, or resize_vhd. Changes require VM Off; does not delete disk files.")] + public Task Disk(string machine, string action = "list", string? path = null, long? sizeGb = null, CancellationToken ct = default) => Run(async () => + { + var id = (await service.ResolveAsync(machine, ct)).Id; + if (action == "list") return await service.InfoAsync(id, ct); + var script = Vm(id) + RequireOff; + var literal = path == null ? "" : L(FullPath(path)); + if (action is not "eject_iso" && path == null) throw new ArgumentException("path required."); + script += action switch + { + "attach_vhd" => $"Add-VMHardDiskDrive -VM $vm -Path {literal}", + "detach_vhd" => $"Get-VMHardDiskDrive -VM $vm | Where-Object Path -EQ {literal} | Remove-VMHardDiskDrive", + "attach_iso" => $"$d=@(Get-VMDvdDrive -VM $vm); if($d.Count -eq 0){{Add-VMDvdDrive -VM $vm -Path {literal}}} elseif($d.Count -eq 1){{$d[0] | Set-VMDvdDrive -Path {literal}}} else{{throw 'Multiple DVD drives; use an exact device configuration'}}", + "eject_iso" => "Get-VMDvdDrive -VM $vm | Set-VMDvdDrive -Path $null", + "resize_vhd" when sizeGb > 0 => $"$d=@(Get-VMHardDiskDrive -VM $vm | Where-Object Path -EQ {literal}); if($d.Count -ne 1){{throw 'Disk is not attached exactly once to this VM'}}; Resize-VHD -Path {literal} -SizeBytes ([long]{sizeGb}*1GB)", + _ => throw new ArgumentException("Unknown disk action or missing sizeGb.") + }; + return await service.PowerShell.JsonAsync(script + "\n@{success=$true} | ConvertTo-Json -Compress", ct, 300); + }); +} diff --git a/src/HyperVControl/Program.cs b/src/HyperVControl/Program.cs new file mode 100644 index 0000000..a8eb6a8 --- /dev/null +++ b/src/HyperVControl/Program.cs @@ -0,0 +1,68 @@ +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using HyperVControl.Services; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace HyperVControl; + +internal static class Program +{ + internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) + { Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } }; + + public static async Task Main(string[] args) + { + try + { + if (args.Length == 0 || args[0] != "--worker") + { Console.InputEncoding = new UTF8Encoding(false); Console.OutputEncoding = new UTF8Encoding(false); } + if (args.Contains("--version")) { Console.WriteLine("Hyper-V Control 0.1.0"); return 0; } + if (args.Contains("--help")) + { + Console.WriteLine("HyperVControl.exe [--elevate]\nC# MCP server over stdio. --elevate uses a same-user UAC worker when necessary.\nNo HTTP listener. Log output goes to stderr. See the bundled skill and README."); + return 0; + } + if (args.Contains("--elevate") && !ElevationBridge.IsAdministrator) + { await ElevationBridge.RunAsync(); return 0; } + if (args.Length >= 2 && args[0] == "--debug-worker") return await GuestDebugWorker.RunAsync(args[1]); + if (args.Length >= 2 && args[0] == "--debug-rpc") return await GuestDebugWorker.ClientAsync(args[1]); + if (args.Length == 1 && args[0] == "--debug-console") return await DebuggerConsole.RunAsync(); + if (args.Length == 2 && args[0] == "--debug-interrupt") return DebuggerConsole.Interrupt(int.Parse(args[1])); + + Stream? workerPipe = null; + if (args.Length == 3 && args[0] == "--worker") + workerPipe = await ElevationBridge.ConnectWorkerAsync(args[1], int.Parse(args[2])); + using var pipeLifetime = workerPipe; + using var logger = LoggerFactory.Create(b => b.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace).SetMinimumLevel(LogLevel.Warning)); + using var control = new ControlService(); + var tools = new HyperVTools(control); + var options = new McpServerOptions + { + ServerInfo = new() { Name = "hyper-v-control", Version = "0.1.0" }, + ServerInstructions = "Manage local Hyper-V VMs by exact name/ID. Start with hyperv_capabilities and hyperv_list. Basic console works at boot without guest credentials; Enhanced requires a supported guest and visible console. PowerShell Direct and UI Automation require guest credentials. Kernel debugger sessions target a VM, never the host kernel. Debugger command timeouts return pending sessions; poll or break them instead of starting another debugger.", + ToolCollection = [] + }; + foreach (var method in typeof(HyperVTools).GetMethods().Where(m => m.GetCustomAttribute() != null)) + options.ToolCollection.Add(McpServerTool.Create(method, tools)); + await using var transport = new StreamServerTransport(workerPipe ?? Console.OpenStandardInput(), + workerPipe ?? Console.OpenStandardOutput(), "hyper-v-control", logger); + await using var server = McpServer.Create(transport, options, logger); + await server.RunAsync(); + return 0; + } + catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223) + { Console.Error.WriteLine("Hyper-V Control: Windows elevation was cancelled. Restart with --elevate when ready, or run with an account granted the necessary Hyper-V rights."); return 1223; } + catch (Exception ex) + { + if (args.Length > 0 && args[0] == "--worker") + { + try { Directory.CreateDirectory(ControlPaths.DataRoot); File.WriteAllText(Path.Combine(ControlPaths.DataRoot, "worker-startup-error.txt"), ex.ToString()); } catch { } + } + Console.Error.WriteLine("Hyper-V Control: " + ex.Message); return 1; + } + } +} diff --git a/src/HyperVControl/Services/ConsoleService.cs b/src/HyperVControl/Services/ConsoleService.cs new file mode 100644 index 0000000..bf88de8 --- /dev/null +++ b/src/HyperVControl/Services/ConsoleService.cs @@ -0,0 +1,77 @@ +using System.Collections.Concurrent; +using HyperVControl.Extracted; + +namespace HyperVControl.Services; + +internal sealed class ConsoleService(CredentialStore credentials) : IDisposable +{ + private readonly Lazy manager = new(() => new()); + private readonly ConcurrentDictionary sessions = new(); + private readonly ConcurrentDictionary gates = new(); + private readonly ConcurrentDictionary leases = new(); + private const string Owner = "stdio"; + + public object List() => sessions.Select(x => new { machineId = x.Key, sessionMode = x.Value.IsEnhanced ? "enhanced" : "basic", x.Value.Note }).ToArray(); + public async Task OpenAsync(Guid id, string mode, int scale, CancellationToken ct) + { + if (mode is not ("basic" or "enhanced" or "default")) throw new ArgumentException("session must be basic, enhanced or default."); + if (scale is not (0 or 100 or 125 or 150 or 175 or 200 or 250 or 300 or 400 or 500)) throw new ArgumentException("Unsupported display scale."); + var gate = gates.GetOrAdd(id, _ => new(1, 1)); + await gate.WaitAsync(ct); + try + { + if (!leases.ContainsKey(id)) leases[id] = VmLease.Acquire(id, "console"); + var result = await manager.Value.OpenAsync(Owner, id, 0, Environment.MachineName, mode == "enhanced", scale, ct); + if (!result.Success) { sessions.TryRemove(id, out _); if(leases.TryRemove(id,out var lease)) lease.Dispose(); throw new InvalidOperationException(result.Error); } + sessions[id] = result; + return new { success = true, machineId = id, sessionMode = result.IsEnhanced ? "enhanced" : "basic", result.Note }; + } + catch + { + if (manager.IsValueCreated) await manager.Value.CloseAsync(Owner, id, CancellationToken.None); + sessions.TryRemove(id, out _); + if (leases.TryRemove(id, out var lease)) lease.Dispose(); + throw; + } + finally { gate.Release(); } + } + public async Task CloseAsync(Guid id, CancellationToken ct) + { + var gate = gates.GetOrAdd(id, _ => new(1, 1)); + await gate.WaitAsync(ct); + try + { + if (!manager.IsValueCreated) return new { success = true }; + var result = await manager.Value.CloseAsync(Owner, id, ct); + sessions.TryRemove(id, out _); + if (leases.TryRemove(id, out var lease)) lease.Dispose(); + return result; + } + finally { gate.Release(); } + } + public async Task ExecuteAsync(Guid id, HyperVConsoleAction action, int? x, int? y, + string? text, int? delta, string profile, CancellationToken ct) + { + if (!sessions.ContainsKey(id)) throw new InvalidOperationException("Open a console for this VM first with hyperv_console."); + if (action is HyperVConsoleAction.Click or HyperVConsoleAction.Scroll or HyperVConsoleAction.Move or HyperVConsoleAction.ButtonDown or HyperVConsoleAction.ButtonUp) + if (x is null || y is null || x < 0 || y < 0) throw new ArgumentException("Nonnegative screenshot x and y are required."); + if (action == HyperVConsoleAction.Scroll && (delta is null || delta is < -32768 or > 32767)) throw new ArgumentException("delta must be -32768..32767 (120 per wheel notch)."); + if (text?.Length > 32768) throw new ArgumentException("Input exceeds 32,768 characters."); + var c = action == HyperVConsoleAction.SignIn ? credentials.Resolve(id, profile) : null; + var gate = gates.GetOrAdd(id, _ => new(1, 1)); + await gate.WaitAsync(ct); + try + { + var result = await manager.Value.ExecuteAsync(Owner, id, action, x, y, text, delta, c?.UserName, c?.Password, ct); + if (!result.Success) throw new InvalidOperationException(result.Error); + return result; + } + finally { gate.Release(); } + } + public void Dispose() + { + if (manager.IsValueCreated) manager.Value.Dispose(); + foreach (var lease in leases.Values) lease.Dispose(); + leases.Clear(); + } +} diff --git a/src/HyperVControl/Services/ControlService.cs b/src/HyperVControl/Services/ControlService.cs new file mode 100644 index 0000000..99dc38c --- /dev/null +++ b/src/HyperVControl/Services/ControlService.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using HyperVControl.Extracted; + +namespace HyperVControl.Services; + +internal sealed class ControlService : IDisposable +{ + public PowerShellRunner PowerShell { get; } = new(); + public CredentialStore Credentials { get; } = new(); + public HyperVService HyperV { get; } + public GuestService Guest { get; } + public DebuggerService Debugger { get; } + public ConsoleService Console { get; } + public HyperVGuestUiService GuestUi { get; } + // One UI task per VM is serialized; different VMs remain independent. + internal readonly System.Collections.Concurrent.ConcurrentDictionary UiGates = new(); + + public ControlService() + { + HyperV = new HyperVService(async (script, ct) => + { + var result = await PowerShell.RunAsync(script, ct, 360); + return new(result.ExitCode, result.Stdout, result.Stderr); + }); + Guest = new(PowerShell, Credentials); + Debugger = new(PowerShell); + Console = new(Credentials); + GuestUi = new HyperVGuestUiService( + (id, script, ct) => Guest.RunTextAsync(id, script, "default", ct), + async (id, archive, ct) => + { + var c = Credentials.Resolve(id); + var script = HyperVGuestUiService.BuildHostCopyScript(id, c.UserName, c.Password, archive); + var result = await PowerShell.JsonAsync(script, ct, 300); + return result.GetProperty("success").GetBoolean() + ? new(true, result.GetProperty("exePath").GetString(), null) + : new(false, null, result.GetProperty("error").GetString()); + }); + } + + public async Task ResolveAsync(string machine, CancellationToken ct) + { + var inventory = await HyperV.GetInventoryAsync(ct); + if (!inventory.Capability.IsAvailable) throw new InvalidOperationException(inventory.Capability.Detail ?? inventory.Capability.State.ToString()); + var matches = inventory.Machines.Where(vm => vm.Id.ToString().Equals(machine, StringComparison.OrdinalIgnoreCase) + || vm.Name.Equals(machine, StringComparison.OrdinalIgnoreCase)).ToArray(); + return matches.Length switch + { + 1 => matches[0], + 0 => throw new ArgumentException($"VM '{machine}' not found. Call hyperv_list to get exact names/IDs."), + _ => throw new ArgumentException("VM name is ambiguous. Use its ID.") + }; + } + + internal static string Vm(Guid id) => $"$vm = Get-VM -Id ([guid]'{id:D}') -ErrorAction Stop\n"; + internal static string VmJson => "$vm | Select-Object Id,Name,State,Generation,ProcessorCount,MemoryStartup,MemoryAssigned,DynamicMemoryEnabled,CheckpointType,Path | ConvertTo-Json -Depth 6 -Compress"; + internal static string RequireOff => "if ([string]$vm.State -ne 'Off') { throw 'This configuration change requires the VM to be Off.' }\n"; + internal static string L(string text) => PowerShellRunner.Literal(text); + + public async Task InfoAsync(Guid id, CancellationToken ct) => await PowerShell.JsonAsync(Vm(id) + """ + @{ vm=($vm | Select-Object Id,Name,State,Generation,Version,ProcessorCount,MemoryStartup,MemoryAssigned,MemoryMinimum,MemoryMaximum,DynamicMemoryEnabled,CheckpointType,ParentSnapshotId,Path); + firmware=$(if($vm.Generation -eq 2){ Get-VMFirmware -VM $vm | Select-Object @{Name='SecureBoot';Expression={$_.SecureBoot.ToString()}},SecureBootTemplate,PreferredNetworkBootProtocol }else{$null}); + network=@(Get-VMNetworkAdapter -VM $vm | Select-Object Id,Name,SwitchName,MacAddress,IPAddresses,Status); + disks=@(Get-VMHardDiskDrive -VM $vm | Select-Object Path,ControllerType,ControllerNumber,ControllerLocation); + dvd=@(Get-VMDvdDrive -VM $vm | Select-Object Path,ControllerNumber,ControllerLocation); + com=@(Get-VMComPort -VM $vm | Select-Object Name,Path); + integration=@(Get-VMIntegrationService -VM $vm | Select-Object Id,Name,Enabled,PrimaryStatusDescription) + } | ConvertTo-Json -Depth 8 -Compress + """, ct); + + public async Task CheckpointIdAsync(Guid id, string checkpoint, CancellationToken ct) + { + var list = await HyperV.GetCheckpointsAsync(id, ct); + if (!list.Success) throw new InvalidOperationException(list.Error); + if (checkpoint == "current") return list.CurrentParentCheckpointId ?? throw new InvalidOperationException("VM has no current parent checkpoint."); + var matches = list.Checkpoints.Where(c => c.Id.ToString().Equals(checkpoint, StringComparison.OrdinalIgnoreCase) + || c.Name.Equals(checkpoint, StringComparison.OrdinalIgnoreCase)).ToArray(); + return matches.Length == 1 ? matches[0].Id : throw new ArgumentException("Checkpoint missing or ambiguous; use an ID from hyperv_checkpoints."); + } + + internal static string FullPath(string path) + { + if (!Path.IsPathFullyQualified(path) || path.Split('\\', '/').Contains("..")) + throw new ArgumentException("Use an absolute path without '..' components."); + return path; + } + + public void Dispose() + { + Console.Dispose(); Debugger.Dispose(); + foreach (var gate in UiGates.Values) gate.Dispose(); + } +} diff --git a/src/HyperVControl/Services/CredentialStore.cs b/src/HyperVControl/Services/CredentialStore.cs new file mode 100644 index 0000000..7a2024f --- /dev/null +++ b/src/HyperVControl/Services/CredentialStore.cs @@ -0,0 +1,84 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; + +namespace HyperVControl.Services; + +internal sealed record GuestCredential(string UserName, string Password); + +internal sealed class CredentialStore +{ + private static string Target(Guid vmId, string profile) => $"HyperVControl/{vmId:D}/{ValidateProfile(profile)}"; + + internal static string ValidateProfile(string profile) => Regex.IsMatch(profile, "^[a-zA-Z][a-zA-Z0-9_]{0,31}$") + ? profile : throw new ArgumentException("credentialProfile must contain 1-32 letters, digits or underscores and start with a letter."); + + public GuestCredential Resolve(Guid vmId, string profile = "default") + { + if (CredRead(Target(vmId, profile), 1, 0, out var ptr)) + { + try + { + var c = Marshal.PtrToStructure(ptr); + var bytes = new byte[c.BlobSize]; + Marshal.Copy(c.Blob, bytes, 0, bytes.Length); + try { return new(c.UserName ?? "", Encoding.Unicode.GetString(bytes)); } + finally { Array.Clear(bytes); } + } + finally { CredFree(ptr); } + } + var prefix = profile == "default" ? "HYPERV_CONTROL_GUEST" : "HYPERV_CONTROL_" + profile.ToUpperInvariant(); + var user = Environment.GetEnvironmentVariable(prefix + "_USERNAME"); + var pass = Environment.GetEnvironmentVariable(prefix + "_PASSWORD"); + if (profile == "default") + { + user ??= Environment.GetEnvironmentVariable("HYPERV_GUEST_USERNAME"); + pass ??= Environment.GetEnvironmentVariable("HYPERV_GUEST_PASSWORD"); + } + if (!string.IsNullOrEmpty(user) && pass != null) return new(user, pass); + throw new InvalidOperationException($"No guest credential for VM {vmId:D}, profile '{profile}'. Use hyperv_credential (Windows Credential Manager) or {prefix}_USERNAME / {prefix}_PASSWORD environment variables."); + } + + public void Save(Guid id, string profile, string user, string password) + { + if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("A guest username is required."); + var bytes = Encoding.Unicode.GetBytes(password); + if (bytes.Length > 2560) throw new ArgumentException("Credential exceeds Windows Credential Manager's size limit."); + var blob = Marshal.AllocHGlobal(bytes.Length); + try + { + Marshal.Copy(bytes, 0, blob, bytes.Length); + var c = new Credential { Type = 1, TargetName = Target(id, profile), UserName = user, + Blob = blob, BlobSize = (uint)bytes.Length, Persist = 2 }; + if (!CredWrite(ref c, 0)) throw new Win32Exception(Marshal.GetLastWin32Error()); + } + finally { Array.Clear(bytes); Marshal.Copy(bytes, 0, blob, bytes.Length); Marshal.FreeHGlobal(blob); } + } + + public void Remove(Guid id, string profile) + { + if (!CredDelete(Target(id, profile), 1, 0) && Marshal.GetLastWin32Error() != 1168) + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct Credential + { + public uint Flags, Type; + public string? TargetName, Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public uint BlobSize; + public nint Blob; + public uint Persist, AttributeCount; + public nint Attributes; + public string? TargetAlias, UserName; + } + [DllImport("advapi32", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CredRead(string target, uint type, uint flags, out nint credential); + [DllImport("advapi32", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CredWrite(ref Credential credential, uint flags); + [DllImport("advapi32", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CredDelete(string target, uint type, uint flags); + [DllImport("advapi32")] private static extern void CredFree(nint buffer); +} diff --git a/src/HyperVControl/Services/DebuggerConsole.cs b/src/HyperVControl/Services/DebuggerConsole.cs new file mode 100644 index 0000000..ee00c91 --- /dev/null +++ b/src/HyperVControl/Services/DebuggerConsole.cs @@ -0,0 +1,78 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; + +namespace HyperVControl.Services; + +internal sealed record DebuggerConsoleRequest(string Engine, string[] Arguments, string SymbolPath); + +/// KD requires a real console even when its command streams are redirected. +internal static class DebuggerConsole +{ + private static readonly Handler IgnoreSignal = _ => true; + + internal static async Task RunAsync() + { + // Preserve the parent's pipes before allocating a private, hidden console. + using var input = new StreamReader(Console.OpenStandardInput(), Encoding.UTF8); + using var output = Console.OpenStandardOutput(); + using var error = Console.OpenStandardError(); + var line = await input.ReadLineAsync() ?? throw new IOException("Missing debugger console request."); + var request = JsonSerializer.Deserialize(line, Program.Json)!; + FreeConsole(); + if (!AllocConsole()) throw new Win32Exception(Marshal.GetLastWin32Error()); + ShowWindow(GetConsoleWindow(), 0); + var start = new ProcessStartInfo(request.Engine) + { + UseShellExecute = false, CreateNoWindow = false, WindowStyle = ProcessWindowStyle.Hidden, + RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, + WorkingDirectory = Path.GetDirectoryName(request.Engine)! + }; + foreach (var argument in request.Arguments) start.ArgumentList.Add(argument); + start.Environment["_NT_SYMBOL_PATH"] = request.SymbolPath; + using var debugger = Process.Start(start) ?? throw new InvalidOperationException("Debugger did not start."); + // Console handlers themselves aren't inherited. The child retains its own handler. + SetConsoleCtrlHandler(IgnoreSignal, true); + using var pumps = new CancellationTokenSource(); + var stdout = debugger.StandardOutput.BaseStream.CopyToAsync(output, pumps.Token); + var stderr = debugger.StandardError.BaseStream.CopyToAsync(error, pumps.Token); + var forward = Task.Run(async () => + { + try + { + while (await input.ReadLineAsync() is { } command) + { await debugger.StandardInput.WriteLineAsync(command); await debugger.StandardInput.FlushAsync(); } + debugger.StandardInput.Close(); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException) { } + }); + await debugger.WaitForExitAsync(); + try { await Task.WhenAll(stdout, stderr).WaitAsync(TimeSpan.FromSeconds(1)); } + catch (TimeoutException) { pumps.Cancel(); } + return debugger.ExitCode; + } + + internal static int Interrupt(int consoleOwnerPid) + { + FreeConsole(); + if (!AttachConsole((uint)consoleOwnerPid)) throw new Win32Exception(Marshal.GetLastWin32Error()); + try + { + SetConsoleCtrlHandler(IgnoreSignal, true); + if (!GenerateConsoleCtrlEvent(1, 0)) throw new Win32Exception(Marshal.GetLastWin32Error()); // CTRL_BREAK_EVENT, this private console only + Thread.Sleep(300); + return 0; + } + finally { FreeConsole(); } + } + private delegate bool Handler(uint signal); + [DllImport("kernel32", SetLastError=true)] private static extern bool AllocConsole(); + [DllImport("kernel32", SetLastError=true)] private static extern bool AttachConsole(uint pid); + [DllImport("kernel32")] private static extern bool FreeConsole(); + [DllImport("kernel32")] private static extern nint GetConsoleWindow(); + [DllImport("kernel32")] private static extern bool SetConsoleCtrlHandler(Handler handler, bool add); + [DllImport("kernel32", SetLastError=true)] private static extern bool GenerateConsoleCtrlEvent(uint signal, uint group); + [DllImport("user32")] private static extern bool ShowWindow(nint window, int command); +} diff --git a/src/HyperVControl/Services/DebuggerService.cs b/src/HyperVControl/Services/DebuggerService.cs new file mode 100644 index 0000000..b47e422 --- /dev/null +++ b/src/HyperVControl/Services/DebuggerService.cs @@ -0,0 +1,229 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace HyperVControl.Services; + +internal sealed record DebuggerInstallation(string Directory, string Source, string? Kd, string? Cdb, string? Version); +internal sealed record DebugOutput(string SessionId, int ProcessId, bool Exited, int? ExitCode, bool Pending, long Cursor, bool Truncated, string Output); + +internal sealed class DebuggerService(PowerShellRunner runner) : IDisposable +{ + private readonly ConcurrentDictionary sessions = new(); + + public async Task> DiscoverAsync(CancellationToken ct) + { + var candidates = new List<(string Path, string Source)>(); + var configured = Environment.GetEnvironmentVariable("HYPERV_CONTROL_DEBUGGER_DIR"); + if (!string.IsNullOrWhiteSpace(configured)) candidates.Add((configured, "explicit")); + { + var appx = await runner.JsonAsync("ConvertTo-Json -InputObject @(Get-AppxPackage -Name Microsoft.WinDbg | Select-Object -ExpandProperty InstallLocation) -Compress", ct); + foreach (var entry in appx.EnumerateArray()) if (entry.GetString() is { } path) candidates.Add((Path.Combine(path, "amd64"), "WinDbg MSIX")); + } + candidates.Add((Path.Combine(ControlPaths.DataRoot, "tools", "windbg", "amd64"), "portable WinDbg MSIX")); + candidates.Add((Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "WinDbg", "amd64"), "WinDbg")); + candidates.Add((Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Windows Kits", "10", "Debuggers", "x64"), "Windows Kits (legacy fallback)")); + var found = new List(); + foreach (var (dir, source) in candidates.DistinctBy(c => c.Path, StringComparer.OrdinalIgnoreCase)) + { + var kd = Path.Combine(dir, "kd.exe"); var cdb = Path.Combine(dir, "cdb.exe"); + if (!File.Exists(kd) && !File.Exists(cdb)) continue; + found.Add(new(dir, source, File.Exists(kd) ? kd : null, File.Exists(cdb) ? cdb : null, + FileVersionInfo.GetVersionInfo(File.Exists(kd) ? kd : cdb).FileVersion)); + } + return found; + } + + public async Task ResolveAsync(string exe, string? explicitDirectory, CancellationToken ct) + { + if (!string.IsNullOrWhiteSpace(explicitDirectory)) + { + var path = Path.Combine(ControlService.FullPath(explicitDirectory), exe); + if (!File.Exists(path)) throw new FileNotFoundException($"{exe} is missing from the specified directory.", path); + return path; + } + var found = await DiscoverAsync(ct); + return (exe == "kd.exe" ? found.Select(x => x.Kd) : found.Select(x => x.Cdb)).FirstOrDefault(x => x != null) + ?? throw new FileNotFoundException("Install current Microsoft WinDbg or set HYPERV_CONTROL_DEBUGGER_DIR to its amd64 directory. Run hyperv_debugger_discover for diagnostics."); + } + + public async Task StartAsync(string mode, string? connect, int? pid, string? executable, + string[]? arguments, string? debuggerDirectory, CancellationToken ct, string? symbolPath = null) + { + if (sessions.Count >= 8) throw new InvalidOperationException("Close a debugger session before opening more than eight."); + var engine = await ResolveAsync(mode == "kernel" ? "kd.exe" : "cdb.exe", debuggerDirectory, ct); + var args = new List { "-noshell" }; + switch (mode) + { + case "kernel": + if (connect is null || !(connect.StartsWith("net:", StringComparison.OrdinalIgnoreCase) || connect.StartsWith("com:pipe,", StringComparison.OrdinalIgnoreCase))) + throw new ArgumentException("Use a KDNET net: or KDCOM com:pipe, attach string. Local host kernel debugging is not exposed."); + args.AddRange(["-k", connect]); break; + case "attach": + if (pid is null or <= 0) throw new ArgumentException("Positive target PID required."); + args.AddRange(["-p", pid.Value.ToString()]); break; + case "launch": + if (executable is null || !File.Exists(ControlService.FullPath(executable))) throw new ArgumentException("Existing target executable required."); + args.AddRange(["-noinh", "-2"]); // Keep a console debuggee out of the debugger's interrupt console. + args.Add(executable); args.AddRange(arguments ?? []); break; + case "dump": + if (executable is null || !File.Exists(ControlService.FullPath(executable))) throw new ArgumentException("Existing dump path required."); + args.AddRange(["-z", executable]); break; + default: throw new ArgumentException("Mode must be kernel, attach, launch or dump."); + } + var session = new DebugSession(engine, args, mode, symbolPath); + if (!sessions.TryAdd(session.Id, session)) { session.Dispose(); throw new InvalidOperationException("Session collision."); } + await Task.Delay(300, ct); + return new { success = !session.Process.HasExited || session.Process.ExitCode == 0, sessionId = session.Id, mode, debugger = engine, version = FileVersionInfo.GetVersionInfo(engine).FileVersion, + result = session.Read(0), note = "Poll for connection and initial break before commands. A process start is not proof of a debugger attachment." }; + } + + private DebugSession Get(string id) => sessions.TryGetValue(id, out var s) ? s : throw new ArgumentException("Unknown debugger session ID."); + public object List() => sessions.Values.Select(s => new { sessionId = s.Id, mode = s.Mode, state = s.Read(0) }).ToArray(); + public DebugOutput Poll(string id, long cursor) => Get(id).Read(cursor); + public Task CommandAsync(string id, string command, int waitMs, CancellationToken ct) => Get(id).CommandAsync(command, waitMs, ct); + public async Task BreakAsync(string id, CancellationToken ct) + { + var s = Get(id); + if (s.Process.HasExited) return s.Read(0); + var start = new ProcessStartInfo(Environment.ProcessPath!) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true }; + start.ArgumentList.Add("--debug-interrupt"); + start.ArgumentList.Add(s.Process.Id.ToString()); + using var wake = Process.Start(start)!; + var output = wake.StandardOutput.ReadToEndAsync(); var error = wake.StandardError.ReadToEndAsync(); + wake.StandardInput.Close(); + try { await wake.WaitForExitAsync(ct).WaitAsync(TimeSpan.FromSeconds(10), ct); } + catch { try { wake.Kill(); } catch (InvalidOperationException) { } throw; } + if (wake.ExitCode != 0) throw new InvalidOperationException("Debugger console interrupt failed: " + await error + await output); + var deadline = Stopwatch.StartNew(); + while (s.Read(0).Pending && deadline.Elapsed < TimeSpan.FromSeconds(5)) await Task.Delay(100, ct); + return s.Read(0); + } + public async Task CloseAsync(string id, CancellationToken ct) + { + var s = Get(id); + if (!s.Process.HasExited) + { + await BreakAsync(id, ct); + await s.Process.StandardInput.WriteLineAsync(".detach\nq"); + await s.Process.StandardInput.FlushAsync(ct); + try { await s.Process.WaitForExitAsync(ct).WaitAsync(TimeSpan.FromSeconds(5), ct); } + catch (TimeoutException) { throw new TimeoutException("Debugger has not detached. Session retained; inspect/poll and retry close."); } + } + var final = s.Read(0); + sessions.TryRemove(id, out _); s.Dispose(); + return new { closed = true, result = final }; + } + public void Dispose() + { + // Interrupt/detach independent sessions together so client EOF cleanup stays bounded. + Task.WhenAll(sessions.Values.Select(s => Task.Run(s.Dispose))).GetAwaiter().GetResult(); + sessions.Clear(); + } + + private sealed class DebugSession : IDisposable + { + public string Id { get; } = Guid.NewGuid().ToString("N"); + public string Mode { get; } + public string Engine { get; } + public Process Process { get; } + private readonly StringBuilder output = new(); + private readonly object gate = new(); + private readonly SemaphoreSlim commandGate = new(1, 1); + private long dropped; + private string? pendingMarker; + private const int Limit = 1_000_000; + + public DebugSession(string engine, IEnumerable args, string mode, string? symbolPath) + { + Mode = mode; Engine = engine; + var start = new ProcessStartInfo(Environment.ProcessPath!) { UseShellExecute = false, CreateNoWindow = true, + RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, + WorkingDirectory = Path.GetDirectoryName(engine)! }; + start.ArgumentList.Add("--debug-console"); + var symbols = symbolPath ?? Environment.GetEnvironmentVariable("_NT_SYMBOL_PATH") + ?? $"srv*{Path.Combine(ControlPaths.DataRoot, "symbols")}*https://msdl.microsoft.com/download/symbols"; + Process = Process.Start(start) ?? throw new InvalidOperationException("Debugger did not start."); + Process.StandardInput.WriteLine(JsonSerializer.Serialize(new DebuggerConsoleRequest(engine, args.ToArray(), symbols), Program.Json)); + Process.StandardInput.Flush(); + _ = PumpAsync(Process.StandardOutput); _ = PumpAsync(Process.StandardError); + } + private async Task PumpAsync(StreamReader reader) + { + var buffer = new char[4096]; + try + { + int count; + while ((count = await reader.ReadAsync(buffer)) > 0) + { + lock (gate) + { + output.Append(buffer, 0, count); + if (pendingMarker != null && Regex.IsMatch(output.ToString(), "(?m)^(?:[^\\r\\n>]{1,40}>[ \\t]*)?" + Regex.Escape(pendingMarker) + "\\r?$")) pendingMarker = null; + if (output.Length > Limit) { var remove = output.Length - Limit; output.Remove(0, remove); dropped += remove; } + } + } + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException) { } + } + public DebugOutput Read(long cursor) + { + lock (gate) + { + var end = dropped + output.Length; + if (cursor < 0 || cursor > end) throw new ArgumentOutOfRangeException(nameof(cursor)); + var begin = (int)(Math.Max(cursor, dropped) - dropped); + return new(Id, Process.Id, Process.HasExited, Process.HasExited ? Process.ExitCode : null, + !Process.HasExited && pendingMarker != null, end, cursor < dropped, output.ToString(begin, output.Length - begin)); + } + } + public async Task CommandAsync(string command, int waitMs, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(command) || command.Length > 32768) throw new ArgumentException("Debugger command must contain 1-32768 characters."); + if (waitMs is < 0 or > 30000) throw new ArgumentException("waitMs must be 0-30000."); + await commandGate.WaitAsync(ct); + try + { + long cursor; + lock (gate) + { + if (pendingMarker != null) throw new InvalidOperationException("A debugger command is still pending. Poll or break the target before submitting another command."); + if (Process.HasExited) throw new InvalidOperationException("Debugger has exited."); + cursor = dropped + output.Length; + pendingMarker = "HVC_END_" + Guid.NewGuid().ToString("N"); + } + var marker = pendingMarker; + await Process.StandardInput.WriteLineAsync(command + "\n.echo " + marker); + await Process.StandardInput.FlushAsync(ct); + var watch = Stopwatch.StartNew(); + while (watch.ElapsedMilliseconds < waitMs) + { + var result = Read(cursor); + if (!result.Pending || result.Exited) return result; + await Task.Delay(50, ct); + } + return Read(cursor); + } + finally { commandGate.Release(); } + } + public void Dispose() + { + if (!Process.HasExited) + { + try + { + var start = new ProcessStartInfo(Environment.ProcessPath!) { UseShellExecute = false, CreateNoWindow = true }; + start.ArgumentList.Add("--debug-interrupt"); start.ArgumentList.Add(Process.Id.ToString()); + using var interrupt = System.Diagnostics.Process.Start(start); + if (interrupt is not null && !interrupt.WaitForExit(1500)) interrupt.Kill(true); + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) { } + try { Process.StandardInput.WriteLine(".detach\nq"); Process.StandardInput.Flush(); } catch (IOException) { } + if (!Process.WaitForExit(1500)) { try { Process.Kill(true); } catch (InvalidOperationException) { } } + } + Process.Dispose(); + } + } +} diff --git a/src/HyperVControl/Services/ElevationBridge.cs b/src/HyperVControl/Services/ElevationBridge.cs new file mode 100644 index 0000000..44f6744 --- /dev/null +++ b/src/HyperVControl/Services/ElevationBridge.cs @@ -0,0 +1,80 @@ +using System.Diagnostics; +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Security.Principal; + +namespace HyperVControl.Services; + +internal static class ElevationBridge +{ + internal static bool IsAdministrator => new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator); + + internal static async Task RunAsync() + { + var pipeName = "HyperVControl-" + Guid.NewGuid().ToString("N"); + using var pipe = new NamedPipeServerStream(pipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + var start = new ProcessStartInfo(Environment.ProcessPath!) { UseShellExecute = true, Verb = "runas", WindowStyle = ProcessWindowStyle.Hidden }; + start.ArgumentList.Add("--worker"); start.ArgumentList.Add(pipeName); + start.ArgumentList.Add(Environment.ProcessId.ToString()); + using var worker = Process.Start(start) ?? throw new InvalidOperationException("Windows did not start the elevated worker."); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(90)); + await pipe.WaitForConnectionAsync(timeout.Token); + if (!GetNamedPipeClientProcessId(pipe.SafePipeHandle.DangerousGetHandle(), out var client) || client != worker.Id) + throw new InvalidOperationException("Unexpected elevation bridge client."); + using var stop = new CancellationTokenSource(); + var input = Console.OpenStandardInput().CopyToAsync(pipe, stop.Token); + var output = pipe.CopyToAsync(Console.OpenStandardOutput(), stop.Token); + await Task.WhenAny(input, output, worker.WaitForExitAsync()); + if (worker.HasExited && worker.ExitCode != 0) + throw new InvalidOperationException($"Elevated worker exited with code {worker.ExitCode}. See {Path.Combine(ControlPaths.DataRoot, "worker-startup-error.txt")}."); + if (input.IsCompletedSuccessfully) + { + // EOF from an MCP client also closes its worker connection. + pipe.Disconnect(); + } + stop.Cancel(); + if (!worker.HasExited) + { + try { await worker.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); } + catch (TimeoutException) { worker.Kill(true); } + } + } + + internal static async Task ConnectWorkerAsync(string name, int parentId) + { + if (!IsAdministrator || !name.StartsWith("HyperVControl-", StringComparison.Ordinal)) + throw new InvalidOperationException("Invalid elevated worker invocation."); + // CurrentUserOnly on a CLIENT compares token Owner, which can become the + // Administrators group after UAC. The server ACL stays CurrentUserOnly; + // authenticate the actual server process User SID below instead of Owner. + var pipe = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous); + await pipe.ConnectAsync(30000); + if (!GetNamedPipeServerProcessId(pipe.SafePipeHandle.DangerousGetHandle(), out var server) || server != parentId || !IsSameUserProcess(parentId)) + { pipe.Dispose(); throw new InvalidOperationException("Unexpected elevation bridge server."); } + return pipe; + } + + private static bool IsSameUserProcess(int pid) + { + using var process = Process.GetProcessById(pid); + if (!OpenProcessToken(process.Handle, 0x0008, out var token)) return false; + try + { + using var identity = new WindowsIdentity(token); + using var current = WindowsIdentity.GetCurrent(); + return identity.User == current.User; + } + finally { CloseHandle(token); } + } + + [DllImport("advapi32", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] private static extern bool OpenProcessToken(nint process, uint desiredAccess, out nint token); + [DllImport("kernel32")] + [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CloseHandle(nint handle); + + [DllImport("kernel32", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetNamedPipeClientProcessId(nint pipe, out uint id); + [DllImport("kernel32", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetNamedPipeServerProcessId(nint pipe, out uint id); +} diff --git a/src/HyperVControl/Services/GuestDebugWorker.cs b/src/HyperVControl/Services/GuestDebugWorker.cs new file mode 100644 index 0000000..96ef7ef --- /dev/null +++ b/src/HyperVControl/Services/GuestDebugWorker.cs @@ -0,0 +1,108 @@ +using System.IO.Pipes; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace HyperVControl.Services; + +internal sealed record GuestDebugConfiguration(string WorkerId, string Mode, string DebuggerDirectory, int? ProcessId, + string? Path, string[]? Arguments, string TaskName); + +internal static class GuestDebugWorker +{ + internal static string PipeName(string id) => Regex.IsMatch(id, "^[0-9a-f]{32}$") + ? "HyperVControl-Debug-" + id : throw new ArgumentException("Invalid guest worker ID."); + + internal static async Task RunAsync(string configPath) + { + var config = JsonSerializer.Deserialize(await File.ReadAllTextAsync(configPath), Program.Json) + ?? throw new ArgumentException("Invalid debug configuration."); + File.Delete(configPath); + using var debugger = new DebuggerService(new()); + using var lifetime = new CancellationTokenSource(TimeSpan.FromHours(1)); + object started; + try { started = await debugger.StartAsync(config.Mode, null, config.ProcessId, config.Path, config.Arguments, config.DebuggerDirectory, lifetime.Token); } + catch (Exception ex) { started = new { ok = false, error = ex.Message }; } + var startup = JsonSerializer.SerializeToElement(started, Program.Json); + var sessionId = startup.TryGetProperty("sessionId", out var s) ? s.GetString() : null; + try + { + while (!lifetime.IsCancellationRequested) + { + using var pipe = new NamedPipeServerStream(PipeName(config.WorkerId), PipeDirection.InOut, 1, + PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + await pipe.WaitForConnectionAsync(lifetime.Token); + using var reader = new StreamReader(pipe, Encoding.UTF8, false, 4096, true); + using var writer = new StreamWriter(pipe, new UTF8Encoding(false), 4096, true) { AutoFlush = true }; + using var requestTimeout = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token); + requestTimeout.CancelAfter(TimeSpan.FromSeconds(45)); + var line = await reader.ReadLineAsync(requestTimeout.Token); + if (line is null) continue; + var close = false; + object result; + try + { + var request = JsonDocument.Parse(line).RootElement; + var action = request.GetProperty("action").GetString(); + if (action == "status") result = started; + else if (sessionId == null) result = started; + else result = action switch + { + "poll" => debugger.Poll(sessionId, request.TryGetProperty("cursor", out var cursor) ? cursor.GetInt64() : 0), + "command" => await debugger.CommandAsync(sessionId, request.GetProperty("command").GetString()!, request.TryGetProperty("waitMs", out var wait) ? wait.GetInt32() : 10000, requestTimeout.Token), + "break" => await debugger.BreakAsync(sessionId, requestTimeout.Token), + "close" => await debugger.CloseAsync(sessionId, requestTimeout.Token), + _ => throw new ArgumentException("Use status/poll/command/break/close.") + }; + close = action == "close"; + } + catch (Exception ex) { result = new { ok = false, error = ex.Message }; } + await writer.WriteLineAsync(JsonSerializer.Serialize(result, Program.Json)); + if (close) break; + } + } + catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { } + finally + { + try { await new PowerShellRunner().RunAsync("Unregister-ScheduledTask -TaskName " + PowerShellRunner.Literal(config.TaskName) + " -Confirm:$false -ErrorAction SilentlyContinue", CancellationToken.None, 15); } + catch { /* The task name is also returned so failed cleanup is recoverable. */ } + } + return 0; + } + + internal static async Task ClientAsync(string id) + { + using var pipe = new NamedPipeClientStream(".", PipeName(id), PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(40)); + await pipe.ConnectAsync(10000, timeout.Token); + using var reader = new StreamReader(pipe, Encoding.UTF8, false, 4096, true); + using var writer = new StreamWriter(pipe, new UTF8Encoding(false), 4096, true) { AutoFlush = true }; + var request = await Console.In.ReadToEndAsync(timeout.Token); + await writer.WriteLineAsync(request); + var response = await reader.ReadLineAsync(timeout.Token) ?? throw new IOException("Guest debug worker disconnected."); + Console.WriteLine(response); + return 0; + } + + internal static async Task RemoteAsync(ControlService service, Guid vmId, string workerId, object request, string profile, CancellationToken ct) + { + PipeName(workerId); + var payload = JsonSerializer.Serialize(request, Program.Json); + var script = $$""" + $psi=[Diagnostics.ProcessStartInfo]::new('C:\ProgramData\HyperVControl\debug\HyperVControl.exe') + $psi.Arguments='--debug-rpc {{workerId}}' + $psi.UseShellExecute=$false; $psi.CreateNoWindow=$true + $psi.RedirectStandardInput=$true; $psi.RedirectStandardOutput=$true; $psi.RedirectStandardError=$true + $p=[Diagnostics.Process]::Start($psi) + $out=$p.StandardOutput.ReadToEndAsync(); $err=$p.StandardError.ReadToEndAsync() + $p.StandardInput.Write({{PowerShellRunner.DecodeUtf8(payload)}}); $p.StandardInput.Close() + try { + if(-not $p.WaitForExit(45000)) { $p.Kill(); throw 'Guest debugger RPC timeout' } + if($p.ExitCode -ne 0) { throw $err.Result } + $out.Result + } finally { $p.Dispose() } + """; + var response = await service.Guest.InvokeAsync(vmId, script, service.Credentials.Resolve(vmId, profile), ct, 60); + return JsonDocument.Parse(response.GetProperty("output").GetString()!).RootElement.Clone(); + } +} diff --git a/src/HyperVControl/Services/GuestService.cs b/src/HyperVControl/Services/GuestService.cs new file mode 100644 index 0000000..284ab2b --- /dev/null +++ b/src/HyperVControl/Services/GuestService.cs @@ -0,0 +1,100 @@ +using System.Text.Json; +using HyperVControl.Extracted; + +namespace HyperVControl.Services; + +internal sealed class GuestService(PowerShellRunner runner, CredentialStore credentials) +{ + internal static string Session(Guid id, GuestCredential c) => $$""" + $guestUser = {{PowerShellRunner.DecodeUtf8(c.UserName)}} + $guestPassword = ConvertTo-SecureString ({{PowerShellRunner.DecodeUtf8(c.Password)}}) -AsPlainText -Force + $credential = [pscredential]::new($guestUser, $guestPassword) + $session = New-PSSession -VMId ([guid]'{{id:D}}') -Credential $credential -ErrorAction Stop + """ + "\n"; + + public async Task RunTextAsync(Guid id, string script, string profile, CancellationToken ct) + { + try + { + var result = await InvokeAsync(id, script, credentials.Resolve(id, profile), ct, 300); + return new(true, result.GetProperty("output").GetString(), null); + } + catch (Exception ex) when (ex is not OperationCanceledException) { return new(false, null, ex.Message); } + } + + internal Task InvokeAsync(Guid id, string script, GuestCredential c, CancellationToken ct, int timeout = 120) => + runner.JsonAsync(Session(id, c) + $$""" + try { + $text = Invoke-Command -Session $session -ErrorAction Stop -ScriptBlock { + param($encoded) + $ErrorActionPreference='Stop' + & ([scriptblock]::Create([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)))) | Out-String -Width 32768 + } -ArgumentList '{{PowerShellRunner.Encoded(script)}}' + @{ output=[string]$text } | ConvertTo-Json -Compress -Depth 8 + } finally { Remove-PSSession -Session $session -ErrorAction SilentlyContinue } + """, ct, timeout); + + public async Task RunAsync(Guid id, string script, string profile, int timeoutSeconds, CancellationToken ct) + { + var inner = BuildRunScript(script, timeoutSeconds); + var result = await InvokeAsync(id, inner, credentials.Resolve(id, profile), ct, timeoutSeconds + 45); + return JsonDocument.Parse(result.GetProperty("output").GetString()!).RootElement.Clone(); + } + + // Kept separate from transport so the exact guest process logic can be exercised + // under Windows PowerShell without mutating a VM. + internal static string BuildRunScript(string script, int timeoutSeconds) + { + if (timeoutSeconds is < 1 or > 300) throw new ArgumentOutOfRangeException(nameof(timeoutSeconds), "Use 1-300 seconds."); + if (script.Length > 200000) throw new ArgumentException("Guest script exceeds 200,000 characters."); + var childScript = "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Stop'\n" + script; + return $$""" + $p=$null + try { + # Windows PowerShell's .NET Framework has no StandardInputEncoding + # property; redirected input inherits Console.InputEncoding instead. + [Console]::InputEncoding=[Text.UTF8Encoding]::new($false) + $psi=[Diagnostics.ProcessStartInfo]::new("$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe") + # Only a fixed bootstrap goes on the command line. Script length never + # contributes to Windows' CreateProcess command-line limit. + $psi.Arguments='-NoLogo -NoProfile -NonInteractive -OutputFormat Text -EncodedCommand {{PowerShellRunner.Encoded(PowerShellRunner.StdinBootstrap)}}' + $psi.UseShellExecute=$false; $psi.CreateNoWindow=$true + $psi.RedirectStandardInput=$true + $psi.RedirectStandardOutput=$true; $psi.RedirectStandardError=$true + $psi.StandardOutputEncoding=[Text.Encoding]::UTF8; $psi.StandardErrorEncoding=[Text.Encoding]::UTF8 + $p=[Diagnostics.Process]::Start($psi) + $outTask=$p.StandardOutput.ReadToEndAsync(); $errTask=$p.StandardError.ReadToEndAsync() + try { $p.StandardInput.Write({{PowerShellRunner.DecodeUtf8(childScript)}}) } + finally { $p.StandardInput.Close() } + $done=$p.WaitForExit({{timeoutSeconds * 1000}}) + if (-not $done) { & taskkill.exe /PID $p.Id /T /F 2>&1 | Out-Null; $p.WaitForExit(5000) | Out-Null } + $out=[string]$outTask.Result + $err=[string]$errTask.Result + $truncated=$out.Length -gt 400000 -or $err.Length -gt 100000 + if ($out.Length -gt 400000) { $out=$out.Substring(0,400000) } + if ($err.Length -gt 100000) { $err=$err.Substring(0,100000) } + @{ok=($done -and $p.ExitCode -eq 0); exitCode= $(if($done){$p.ExitCode}else{-1}); timedOut=(-not $done); stdout=$out; stderr=$err; truncated=$truncated} | ConvertTo-Json -Compress + } finally { if ($p) { $p.Dispose() } } + """; + } + + public async Task CopyAsync(Guid id, string source, string destination, bool fromGuest, string profile, CancellationToken ct) + { + ControlService.FullPath(source); ControlService.FullPath(destination); + if (fromGuest) + { + var full = Path.GetFullPath(destination); + foreach (var folder in new[] { Environment.SpecialFolder.Windows, Environment.SpecialFolder.ProgramFiles, Environment.SpecialFolder.ProgramFilesX86 }) + { + var blocked = Environment.GetFolderPath(folder); + if (blocked.Length > 0 && (full.Equals(blocked, StringComparison.OrdinalIgnoreCase) || full.StartsWith(blocked.TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase))) + throw new ArgumentException("Choose a host destination outside Windows and Program Files."); + } + } + var c = credentials.Resolve(id, profile); + var script = HyperVGuestUiService.BuildHostFileCopyScript(id, c.UserName, c.Password, source, destination, fromGuest); + var result = await runner.JsonAsync(script, ct, 300); + if (!result.GetProperty("success").GetBoolean()) throw new InvalidOperationException(result.GetProperty("error").GetString()); + return JsonSerializer.SerializeToElement(new { success = true, bytesCopied = long.Parse(result.GetProperty("exePath").GetString()!), source, destination }); + } +} diff --git a/src/HyperVControl/Services/PowerShellRunner.cs b/src/HyperVControl/Services/PowerShellRunner.cs new file mode 100644 index 0000000..fc07e6d --- /dev/null +++ b/src/HyperVControl/Services/PowerShellRunner.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; + +namespace HyperVControl.Services; + +internal sealed record ProcessResult(int ExitCode, string Stdout, string Stderr); + +internal static class ControlPaths +{ + internal static string DataRoot => Environment.GetEnvironmentVariable("HYPERV_CONTROL_DATA") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "HyperVControl"); +} + +internal sealed class PowerShellRunner +{ + internal static string Literal(string value) + { + // Match PowerShell's IsSingleQuote, including its four Unicode delimiters. + // Doubling the original character preserves the value instead of normalizing it. + // https://github.com/PowerShell/PowerShell/blob/master/src/System.Management.Automation/engine/parser/CharTraits.cs + var quoted = new StringBuilder(value.Length + 2).Append('\''); + foreach (var character in value) + { + if (character is '\'' or '\u2018' or '\u2019' or '\u201a' or '\u201b') + quoted.Append(character); + quoted.Append(character); + } + return quoted.Append('\'').ToString(); + } + + internal const string StdinBootstrap = + "[Console]::InputEncoding=[Text.UTF8Encoding]::new($false); $s=[Console]::In.ReadToEnd(); & ([scriptblock]::Create($s))"; + internal static string Encoded(string value) => Convert.ToBase64String(Encoding.Unicode.GetBytes(value)); + internal static string DecodeUtf8(string value) => + "[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + Convert.ToBase64String(Encoding.UTF8.GetBytes(value)) + "'))"; + + public async Task RunAsync(string script, CancellationToken ct, int timeoutSeconds = 120) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct); + deadline.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); + var start = new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe")) + { + UseShellExecute = false, CreateNoWindow = true, + RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, + StandardInputEncoding = new UTF8Encoding(false), StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 + }; + foreach (var arg in new[] { "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", StdinBootstrap }) + start.ArgumentList.Add(arg); + using var process = Process.Start(start) ?? throw new InvalidOperationException("Cannot start Windows PowerShell."); + // PowerShell scripts and guest credentials travel on stdin, never its process command line. + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + try + { + await process.StandardInput.WriteAsync(("[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)\n" + script).AsMemory(), deadline.Token); + process.StandardInput.Close(); + await process.WaitForExitAsync(deadline.Token); + return new(process.ExitCode, await stdout, await stderr); + } + catch (OperationCanceledException) + { + try { process.Kill(true); } catch (InvalidOperationException) { } + if (ct.IsCancellationRequested) throw; + throw new TimeoutException($"PowerShell operation exceeded {timeoutSeconds}s. Inspect the VM before retrying; a completed guest mutation is not rolled back."); + } + } + + public async Task JsonAsync(string script, CancellationToken ct, int timeoutSeconds = 120) + { + var result = await RunAsync(""" + $ErrorActionPreference='Stop' + $ProgressPreference='SilentlyContinue' + $WarningPreference='SilentlyContinue' + try { + """ + "\n" + script + "\n} catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }", ct, timeoutSeconds); + if (result.ExitCode != 0) throw new InvalidOperationException(result.Stderr.Trim()); + try { return JsonDocument.Parse(result.Stdout).RootElement.Clone(); } + catch (JsonException ex) { throw new InvalidOperationException("PowerShell returned an invalid JSON result: " + result.Stdout[..Math.Min(2000, result.Stdout.Length)], ex); } + } +} diff --git a/src/HyperVControl/Services/VmLease.cs b/src/HyperVControl/Services/VmLease.cs new file mode 100644 index 0000000..bdedabb --- /dev/null +++ b/src/HyperVControl/Services/VmLease.cs @@ -0,0 +1,13 @@ +namespace HyperVControl.Services; + +/// File-handle ownership releases automatically when an MCP worker exits. +internal static class VmLease +{ + internal static FileStream Acquire(Guid id, string purpose) + { + var directory = Path.Combine(ControlPaths.DataRoot, "locks"); + Directory.CreateDirectory(directory); + try { return new FileStream(Path.Combine(directory, $"{purpose}-{id:D}.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); } + catch (IOException ex) { throw new InvalidOperationException($"Another Hyper-V Control session owns this VM's {purpose} channel. Close that session before taking control.", ex); } + } +} diff --git a/src/HyperVControl/app.manifest b/src/HyperVControl/app.manifest new file mode 100644 index 0000000..160f3dd --- /dev/null +++ b/src/HyperVControl/app.manifest @@ -0,0 +1,10 @@ + + + + + + + + PerMonitorV2 + + diff --git a/src/HyperVControl/packages.lock.json b/src/HyperVControl/packages.lock.json new file mode 100644 index 0000000..046c28c --- /dev/null +++ b/src/HyperVControl/packages.lock.json @@ -0,0 +1,339 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows7.0": { + "Microsoft.Extensions.Hosting": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "tL9FkfV64GPUDSPvwrgyw42LVzsnVAnyrqJEuZVJbODgrQ3eL63zmzEcVWoCHzfgqUhWggzbgAyUCnz/zfI3Pg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.10", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.10", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.10", + "Microsoft.Extensions.Configuration.Json": "10.0.10", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.10", + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Diagnostics": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Physical": "10.0.10", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Configuration": "10.0.10", + "Microsoft.Extensions.Logging.Console": "10.0.10", + "Microsoft.Extensions.Logging.Debug": "10.0.10", + "Microsoft.Extensions.Logging.EventLog": "10.0.10", + "Microsoft.Extensions.Logging.EventSource": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" + }, + "ModelContextProtocol": { + "type": "Direct", + "requested": "[2.2.0, )", + "resolved": "2.2.0", + "contentHash": "4Pb9u02Nwsp0poueDsqNdyGRojFxOYpljB7zDBsq+aHL+Afou3OgxlBc3GWFVnsRMRJrUtWqDh3s6k2JgPzmrQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.10", + "ModelContextProtocol.Core": "[2.2.0]" + } + }, + "System.Management": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "U13ksdXfQlF2WDdu3S4ZPVpikFs2HgV3dbJJZLeZpBIQ4z/xCmQnwEf6LsEEIC2zSamVVa1acA9RdVhZwnWKeA==" + }, + "Microsoft.Extensions.AI.Abstractions": { + "type": "Transitive", + "resolved": "10.8.3", + "contentHash": "K0B05oApxmviWalNHPMBBcRC7erKiDATz3ENNR/jqTR9JwIwLRefgDhj2jCRwL1aca99pXUe0qyQC73/xIuZig==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "plJWK2zpWuuyxI8F8s2scx6Je7N1Ajjs6HvYUGKwRnDMWIVIz9FHwAkiT7ASgrvAOd10T0FPVlh9BzAJJME+jg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "GqmN2o1CkJvk7uWp+p4CwBYW0w/zfoEbvsiFDbO2G8l1Uz+mrDAbAcZiXhU2lufKPby1cjAUdd5GTWpebYOkOA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "33cBeR2HRbzHUTtmcmLdNOApneNGcymwwL4arHuotgVK9Frba8kcDTrvVTj7cSCmF1R9OiSbZH0KxNOwab3HUg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "KRfFSSCV58vEdU7mPED/YMzeovIWF5P0g8s9K8n9HEfy0/WzMq37SrPdXdFN5/dFT/rPMHpF7AvpoXHckbcBFg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "ZOhZYwvbXGTgGVRwswIirofEMVHuWdxjdh0JeUZXwaF9cgcjXdz/t0ELtgaevw7ezTyv47yPNCgGreWtLkn3IQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Physical": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "uvJ6sHwjgrkMEJOgiC76G0mcZGXerwyyWkwX34EOjCbxKG6TCtfAoqDKAMsCvEBf9HxjlGQEgqsSMOGCmGBf+A==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "1s1sKFTk/Foam64JY6+m/diH8drL3Wx6V3gtSd5v1IEZtszZYyc1pW8uRnMblzpNiR0l0t8gGk7tXj3xHzFgdg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Json": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Physical": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "Kr/e7lUf4+N8tacbqJ2Ctwe/HarKdAc9ZkgKVVqvtJDBKbez+T/KnUwu82KSlnBp/SrpBcxc7u7xkE2oUZT/5Q==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.10" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "9uWiKpeOVac355STyChWR/pliFX/5CeLqChW9kKsaxyDH4EUTZxMkT4Jwp/J/peLm0GBFmSX5c0WCse3yCnq1Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "c5zqFCY9DiIpMovLd7/d/CTiEtrMOuQ639dhv3PABtKQIKNQikSHwQt8+N679uii9q+B55lgK28Uv64FOwEu8w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "jhJAyo38kSrH3ARvWUk0h8itogVnQu2DCZuPo+s0Z+tXes0ugTxMPaHYzap85785eHQmPFqD9TYERqBbtGxn/w==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "jSOCVxEwCd4Aq925kJVz1kSO1EpX2OHYKL04qVREXkDU7Ce3pVDdHPYm+fEy8y/th2kJf/DAstRHpJAqoNWP8w==" + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5LugpYGHk+mkn0a8IZgcyfBca8PCTAU9RQFoMrTdtOOidq88M2SI5f3px6ugnzgxC+eTkvYYJi8pzlUnG5xdAQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "cLrqxkuEfcilZ8SjK+9KAnpLk9lOoMPaOokF+wRUYie+iUEcdX4/p/+gJkt0BYgWLthjpBUCkVTBI6Kxg0nsOw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "VIlNzPwPS0GeQVSmCqqo36ugryX3LpE9ul6gEkks5VLET3weH/XMLeWmclwfoGn4Nxi2mwVibB+OZBVJ9tDqvg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Configuration": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "8+TZBnV5fgBXoVNJ5ROSErUwYogk4hOgV7c2HWK1u5cqKGmiUTUn7+KqZ35iQu8e/B7Ykccyz5OTjdXcidNZ9g==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "0RE4951AzQ+YD4gVrvbq0BhdsiBgSDo44yM7+QBZ2mrmMJeNjY+teCIYfUjqDPVYnKs0HR6SkkhgrX1YgXZq3Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "85SAPwXhJtdBInzN2k7SChiFiBGh3KOWay5AfoY+GREF6P7oZA98+ST2p7Z9384iLKYjkZSKIZ/FqIO5aojtNw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "tnBmu/LwF25ZQK+HBNCu2xrwnkKoB/XEbJyooGGoYxHrhvxbSKi7eOFiJ4AXBy/QU4vtCvCJfoi8k9Ej72qzOQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + }, + "ModelContextProtocol.Core": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "FeBfXU6T8k+jw4afg4sfxdEX2rL/e5oKOk9ROOGztu9k47+7Bz08sdaToYt2XvMY1opNbwxYQOFMj6wH9TInhA==", + "dependencies": { + "Microsoft.Extensions.AI.Abstractions": "10.8.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10" + } + } + }, + "net10.0-windows7.0/win-x64": { + "System.Management": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "U13ksdXfQlF2WDdu3S4ZPVpikFs2HgV3dbJJZLeZpBIQ4z/xCmQnwEf6LsEEIC2zSamVVa1acA9RdVhZwnWKeA==" + } + } + } +} diff --git a/tests/HyperVControl.Tests/HyperVControl.Tests.csproj b/tests/HyperVControl.Tests/HyperVControl.Tests.csproj new file mode 100644 index 0000000..874d271 --- /dev/null +++ b/tests/HyperVControl.Tests/HyperVControl.Tests.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0-windows + enable + enable + win-x64 + true + + + diff --git a/tests/HyperVControl.Tests/Program.cs b/tests/HyperVControl.Tests/Program.cs new file mode 100644 index 0000000..97b53e4 --- /dev/null +++ b/tests/HyperVControl.Tests/Program.cs @@ -0,0 +1,224 @@ +using System.Text.Json; +using HyperVControl.Extracted; +using HyperVControl.Services; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +var root = Path.GetFullPath(args.Length > 1 ? args[1] : "."); +var results = Path.Combine(root, "build", "hyperv-control", "validation"); +Directory.CreateDirectory(results); +void Check(bool condition, string message) { if (!condition) throw new Exception(message); Console.WriteLine("PASS " + message); } + +if (args.Length == 0 || args[0] == "unit") +{ + Check(Rgb565Pixels.ToBgra32([0, 0, 255, 255, 0, 248, 224, 7, 31, 0], 5, 1).SequenceEqual(new byte[] + { 0,0,0,255, 255,255,255,255, 0,0,255,255, 0,255,0,255, 255,0,0,255 }), "RGB565 black/white/RGB channels"); + try { Rgb565Pixels.ToBgra32([0], 1, 1); throw new Exception("accepted short frame"); } catch (ArgumentException) { Console.WriteLine("PASS truncated framebuffer rejected"); } + Check(Rgb565Pixels.ToBgra32([255,255,0,0,0,0], 1, 1).SequenceEqual(new byte[] {255,255,255,255}), "Hyper-V framebuffer trailer tolerated"); + Check(PowerShellRunner.Literal("x'; $pwn = 1; '") == "'x''; $pwn = 1; '''", "PowerShell data quoting"); + var runner = new PowerShellRunner(); + var value = "空间 ' quoted ; $(Write-Output PWN) ` literal"; + var roundtrip = await runner.JsonAsync("@{v=" + PowerShellRunner.Literal(value) + "} | ConvertTo-Json -Compress", default); + Check(roundtrip.GetProperty("v").GetString() == value, "PowerShell Unicode/injection roundtrip"); + try { await runner.RunAsync("Start-Sleep -Seconds 30", default, 1); throw new Exception("no timeout"); } catch (TimeoutException) { Console.WriteLine("PASS child process timeout"); } + Check(HyperVGuestUiService.ValidateUiCommand("inspect") == "inspect", "UI inspection routing"); + try { HyperVGuestUiService.ValidateUiCommand("send-keys"); throw new Exception("accepted unsupported UI input"); } catch (ArgumentException) { Console.WriteLine("PASS UI keyboard redirects to console"); } + Check(HyperVGuestUiService.BuildWindowsArgumentLine(["one two", "a\"b", @"C:\tail space\"]).Contains("\\\""), "Windows argv quote escaping"); + try { await HyperVControl.Tests.ReviewRegressionTests.RunAsync(Check); } + catch (Exception ex) { Console.Error.WriteLine(ex); Environment.ExitCode = 1; return; } + Console.WriteLine("UNIT PASS"); + return; +} + +var executable = Path.Combine(root, "build", "hyperv-control", "publish", "HyperVControl.exe"); +var elevated = args[0] != "smoke"; +var launcher = args[0] == "launcher"; +var transport = new StdioClientTransport(new StdioClientTransportOptions +{ + Command = launcher ? "powershell.exe" : executable, + Arguments = launcher ? ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", Path.Combine(root,"plugins","hyper-v-control","scripts","start.ps1")] : elevated ? ["--elevate"] : [], + EnvironmentVariables = new Dictionary { ["HYPERV_CONTROL_DEBUGGER_DIR"] = Path.Combine(root, "build", "hyperv-control", "windbg", "amd64") }, + StandardErrorLines = line => Console.Error.WriteLine(line) +}); +using var lifetime = new CancellationTokenSource(TimeSpan.FromMinutes(30)); +await using var client = await McpClient.CreateAsync(transport, cancellationToken: lifetime.Token); +var tools = await client.ListToolsAsync(cancellationToken: lifetime.Token); +Check(tools.Count >= 30, $"MCP initialize and {tools.Count} tools discovered"); +Check(tools.Select(t => t.Name).Distinct().Count() == tools.Count, "Unique tool names"); +await File.WriteAllTextAsync(Path.Combine(results, "tools.json"), JsonSerializer.Serialize(tools.Select(t => new {t.Name,t.Description,t.JsonSchema}), new JsonSerializerOptions {WriteIndented=true})); + +async Task Call(string name, object? arguments = null, bool allowError = false, string? label = null) +{ + var dict = arguments == null ? new Dictionary() : JsonSerializer.Deserialize>(JsonSerializer.Serialize(arguments))!; + var result = await client.CallToolAsync(name, dict, cancellationToken: lifetime.Token); + var text = string.Join("\n", result.Content.OfType().Select(b => b.Text)); + foreach (var image in result.Content.OfType()) + { + var path = Path.Combine(results, (label ?? name) + ".png"); + await File.WriteAllBytesAsync(path, image.DecodedData.ToArray()); + Console.WriteLine("IMAGE " + path); + } + await File.WriteAllTextAsync(Path.Combine(results, (label ?? name) + ".json"), text); + if (result.IsError == true && !allowError) throw new Exception(name + ": " + text); + Console.WriteLine((result.IsError == true ? "ERROR " : "PASS ") + (label ?? name) + " " + text[..Math.Min(1600, text.Length)]); + return JsonDocument.Parse(text).RootElement.Clone(); +} + +if (args[0] is "smoke" or "launcher") +{ + var invalid = await Call("hyperv_power", new {machine="__missing_hypervcontrol_test_vm__",action="delete_everything"}, true); + Check(invalid.GetProperty("ok").GetBoolean() == false, "Tool errors are structured MCP errors"); + var paths = await Call("hyperv_vm_create", new {name="not-created",path="relative",vhdPath="relative"}, true); + Check(paths.GetProperty("ok").GetBoolean() == false, "Invalid path rejected before host mutation"); + Console.WriteLine("STDIO SMOKE PASS"); + return; +} + +var machine = Environment.GetEnvironmentVariable("HYPERV_CONTROL_TEST_VM") ?? "Windows 11 AV Test"; +if (args[0] == "steps") +{ + using var steps = JsonDocument.Parse(await File.ReadAllTextAsync(args[2])); + foreach(var step in steps.RootElement.EnumerateArray()) + { + if(step.TryGetProperty("delayMs",out var delay)) { await Task.Delay(delay.GetInt32()); continue; } + await Call(step.GetProperty("name").GetString()!,step.GetProperty("arguments"), + step.TryGetProperty("allowError",out var ae)&&ae.GetBoolean(),step.TryGetProperty("label",out var l)?l.GetString():null); + } +} +else if (args[0] == "prepare") +{ + if (File.Exists(Path.Combine(results,"test-checkpoint.json"))) throw new InvalidOperationException("A test baseline already exists. Finish cleanup and archive validation output before preparing again."); + var baseline = await Call("hyperv_vm_info", new {machine}, label:"baseline-vm"); + Check(baseline.GetProperty("vm").GetProperty("State").GetInt32() == 3, "Live preparation starts with an Off VM"); + var vmId = baseline.GetProperty("vm").GetProperty("Id").GetString()!; + await Call("hyperv_checkpoints", new {machine}, label:"baseline-checkpoints"); + var name = "HyperVControl-validation-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); + var snapshot = await Call("hyperv_checkpoint", new {machine,action="create",checkpoint=name}, label:"test-checkpoint"); + Check(snapshot.GetProperty("success").GetBoolean(), "Own baseline checkpoint created"); + await Call("hyperv_power", new {machine,action="start"}); + // Read only the existing credential for the user-authorized VM; never print its contents. + var credentialFile = Environment.GetEnvironmentVariable("HYPERV_CONTROL_TEST_CREDENTIAL_FILE") + ?? throw new InvalidOperationException("Set HYPERV_CONTROL_TEST_CREDENTIAL_FILE to a per-VM credential JSON file for this explicit live test."); + using var entries = JsonDocument.Parse(await File.ReadAllTextAsync(credentialFile)); + var entry = entries.RootElement.EnumerateObject().Single(p => p.Name.Equals(vmId,StringComparison.OrdinalIgnoreCase)).Value; + Exception? last = null; + for (var i = 0; i < 12; i++) + { + try { await Call("hyperv_credential", new {machine,username=entry.GetProperty("userName").GetString(),password=entry.GetProperty("password").GetString()}); last=null; break; } + catch(Exception ex) { last=ex; await Task.Delay(5000); } + } + if(last!=null) throw last; + await Call("hyperv_guest_run",new {machine,script="[Security.Principal.WindowsIdentity]::GetCurrent().Name; whoami /groups; Get-CimInstance Win32_OperatingSystem | Select Caption,Version | ConvertTo-Json -Compress"},label:"guest-identity"); +} +else if (args[0] == "guest") +{ + var failedExit = await Call("hyperv_guest_run",new {machine,script="[Console]::WriteLine('stdout-中文'); [Console]::Error.WriteLine('stderr-marker'); exit 7"},true,"guest-exit"); + Check(failedExit.GetProperty("exitCode").GetInt32() == 7 && failedExit.GetProperty("stdout").GetString()!.Contains("stdout-中文") && failedExit.GetProperty("stderr").GetString()!.Contains("stderr-marker"), "Guest exit code and separate UTF-8 output streams"); + await Call("hyperv_guest_run",new {machine,script="'guest-success'"},label:"guest-success"); + var source=Path.Combine(results,"roundtrip 空格.txt"); + await File.WriteAllTextAsync(source,"HyperVControl UTF-8 数据 123\n"); + await Call("hyperv_copy",new {machine,direction="to_guest",source,destination=@"C:\ProgramData\HyperVControl\test 空格.txt"},label:"copy-to-guest"); + var dest=Path.Combine(results,"roundtrip-return.txt"); + await Call("hyperv_copy",new {machine,direction="from_guest",source=@"C:\ProgramData\HyperVControl\test 空格.txt",destination=dest},label:"copy-from-guest"); + var sentBytes = await File.ReadAllBytesAsync(source); + var receivedBytes = await File.ReadAllBytesAsync(dest); + Check(sentBytes.SequenceEqual(receivedBytes),"Guest file roundtrip byte equality"); + await Call("hyperv_guest_files",new {machine,path=@"C:\ProgramData\HyperVControl\test 空格.txt",action="read"}); + await Call("hyperv_console",new {machine,action="open",session="basic"}); + await Call("hyperv_screenshot",new {machine},label:"basic-before"); + await Call("hyperv_input",new {machine,action="key",text="WIN+R"}); + await Call("hyperv_input",new {machine,action="type",text="notepad"}); + await Call("hyperv_input",new {machine,action="key",text="ENTER"}); + await Task.Delay(1500); + await Call("hyperv_screenshot",new {machine},label:"basic-after"); +} +else if (args[0] == "ui") +{ + await Call("hyperv_guest_setup",new {machine}); + await Call("hyperv_ui",new {machine,command="status"},true,"ui-target-required"); + await Call("hyperv_ui",new {machine,command="list-windows"},label:"ui-windows"); +} +else if(args[0] == "final-guest") +{ + var emptyArguments = await Call("hyperv_guest_process",new {machine,executable=@"C:\Windows\System32\hostname.exe"},label:"guest-process-empty-argv"); + Check(emptyArguments.GetProperty("exitCode").GetInt32()==0,"Native guest process with no arguments"); + var nativeExit = await Call("hyperv_guest_process",new {machine,executable=@"C:\Windows\System32\cmd.exe",arguments=new[]{"/d","/c","echo argv-marker & exit /b 23"}},true,"guest-process-exit"); + Check(nativeExit.GetProperty("exitCode").GetInt32()==23 && nativeExit.GetProperty("stdout").GetString()!.Contains("argv-marker"),"Native argv and nonzero exit status"); + var paused=await Call("hyperv_power",new {machine,action="pause"},label:"pause-vm"); + Check(paused.GetProperty("machine").GetProperty("state").GetString()=="paused","Paused VM observed"); + await Call("hyperv_power",new {machine,action="resume"},label:"resume-vm"); + var saved=await Call("hyperv_power",new {machine,action="save"},label:"save-vm"); + Check(saved.GetProperty("machine").GetProperty("state").GetString()=="saved","Saved VM observed"); + await Call("hyperv_power",new {machine,action="start"},label:"resume-saved-vm"); + await Call("hyperv_guest_run",new {machine,script="'restored from saved state'"},label:"saved-state-guest-alive"); +} +else if(args[0] is "debug" or "debug-host") +{ + var start=await Call("hyperv_debug_open",new {mode="launch",path=@"C:\Windows\System32\cmd.exe",arguments=new[]{"/c","ping -n 60 127.0.0.1 >nul"},debuggerDirectory=Path.Combine(root,"build","hyperv-control","windbg","amd64")}); + var id=start.GetProperty("sessionId").GetString(); + var commandResult = await Call("hyperv_debug_command",new {sessionId=id,command=".echo HVC_DEBUG_READY; r; k; lm",waitMs=10000}); + Check(!commandResult.GetProperty("pending").GetBoolean(), "Debugger completed command recognized"); + await Call("hyperv_debug_command",new {sessionId=id,command=".echo HVC_SECOND_COMMAND",waitMs=10000},label:"second-debug-command"); + var targetIdentity = await Call("hyperv_debug_command",new {sessionId=id,command="? @$tpid",waitMs=10000},label:"debug-target-id"); + var pidMatch = System.Text.RegularExpressions.Regex.Match(targetIdentity.GetProperty("output").GetString()!, @"Evaluate expression:\s*(\d+)"); + Check(pidMatch.Success,"Target process ID observed"); + var targetPid = int.Parse(pidMatch.Groups[1].Value); + var running = await Call("hyperv_debug_command",new {sessionId=id,command="g",waitMs=200},label:"debug-continue"); + Check(running.GetProperty("pending").GetBoolean(), "Continue returns a pending command"); + await Call("hyperv_debug_break",new {sessionId=id},label:"debug-interrupt"); + await Task.Delay(500); + await Call("hyperv_debug_command",new {sessionId=id,command="r",waitMs=10000},label:"debug-registers-after-break"); + await Call("hyperv_debug_close",new {sessionId=id}); + using(var target = System.Diagnostics.Process.GetProcessById(targetPid)) Check(!target.HasExited,"Detach preserves the target process"); + if (args[0] == "debug") + { + await Call("hyperv_guest_debug_setup",new {machine,debuggerDirectory=Path.Combine(root,"build","hyperv-control","windbg","amd64")}); + var guest=await Call("hyperv_guest_debug_open",new {machine,mode="launch",path=@"C:\Windows\System32\cmd.exe",arguments=new[]{"/c","ping -n 60 127.0.0.1 >nul"},guestDebuggerDirectory=@"C:\ProgramData\HyperVControl\debug\engines\amd64"}); + var worker=guest.GetProperty("workerId").GetString(); + var guestCommand = await Call("hyperv_guest_debug",new {machine,workerId=worker,action="command",command=".echo HVC_GUEST_DEBUG_READY; r; k; lm",waitMs=10000},label:"guest-debug-command"); + Check(!guestCommand.GetProperty("pending").GetBoolean(),"Guest debugger command completion"); + await Call("hyperv_guest_debug",new {machine,workerId=worker,action="close"},label:"guest-debug-close"); + } +} +else if(args[0] is "kernel-serial" or "kernel-net" or "kernel-recover") +{ + string connect; + if(args[0] == "kernel-net") + { + var configured=await Call("hyperv_kernel_configure",new {machine,action="kdnet",hostIp=args[2],port=50009},label:"kernel-net-configured"); + connect=configured.GetProperty("kernelAttachString").GetString()!; + } + else connect=@"com:pipe,port=\\.\pipe\HyperVControl-validation,resets=0,reconnect"; + var kd=await Call("hyperv_debug_open",new {mode="kernel",connectString=connect,debuggerDirectory=Path.Combine(root,"build","hyperv-control","windbg","amd64"),symbolPath=Path.Combine(root,"build","hyperv-control","symbols")},label:args[0]+"-open"); + var session=kd.GetProperty("sessionId").GetString(); + if(args[0] != "kernel-recover") await Call("hyperv_power",new {machine,action="restart"},label:args[0]+"-reboot"); + await Task.Delay(12000); + await Call("hyperv_debug_break",new {sessionId=session},label:args[0]+"-break"); + var output=await Call("hyperv_debug_command",new {sessionId=session,command=".echo HVC_KERNEL_READY; vertarget; r; k",waitMs=10000},label:args[0]+"-commands"); + for(var retry=0; output.GetProperty("pending").GetBoolean() && retry < 12; retry++) + { + await Task.Delay(10000); + output=await Call("hyperv_debug_poll",new {sessionId=session},label:args[0]+"-poll"); + } + Check(!output.GetProperty("pending").GetBoolean() && output.GetProperty("output").GetString()!.Contains("HVC_KERNEL_READY"),"Kernel debugger command completed"); + await Call("hyperv_debug_close",new {sessionId=session},label:args[0]+"-close"); + await Call("hyperv_guest_run",new {machine,script="'Guest resumed after kernel detach'"},label:args[0]+"-guest-alive"); +} +else if(args[0] == "cleanup") +{ + var checkpoint=JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(results,"test-checkpoint.json"))).RootElement.GetProperty("checkpoint").GetProperty("id").GetString(); + await Call("hyperv_console",new {machine,action="close"}); + await Call("hyperv_power",new {machine,action="turn_off"}); + await Call("hyperv_checkpoint",new {machine,action="restore",checkpoint},label:"restore-test-baseline"); + await Call("hyperv_checkpoint",new {machine,action="remove",checkpoint},label:"remove-test-baseline"); + var finalVm = await Call("hyperv_vm_info",new {machine},label:"final-vm"); + Check(finalVm.GetProperty("vm").GetProperty("State").GetInt32()==3,"Initial Off state restored"); + var finalCheckpoints=await Call("hyperv_checkpoints",new {machine},label:"final-checkpoints"); + var original=JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(results,"baseline-checkpoints.json"))).RootElement; + var originalIds=original.GetProperty("checkpoints").EnumerateArray().Select(c=>c.GetProperty("id").GetString()).ToHashSet(); + var finalIds=finalCheckpoints.GetProperty("checkpoints").EnumerateArray().Select(c=>c.GetProperty("id").GetString()).ToHashSet(); + Check(originalIds.SetEquals(finalIds),"All original checkpoint IDs preserved; only test checkpoint removed"); + Check(original.GetProperty("currentParentCheckpointId").GetString()==finalCheckpoints.GetProperty("currentParentCheckpointId").GetString(),"Original checkpoint branch restored"); +} +else throw new ArgumentException("Unknown validation phase."); +Console.WriteLine(args[0].ToUpperInvariant()+" PASS"); diff --git a/tests/HyperVControl.Tests/ReviewRegressionTests.cs b/tests/HyperVControl.Tests/ReviewRegressionTests.cs new file mode 100644 index 0000000..daa7102 --- /dev/null +++ b/tests/HyperVControl.Tests/ReviewRegressionTests.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using HyperVControl.Extracted; +using HyperVControl.Services; +using ModelContextProtocol.Protocol; + +namespace HyperVControl.Tests; + +internal static class ReviewRegressionTests +{ + internal static async Task RunAsync(Action check) + { + var runner = new PowerShellRunner(); + string[] values = [ + "", "O'Brien", "C:\\VMs\\Smart\u2019Name.vhdx", "\u2018\u2019\u201a\u201b", + "literal $([Console]::WriteLine('unexpected')) ` text\r\nnext line", + .. new[] { '\'', '\u2018', '\u2019', '\u201a', '\u201b' } + .Select(quote => $"VM{quote}; $injected = $true; #") + ]; + (string Name, Func Quote)[] helpers = [ + ("Host", PowerShellRunner.Literal), + ("Guest UI", HyperVGuestUiService.QuotePowerShellLiteral) + ]; + foreach (var (name, quote) in helpers) + { + var script = "$injected=$false\n$values=@(\n" + string.Join('\n', values.Select(quote)) + + "\n)\n@{values=$values; injected=$injected} | ConvertTo-Json -Compress"; + var result = await runner.JsonAsync(script, default); + check(!result.GetProperty("injected").GetBoolean() + && result.GetProperty("values").EnumerateArray().Select(v => v.GetString()).SequenceEqual(values), + name + " literals preserve ASCII/Unicode quotes without executing embedded commands"); + } + + // Execute the exact generated guest wrapper locally under Windows PowerShell. + // These regressions need process creation and pipes, not Hyper-V or elevation. + foreach (var length in new[] { 13_001, 200_000 }) + { + const string suffix = "\n[Console]::Out.Write('tail-\u03a9'); [Console]::Error.Write('stderr-\u03a9'); exit 7"; + var script = "#" + new string('\u2603', length - suffix.Length - 1) + suffix; + var result = await runner.JsonAsync(GuestService.BuildRunScript(script, 30), default, 45); + check(result.GetProperty("exitCode").GetInt32() == 7 + && !result.GetProperty("ok").GetBoolean() + && !result.GetProperty("timedOut").GetBoolean() + && result.GetProperty("stdout").GetString() == "tail-\u03a9" + && result.GetProperty("stderr").GetString() == "stderr-\u03a9", + $"{length:N0}-character guest script reaches its tail with separate UTF-8 streams and exit code"); + } + var empty = await runner.JsonAsync(GuestService.BuildRunScript("", 30), default, 45); + check(empty.GetProperty("ok").GetBoolean() && empty.GetProperty("stdout").GetString() == "", + "Empty guest script completes after stdin EOF"); + var timedOut = await runner.JsonAsync(GuestService.BuildRunScript("Start-Sleep -Seconds 30", 1), default, 15); + check(timedOut.GetProperty("timedOut").GetBoolean() && timedOut.GetProperty("exitCode").GetInt32() == -1, + "Guest child timeout still terminates execution with stdin transport"); + try + { + GuestService.BuildRunScript(new string('x', 200_001), 30); + throw new Exception("Oversized guest script was accepted."); + } + catch (ArgumentException) { check(true, "Guest script length guard rejects over-limit input before execution"); } + + (string Name, string Json, bool Error)[] startupCases = [ + ("startup failure", "{\"success\":false,\"sessionId\":\"failed\",\"result\":{\"exitCode\":1}}", true), + ("exception envelope", "{\"ok\":false,\"error\":\"Debugger unavailable\"}", true), + ("success=false with ok=true", "{\"success\":false,\"ok\":true}", true), + ("ok=false with success=true", "{\"success\":true,\"ok\":false}", true), + ("successful startup", "{\"success\":true,\"sessionId\":\"running\"}", false) + ]; + foreach (var (name, json, error) in startupCases) + { + using var state = JsonDocument.Parse(json); + var result = HyperVTools.Result(new HyperVTools.GuestDebugStartup("worker-id", "task-name", state.RootElement)); + using var content = JsonDocument.Parse(result.Content.OfType().Single().Text); + check(result.IsError == error + && content.RootElement.GetProperty("success").GetBoolean() == !error + && content.RootElement.GetProperty("workerId").GetString() == "worker-id" + && content.RootElement.GetProperty("taskName").GetString() == "task-name" + && JsonElement.DeepEquals(content.RootElement.GetProperty("result"), state.RootElement), + $"Guest debugger {name} preserves diagnostics and produces the correct MCP error flag"); + } + } +}