aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/test_windows_input.ps1415
-rw-r--r--scripts/test_windows_input.py243
-rw-r--r--scripts/windows_input/Bootstrap.ps151
-rw-r--r--scripts/windows_input/Common.ps188
-rw-r--r--scripts/windows_input/Native.cs439
-rw-r--r--scripts/windows_input/Probe.ps170
-rw-r--r--scripts/windows_input/README.md213
-rw-r--r--scripts/windows_input/report.py446
8 files changed, 1965 insertions, 0 deletions
diff --git a/scripts/test_windows_input.ps1 b/scripts/test_windows_input.ps1
new file mode 100644
index 00000000..dfbd41b4
--- /dev/null
+++ b/scripts/test_windows_input.ps1
@@ -0,0 +1,415 @@
+#Requires -Version 7.0
+<# Local interactive qualification, deliberately excluded from normal CI.
+ F12 aborts before the next injected gesture. Use an isolated unlocked desktop.
+ SendInput has an unavoidable focus race: do not use this while doing other work.
+#>
+[CmdletBinding()]
+param(
+ [string] $ExePath,
+ [string] $StablePath,
+ [string] $PreviewPath,
+ [ValidateSet('default', 'win32', 'vt')][string] $Profile = 'default',
+ [ValidateSet('native', 'legacy', 'mok2', 'kitty')][string[]] $Modes = @('native', 'legacy', 'mok2', 'kitty'),
+ [ValidateSet('stable', 'preview')][string[]] $Channels = @('stable', 'preview'),
+ [ValidateSet('direct', 'herdr')][string[]] $Paths = @('direct', 'herdr'),
+ [string[]] $Cases,
+ [int[]] $Widths = @(80, 119, 120, 121, 132, 160, 240),
+ [int[]] $Heights = @(24, 50),
+ [string] $OutputDirectory,
+ [switch] $AllowInputInjection,
+ [switch] $Manual,
+ [switch] $MatrixOnly
+)
+. "$PSScriptRoot/windows_input/Common.ps1"
+$reportScript = Join-Path $PSScriptRoot 'windows_input/report.py'
+$python = (Get-Command python -ErrorAction Stop).Source
+if (-not $OutputDirectory) { $OutputDirectory = Join-Path $PWD ".local/windows-input/$([guid]::NewGuid().ToString('N'))" }
+if (Test-Path -LiteralPath $OutputDirectory) { throw 'Output directory must be new, to prevent stale evidence or overwrites' }
+$root = [IO.Directory]::CreateDirectory($OutputDirectory).FullName
+$null = Invoke-GauntletProcess $python @($reportScript, 'matrix', '--output', (Join-Path $root 'matrix.json'))
+$matrix = Read-GauntletJson (Join-Path $root 'matrix.json')
+if ($MatrixOnly) { Write-Host "Matrix: $root/matrix.json"; exit 0 }
+$knownCases = @($matrix.cases | ForEach-Object id)
+if (@($Cases).Count -and @($Cases | Where-Object { $_ -notin $knownCases }).Count) { throw 'Unknown case selection' }
+$selectedCases = if (@($Cases).Count) { @($matrix.cases | Where-Object id -in $Cases) } else { @($matrix.cases) }
+if (-not $IsWindows) { throw 'Real-host qualification requires Windows and an interactive desktop; no tests passed' }
+if (-not $AllowInputInjection) { throw 'Read scripts/windows_input/README.md, then explicitly pass -AllowInputInjection on an isolated desktop' }
+if (-not [Environment]::UserInteractive) { throw 'Interactive desktop unavailable' }
+if ($Widths.Count -eq 0 -or $Heights.Count -eq 0 -or @($Widths | Where-Object { $_ -lt 40 -or $_ -gt 500 }).Count -or @($Heights | Where-Object { $_ -lt 15 -or $_ -gt 150 }).Count) { throw 'Invalid geometry selection' }
+Add-Type -Path "$PSScriptRoot/windows_input/Native.cs"
+[HerdrInputGauntlet.Desktop]::AssertNotElevated($PID)
+[HerdrInputGauntlet.Desktop]::Neutral()
+$controllerWindow = [IntPtr]::Zero
+if (@($selectedCases | Where-Object id -eq 'mouse-focus-refresh').Count) {
+ $controllerWindow = [HerdrInputGauntlet.Desktop]::GetForegroundWindow()
+ if ($controllerWindow -eq [IntPtr]::Zero) { throw 'Controller has no foreground window for focus-cycle qualification' }
+ [HerdrInputGauntlet.Desktop]::AssertNotElevated([HerdrInputGauntlet.Desktop]::Pid($controllerWindow))
+}
+$sourceCommit = $null
+$sourceDirty = $null
+if (-not $ExePath) {
+ $repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
+ $sourceCommit = (& git -C $repo rev-parse HEAD).Trim()
+ if ($LASTEXITCODE -ne 0) { throw 'Could not identify the source checkout commit' }
+ $sourceDirty = [bool](& git -C $repo status --porcelain)
+ Write-Host "Building current checkout: $repo"
+ Push-Location $repo
+ try {
+ & cargo build --release --locked
+ if ($LASTEXITCODE -ne 0) { throw "cargo build failed with exit code $LASTEXITCODE" }
+ } finally { Pop-Location }
+ $targetRoot = if ($env:CARGO_TARGET_DIR) { [IO.Path]::GetFullPath($env:CARGO_TARGET_DIR, $repo) } else { Join-Path $repo 'target' }
+ $builtExe = Join-Path $targetRoot 'release/herdr.exe'
+ $package = Join-Path $repo '.local/windows-input/cache/Microsoft.Windows.Console.ConPTY.nupkg'
+ $stage = Join-Path $root 'package'
+ Write-Host 'Staging the current binary with the pinned ConPTY runtime'
+ $null = Invoke-GauntletProcess $python @((Join-Path $PSScriptRoot 'package_windows_conpty.py'), 'stage', '--package', $package, '--herdr-exe', $builtExe, '--output-dir', $stage) -Timeout 300
+ $ExePath = Join-Path $stage 'herdr.exe'
+}
+$exe = (Resolve-Path -LiteralPath $ExePath).Path
+$conpty = Join-Path ([IO.Path]::GetDirectoryName($exe)) 'conpty/conpty.dll'
+if (-not (Test-Path -LiteralPath $conpty -PathType Leaf)) { throw "Selected Herdr binary has no adjacent bundled ConPTY runtime: $exe. Pass -ExePath to a packaged herdr.exe" }
+$pwsh = (Get-Process -Id $PID).Path
+$exeHash = (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash
+if ($sourceCommit) { Write-Host "Source: $sourceCommit$(if ($sourceDirty) { ' + working tree changes' })" }
+Write-Host "Herdr under test: $exe"
+Write-Host "SHA-256: $exeHash"
+Write-Host "Modes: $($Modes -join ', '); widths: $($Widths -join ', '); heights: $($Heights -join ', ')"
+Write-Host "Channels: $($Channels -join ', '); paths: $($Paths -join ', '); cases: $(if (@($Cases).Count) { $Cases -join ', ' } else { 'all' })"
+Write-Host "Clipboard formats at start: $([HerdrInputGauntlet.Desktop]::CountClipboardFormats()) (paste runs only when zero)"
+$document = @{ schema = 1; run = [IO.Path]::GetFileName($root); started = [DateTime]::UtcNow.ToString('O');
+ source_commit = $sourceCommit; source_dirty = $sourceDirty; exe = $exe; exe_sha256 = $exeHash;
+ powershell = $PSVersionTable.PSVersion.ToString(); os = [Environment]::OSVersion.VersionString;
+ profile = $Profile; controller_elevated = $false; observations = @(); hosts = @(); errors = @(); cleanup_errors = @();
+ widths = $Widths; heights = $Heights; modes = $Modes; channels = $Channels; paths = $Paths;
+ cases = @($selectedCases | ForEach-Object id); note = 'Desktop input evidence; native qualification still required' }
+$rawReport = Join-Path $root 'observations.json'
+$artifactReport = Join-Path $root 'report.json'
+
+function Save-Report { Write-GauntletJson $rawReport $document }
+function Resolve-Terminal($Name, $Override) {
+ if ($Override) { return @{ path = (Resolve-Path -LiteralPath $Override).Path; app_user_model_id = $null } }
+ $packageName = if ($Name -eq 'stable') { 'Microsoft.WindowsTerminal' } else { 'Microsoft.WindowsTerminalPreview' }
+ $package = @(Get-AppxPackage -Name $packageName -ErrorAction SilentlyContinue | Sort-Object Version -Descending)
+ if ($package.Count -eq 0) { return $null }
+ $path = Join-Path $package[0].InstallLocation 'WindowsTerminal.exe'
+ if (-not (Test-Path -LiteralPath $path)) { return $null }
+ return @{ path = $path; app_user_model_id = "$($package[0].PackageFamilyName)!App" }
+}
+function Observer-Request($Plan, $Action, $Value = $null) {
+ $id = [guid]::NewGuid().ToString('N')
+ Write-GauntletJson (Join-Path $Plan.work 'request.json') @{ id = $id; nonce = $Plan.nonce; action = $Action; value = $Value }
+ $reply = Wait-GauntletJson (Join-Path $Plan.work 'reply.json') $root { param($v) $v.id -eq $id -and $v.nonce -eq $Plan.nonce -and $v.action -eq $Action }
+ if ($reply.error) { throw "Observer failed: $($reply.error)" }
+ return $reply
+}
+function Outer-State($Plan) {
+ $after = if ($Plan.ContainsKey('outer_sequence')) { $Plan.outer_sequence } else { 0 }
+ $state = Wait-GauntletJson (Join-Path $Plan.work 'outer.json') $root { param($v) $v.nonce -eq $Plan.nonce -and $v.sequence -gt $after }
+ $Plan.outer_sequence = $state.sequence
+ return $state
+}
+function Set-ObservedGeometry($Plan, $Window, $WindowPid, $Width, $Height) {
+ # Correct against actual console cell measurements, not a claimed pixel size.
+ for ($attempt = 0; $attempt -lt 8; $attempt++) {
+ [HerdrInputGauntlet.Desktop]::Guard($Window, $Plan.nonce, $WindowPid)
+ $outer = Outer-State $Plan
+ $w = [int]$outer.geometry[0]; $h = [int]$outer.geometry[1]
+ if ($w -eq $Width -and $h -eq $Height) { return $outer }
+ [HerdrInputGauntlet.Desktop]::Resize($Window, $Plan.nonce, $WindowPid, ($Width - $w) * 8, ($Height - $h) * 16)
+ Start-Sleep -Milliseconds 350
+ Update-GauntletLease $root
+ }
+ return $null
+}
+function New-Observation($HostName, $Plan, $Width, $Height, $Case) {
+ return @{ host = $HostName; path = $Plan.path; mode = $Plan.mode; width = $Width; height = $Height; case = $Case.id;
+ nonce = $Plan.nonce; status = 'inconclusive'; ready = $false; focus_verified = $false; complete = $false }
+}
+
+try {
+ [HerdrInputGauntlet.Desktop]::StartEmergencyStop()
+ $terminals = @{ stable = (Resolve-Terminal 'stable' $StablePath); preview = (Resolve-Terminal 'preview' $PreviewPath) }
+ foreach ($channel in $Channels) {
+ $terminal = $terminals[$channel]
+ if ($terminal) { Write-Host "Windows Terminal $channel`: $($terminal.path) ($((Get-Item $terminal.path).VersionInfo.FileVersion))" }
+ else { Write-Host "Windows Terminal $channel`: not installed (channel will be not_run)" }
+ }
+ $launcherIdentities = @{}; $installationIdentities = @{}
+ foreach ($channel in $Channels) {
+ if ($terminals[$channel]) {
+ $launcherIdentities[$channel] = [HerdrInputGauntlet.Desktop]::FileIdentity($terminals[$channel].path)
+ $installationIdentities[$channel] = [HerdrInputGauntlet.Desktop]::FileIdentity([IO.Path]::GetDirectoryName($terminals[$channel].path))
+ }
+ }
+ if ($Channels -contains 'stable' -and $Channels -contains 'preview' -and $terminals.stable -and $terminals.preview -and ($launcherIdentities.stable -eq $launcherIdentities.preview -or $installationIdentities.stable -eq $installationIdentities.preview)) {
+ throw 'Stable and Preview resolve to the same executable/installation; refusing duplicate channel evidence'
+ }
+ foreach ($hostName in $Channels) {
+ $terminal = $terminals[$hostName]
+ if (-not $terminal) {
+ $document.hosts += @{ channel = $hostName; status = 'not_run'; reason = 'Terminal installation not found; supply explicit path' }
+ Save-Report
+ continue
+ }
+ $hostRecord = @{ channel = $hostName; launcher = $terminal.path; launcher_version = (Get-Item $terminal.path).VersionInfo.FileVersion;
+ launcher_identity = $launcherIdentities[$hostName]; installation_identity = $installationIdentities[$hostName]; runs = @() }
+ $document.hosts += $hostRecord
+ foreach ($path in $Paths) { foreach ($mode in $Modes) {
+ $nonce = 'herdr-gauntlet-' + [guid]::NewGuid().ToString('N')
+ $work = [IO.Directory]::CreateDirectory((Join-Path $root $nonce)).FullName
+ $configHome = [IO.Directory]::CreateDirectory((Join-Path $work 'config')).FullName
+ $config = Join-Path $configHome 'test.toml'
+ $shellToml = $pwsh | ConvertTo-Json -Compress
+ [IO.File]::WriteAllText($config, "onboarding = false`n[terminal]`ndefault_shell = $shellToml`n[ui]`nmouse_capture = true`n")
+ $plan = @{ root = $root; work = $work; nonce = $nonce; session = $nonce; config = $config; config_home = $configHome;
+ path = $path; mode = $mode; profile = $Profile; exe = $exe; pwsh = $pwsh; input_trace = (Join-Path $work 'input-transport.log') }
+ $planPath = Join-Path $work 'plan.json'
+ Write-GauntletJson $planPath $plan
+ $window = [IntPtr]::Zero; $windowPid = 0; $server = $null; $launcher = $null; $clipboardSequence = $null; $injectionAuthorized = $false
+ $cursorPosition = $null; $mouseReporting = $false
+ Update-GauntletLease $root
+ try {
+ if ($path -eq 'herdr') {
+ $server = New-GauntletProcess $exe @('--session', $nonce, 'server') $plan -Capture
+ # Drain pipes asynchronously; server output must never block readiness.
+ $serverOut = $server.StandardOutput.ReadToEndAsync(); $serverErr = $server.StandardError.ReadToEndAsync()
+ $deadline = [DateTime]::UtcNow.AddSeconds(30)
+ do {
+ Update-GauntletLease $root
+ try { $status = Invoke-GauntletProcess $exe @('--session', $nonce, 'status', 'server') $plan -Timeout 5 } catch { $status = '' }
+ if ($status -match 'status: running') { break }
+ Start-Sleep -Milliseconds 200
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if ($status -notmatch 'status: running') { throw 'Owned server did not become ready' }
+ $created = (Invoke-GauntletProcess $exe @('--session', $nonce, 'workspace', 'create', '--cwd', $work, '--focus') $plan) | ConvertFrom-Json
+ $pane = $created.result.root_pane.pane_id
+ if (-not $pane) { throw 'Missing owned probe pane identity' }
+ $command = '& ' + (Quote-GauntletPS $pwsh) + ' -NoProfile -File ' + (Quote-GauntletPS "$PSScriptRoot/windows_input/Probe.ps1") + ' -PlanPath ' + (Quote-GauntletPS $planPath)
+ $null = Invoke-GauntletProcess $exe @('--session', $nonce, 'pane', 'run', $pane, $command) $plan
+ }
+ # Always request a new window. Bootstrap independently clears inherited identity.
+ $terminalArguments = @('-w', 'new', 'new-tab', '--title', $nonce, '--suppressApplicationTitle', '--', $pwsh, '-NoProfile', '-File', "$PSScriptRoot/windows_input/Bootstrap.ps1", '-PlanPath', $planPath)
+ if ($terminal.app_user_model_id) {
+ $launcher = Get-Process -Id ([HerdrInputGauntlet.Desktop]::ActivateApplication($terminal.app_user_model_id, $terminalArguments)) -ErrorAction SilentlyContinue
+ } else {
+ $launcher = New-GauntletProcess $terminal.path $terminalArguments $plan
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(25)
+ do {
+ Update-GauntletLease $root
+ $window = [HerdrInputGauntlet.Desktop]::Find($nonce)
+ if ($window -ne [IntPtr]::Zero) { break }
+ Start-Sleep -Milliseconds 100
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if ($window -eq [IntPtr]::Zero) { throw 'No unique owned Terminal window appeared' }
+ $windowPid = [HerdrInputGauntlet.Desktop]::Pid($window)
+ $windowProcess = Get-Process -Id $windowPid
+ if ($windowProcess.ProcessName -ne 'WindowsTerminal') { throw 'Nonce window is not Windows Terminal; refusing injection' }
+ [HerdrInputGauntlet.Desktop]::AssertNotElevated($windowPid)
+ $imageIdentity = [HerdrInputGauntlet.Desktop]::FileIdentity($windowProcess.Path)
+ $installationIdentity = [HerdrInputGauntlet.Desktop]::FileIdentity([IO.Path]::GetDirectoryName($windowProcess.Path))
+ $processIdentity = "$windowPid/$($windowProcess.StartTime.ToUniversalTime().Ticks)"
+ foreach ($otherHost in $document.hosts) {
+ if ($otherHost.channel -ne $hostName -and $otherHost.ContainsKey('runs')) {
+ foreach ($other in $otherHost.runs) {
+ if ($other.image_identity -eq $imageIdentity -or $other.installation_identity -eq $installationIdentity -or $other.process_identity -eq $processIdentity) {
+ throw 'Stable and Preview activated the same Terminal installation/process; refusing duplicate evidence'
+ }
+ }
+ }
+ }
+ $runRecord = @{ nonce = $nonce; path = $path; mode = $mode; hwnd = $window.ToInt64(); pid = $windowPid;
+ terminal_path = $windowProcess.Path; terminal_version = $windowProcess.MainModule.FileVersionInfo.FileVersion;
+ elevated = $false; image_identity = $imageIdentity; installation_identity = $installationIdentity; process_identity = $processIdentity;
+ layout = [HerdrInputGauntlet.Desktop]::Layout($window) }
+ $hostRecord.runs += $runRecord
+ $ready = Wait-GauntletJson (Join-Path $work 'ready.json') $root { param($v) $v.nonce -eq $nonce -and $v.mode -eq $mode }
+ [HerdrInputGauntlet.Desktop]::Focus($window, $nonce, $windowPid)
+ $injectionAuthorized = $true
+ Start-Sleep -Milliseconds 500
+ # Full case catalogue once; boundary geometries run discriminating sentinels.
+ $geometries = @(@{ width = 120; height = 30; full = $true })
+ foreach ($height in $Heights) { foreach ($width in $Widths) { $geometries += @{ width = $width; height = $height; full = $false } } }
+ # Return narrow after wide to exercise repeated reflow/recovery.
+ $geometries += @{ width = 80; height = 30; full = $false }
+ $phase = 0
+ foreach ($geometry in $geometries) {
+ $phase++
+ $selected = if ($geometry.full) { $selectedCases } else { @($selectedCases | Where-Object { $_.id -in @('letter-a', 'shift-enter', 'paste-lf', 'mouse-focus-refresh') }) }
+ $outer = Set-ObservedGeometry $plan $window $windowPid $geometry.width $geometry.height
+ foreach ($case in $selected) {
+ $row = New-Observation $hostName $plan $geometry.width $geometry.height $case
+ $row.phase = $phase
+ $document.observations += $row
+ if ($case.kind -eq 'qualification' -or -not $case.expected.ContainsKey($mode)) { $row.status = 'not_run'; $row.reason = 'Requires separate qualification: ' + $case.id; continue }
+ if ($case.kind -eq 'manual' -and -not $Manual) { $row.status = 'not_run'; $row.reason = 'Operator-assisted case; rerun with -Manual and declared layout'; continue }
+ $layoutChords = $null
+ if ($case.kind -eq 'layout-key') {
+ try {
+ $layoutChords = @([HerdrInputGauntlet.Desktop]::DeadKeyChord($window, [char]$case.dead), [int[]]@([int]$case.base_vk))
+ $row.input_layout = [HerdrInputGauntlet.Desktop]::Layout($window)
+ } catch {
+ $row.status = 'not_run'
+ $row.reason = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { $_.Exception.Message }
+ continue
+ }
+ }
+ if ($null -eq $outer) { $row.status = 'not_run'; $row.reason = 'Requested cell geometry not reached; monitor/font/window constraints'; continue }
+ $fresh = Outer-State $plan
+ if ($fresh.geometry[0] -ne $geometry.width -or $fresh.geometry[1] -ne $geometry.height) {
+ $fresh = Set-ObservedGeometry $plan $window $windowPid $geometry.width $geometry.height
+ if ($null -eq $fresh) { $row.status = 'inconclusive'; $row.reason = 'Geometry changed and could not be restored'; continue }
+ }
+ $row.outer_geometry = $fresh.geometry
+ $row.outer_sequence = $fresh.sequence
+ $row.negotiation_hex = $ready.negotiation_hex
+ if ($mode -eq 'kitty' -and -not $ready.kitty_acknowledged) { $row.status = 'inconclusive'; $row.reason = 'Kitty disambiguation query not acknowledged; host support is not established'; continue }
+ if ($case.kind -in @('paste', 'mouse-interleave') -and [HerdrInputGauntlet.Desktop]::CountClipboardFormats() -ne 0) {
+ $row.status = 'not_run'; $row.reason = 'Clipboard is not empty; refusing to replace user data'; continue
+ }
+ if ($case.kind -in @('mouse-interleave', 'mouse-focus-refresh')) { $null = Observer-Request $plan 'mouse-on'; $mouseReporting = $true }
+ $traceLineCount = if ($path -eq 'herdr' -and (Test-Path -LiteralPath $plan.input_trace)) { @(Get-Content -LiteralPath $plan.input_trace).Count } else { 0 }
+ $begin = Observer-Request $plan 'begin'
+ $row.ready = $true; $row.pane_geometry = $begin.geometry
+ [HerdrInputGauntlet.Desktop]::Guard($window, $nonce, $windowPid)
+ $row.focus_verified = $true
+ if ($case.kind -eq 'key') {
+ $row.scans = @([HerdrInputGauntlet.Desktop]::Scans($window, [int[]]$case.chords[0]))
+ foreach ($chord in $case.chords) { $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]$chord) }
+ } elseif ($case.kind -eq 'paste') {
+ $clipboardSequence = [HerdrInputGauntlet.Desktop]::SetEmptyClipboard($window, $case.text)
+ $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(17, 86))
+ } elseif ($case.kind -eq 'mouse-interleave') {
+ $cursorPosition = [HerdrInputGauntlet.Desktop]::Cursor()
+ $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(65))
+ [HerdrInputGauntlet.Desktop]::MouseMoveInside($window, $nonce, $windowPid, -60)
+ Start-Sleep -Milliseconds 150
+ $clipboardSequence = [HerdrInputGauntlet.Desktop]::SetEmptyClipboard($window, $case.text)
+ $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(17, 86))
+ Start-Sleep -Milliseconds 150
+ [HerdrInputGauntlet.Desktop]::MouseMoveInside($window, $nonce, $windowPid, 60)
+ Start-Sleep -Milliseconds 150
+ $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(66))
+ } elseif ($case.kind -eq 'mouse-focus-refresh') {
+ $cursorPosition = [HerdrInputGauntlet.Desktop]::Cursor()
+ $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(65))
+ [HerdrInputGauntlet.Desktop]::MouseClickWheelInside($window, $nonce, $windowPid, -60)
+ Start-Sleep -Milliseconds 200
+ $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(66))
+ [HerdrInputGauntlet.Desktop]::FocusAwayAndBack($controllerWindow, $window, $nonce, $windowPid)
+ $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(67))
+ [HerdrInputGauntlet.Desktop]::MouseClickWheelInside($window, $nonce, $windowPid, 60)
+ Start-Sleep -Milliseconds 200
+ $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(68))
+ } elseif ($case.kind -eq 'mode-transitions') {
+ foreach ($step in @(
+ @{ mode = 'legacy'; chords = @([int[]]@(65), [int[]]@(16, 13)) },
+ @{ mode = 'mok2'; chords = @([int[]]@(66), [int[]]@(16, 13)) },
+ @{ mode = 'kitty'; chords = @([int[]]@(67), [int[]]@(16, 13)) },
+ @{ mode = 'mok2'; chords = @([int[]]@(68), [int[]]@(16, 13)) },
+ @{ mode = 'legacy'; chords = @([int[]]@(69), [int[]]@(16, 13), [int[]]@(70)) }
+ )) {
+ if ($step.mode) { $null = Observer-Request $plan 'set-mode' $step.mode; Start-Sleep -Milliseconds 200 }
+ foreach ($chord in $step.chords) { $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]$chord) }
+ }
+ } elseif ($case.kind -eq 'layout-key') {
+ foreach ($chord in $layoutChords) { $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]$chord) }
+ } else {
+ Write-Host "$($case.id): $($case.prompt) Return to this controller and press Enter after the gesture."
+ # Manual waits are bounded by the independent bootstrap lease watchdog.
+ $null = Read-Host
+ $row.operator_layout = [HerdrInputGauntlet.Desktop]::Layout($window)
+ }
+ Start-Sleep -Milliseconds 500
+ if ($case.kind -ne 'manual') { [HerdrInputGauntlet.Desktop]::Guard($window, $nonce, $windowPid) }
+ $end = Observer-Request $plan 'end'
+ if ($case.kind -eq 'mode-transitions') { $null = Observer-Request $plan 'set-mode' $mode }
+ $row.capture_id = $end.id
+ $row.hex = $end.hex; $row.records = $end.records; $row.error = $end.error; $row.complete = $end.quiet_reached
+ $row.final_outer_geometry = (Outer-State $plan).geometry
+ $row.status = 'observed'; $row.final_pane_geometry = $end.geometry
+ if ($path -eq 'herdr' -and (Test-Path -LiteralPath $plan.input_trace)) {
+ $traceLines = @(Get-Content -LiteralPath $plan.input_trace)
+ $trace = $traceLines -join "`n"
+ $captureTrace = ($traceLines | Select-Object -Skip $traceLineCount) -join "`n"
+ $row.input_reader = if ($trace.Contains('reader=windows-console')) { 'windows-console' } elseif ($trace.Contains('reader=crossterm')) { 'crossterm' } else { 'unknown' }
+ if ($captureTrace.Contains('transport=win32-serialized')) { $row.input_transport = 'win32-serialized' }
+ }
+ if ($null -ne $clipboardSequence) {
+ if (-not [HerdrInputGauntlet.Desktop]::ClearOwnedClipboard($clipboardSequence)) { throw 'Could not clear test-owned clipboard' }
+ $clipboardSequence = $null
+ }
+ if ($mouseReporting) { $null = Observer-Request $plan 'mouse-off'; $mouseReporting = $false }
+ if ($null -ne $cursorPosition) { [HerdrInputGauntlet.Desktop]::RestoreCursor($cursorPosition); $cursorPosition = $null }
+ if ($case.kind -eq 'manual') { [HerdrInputGauntlet.Desktop]::Focus($window, $nonce, $windowPid) }
+ Save-Report
+ Update-GauntletLease $root
+ }
+ }
+ } catch {
+ $document.errors += "$nonce : $($_.Exception.Message)"
+ Save-Report
+ # Abort the campaign on lost focus, readiness failure or partial injection.
+ throw
+ } finally {
+ if ($mouseReporting) {
+ try { $null = Observer-Request $plan 'mouse-off'; $mouseReporting = $false } catch { $document.cleanup_errors += $_.Exception.Message }
+ }
+ if ($null -ne $cursorPosition -and [HerdrInputGauntlet.Desktop]::GetForegroundWindow() -eq $window) {
+ [HerdrInputGauntlet.Desktop]::RestoreCursor($cursorPosition); $cursorPosition = $null
+ }
+ if ($null -ne $clipboardSequence -and -not [HerdrInputGauntlet.Desktop]::ClearOwnedClipboard($clipboardSequence)) { $document.cleanup_errors += 'Could not clear test-owned clipboard' }
+ [IO.File]::WriteAllText((Join-Path $work 'probe-stop'), '')
+ if (Test-Path (Join-Path $work 'ready.json')) {
+ try {
+ $probeExit = Wait-GauntletJson (Join-Path $work 'probe-exit.json') $root { param($v) $v.nonce -eq $nonce } -Timeout 5
+ $document.cleanup_errors += @($probeExit.cleanup_errors)
+ } catch { $document.cleanup_errors += $_.Exception.Message }
+ }
+ # Normal detach only while still owning foreground focus; never type into another window.
+ if ($injectionAuthorized -and $path -eq 'herdr' -and [HerdrInputGauntlet.Desktop]::IsOwned($window, $nonce, $windowPid) -and [HerdrInputGauntlet.Desktop]::GetForegroundWindow() -eq $window) {
+ try {
+ $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(17, 66))
+ Start-Sleep -Milliseconds 100
+ $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(81))
+ } catch { $document.cleanup_errors += $_.Exception.Message }
+ }
+ if ($null -ne $server -and -not $server.HasExited) {
+ try { $null = Invoke-GauntletProcess $exe @('session', 'stop', $nonce) $plan }
+ catch { if (-not $server.WaitForExit(5000)) { $document.cleanup_errors += $_.Exception.Message } }
+ }
+ [IO.File]::WriteAllText((Join-Path $work 'stop'), '')
+ if ($window -ne [IntPtr]::Zero) {
+ try {
+ $exitRecord = Wait-GauntletJson (Join-Path $work 'bootstrap-exit.json') $root { param($v) $v.nonce -eq $nonce } -Timeout 45
+ $document.cleanup_errors += @($exitRecord.cleanup_errors)
+ if ($exitRecord.before[2] -ne $exitRecord.after[2]) { $document.cleanup_errors += "$nonce console input mode not restored" }
+ } catch { $document.cleanup_errors += $_.Exception.Message }
+ $windowDeadline = [DateTime]::UtcNow.AddSeconds(5)
+ while ([HerdrInputGauntlet.Desktop]::IsOwned($window, $nonce, $windowPid) -and [DateTime]::UtcNow -lt $windowDeadline) { Start-Sleep -Milliseconds 100 }
+ if ([HerdrInputGauntlet.Desktop]::IsOwned($window, $nonce, $windowPid)) { $document.cleanup_errors += "$nonce Terminal window remained open; close it manually" }
+ }
+ if ($null -ne $server) {
+ if (-not $server.WaitForExit(5000)) {
+ $server.Kill($true)
+ $document.cleanup_errors += "$nonce server required forced cleanup"
+ if (-not $server.WaitForExit(5000)) { $document.cleanup_errors += "$nonce server remained active after forced cleanup" }
+ }
+ if (-not (Test-Path (Join-Path $work 'bootstrap-exit.json'))) {
+ try { $null = Invoke-GauntletProcess $exe @('session', 'delete', $nonce) $plan } catch { $document.cleanup_errors += $_.Exception.Message }
+ }
+ $server.Dispose()
+ }
+ if ($null -ne $launcher) { $launcher.Dispose() }
+ Save-Report
+ }
+ } }
+ }
+} catch {
+ if ($document.errors.Count -eq 0) { $document.errors += $_.Exception.Message }
+} finally { [HerdrInputGauntlet.Desktop]::StopEmergencyStop(); Save-Report }
+& $python $reportScript report --input $rawReport --output $artifactReport
+$code = $LASTEXITCODE
+Write-Host "Evidence and report: $root"
+exit $code
diff --git a/scripts/test_windows_input.py b/scripts/test_windows_input.py
new file mode 100644
index 00000000..f8a70eee
--- /dev/null
+++ b/scripts/test_windows_input.py
@@ -0,0 +1,243 @@
+"""Portable tests of the gauntlet's oracle, not Windows input qualification."""
+import copy
+import unittest
+from scripts.windows_input.report import catalogue, channel_identity_errors, herdr_protocol_label, known_host_gap, qualification_matrix, summarize, verdict
+
+
+class WindowsInputGauntletTests(unittest.TestCase):
+ def setUp(self):
+ self.matrix = catalogue()
+ self.cases = {case["id"]: case for case in self.matrix["cases"]}
+ self.evidence = dict(ready=True, focus_verified=True, complete=True, width=121, height=24,
+ outer_geometry=[121, 24, 0x298], pane_geometry=[100, 20, 0x200],
+ final_outer_geometry=[121, 24, 0x298], final_pane_geometry=[100, 20, 0x200])
+
+ def test_catalogue_has_unique_cases_and_geometry_boundaries(self):
+ self.assertEqual(len(self.cases), len(self.matrix["cases"]))
+ self.assertEqual(self.matrix["widths"], [80, 119, 120, 121, 132, 160, 240])
+ self.assertEqual(self.matrix["heights"], [24, 50])
+ for case in self.cases.values():
+ self.assertTrue(case["id"])
+ self.assertLessEqual(set(case["expected"]), set(self.matrix["modes"]))
+ for expected in case["expected"].values():
+ for value in expected.get("hex", []):
+ self.assertTrue(bytes.fromhex(value))
+ self.assertEqual(self.cases["dead-acute"]["kind"], "layout-key")
+ self.assertEqual(self.cases["altgr-euro"]["kind"], "manual")
+
+ def test_default_catalogue_does_not_inject_terminal_host_actions(self):
+ for case_id in ["ctrl-v", "alt-enter", "ctrl-shift-up", "ctrl-shift-down", "ctrl-shift-home", "ctrl-shift-end"]:
+ self.assertEqual(self.cases[case_id]["kind"], "qualification", case_id)
+
+ def test_kitty_function_keys_accept_native_and_legacy_encodings(self):
+ self.assertEqual(self.cases["shift-tab"]["expected"]["kitty"]["hex"], ["1b5b5a", "1b5b393b3275"])
+ self.assertEqual(self.cases["up"]["expected"]["kitty"]["hex"], ["1b5b41", "1b5b353734313975"])
+
+ def test_shift_enter_cannot_pass_with_plain_enter_or_trailing_duplicates(self):
+ case = self.cases["shift-enter"]
+ correct = "1b5b32373b323b31337e"
+ for value, status in [("0d", "fail"), (correct, "pass"), (correct + "0d", "fail")]:
+ self.assertEqual(verdict(case, "mok2", {**self.evidence, "hex": value})[0], status)
+ self.assertEqual(verdict(case, "legacy", {**self.evidence, "hex": "0d"})[0], "unsupported")
+
+ def test_focus_readiness_and_actual_geometry_are_required(self):
+ case = self.cases["letter-a"]
+ good = {**self.evidence, "hex": "61"}
+ for field in ["ready", "focus_verified", "complete", "pane_geometry", "outer_geometry", "final_pane_geometry", "final_outer_geometry"]:
+ value = copy.deepcopy(good)
+ value.pop(field)
+ self.assertEqual(verdict(case, "legacy", value)[0], "inconclusive", field)
+ self.assertEqual(verdict(case, "legacy", {**good, "outer_geometry": [120, 24]})[0], "inconclusive")
+ for malformed in [1, "121,24", {}, [121], [121, "24"], [True, 24]]:
+ self.assertEqual(verdict(case, "legacy", {**good, "pane_geometry": malformed})[0], "inconclusive")
+
+ def test_native_modifiers_release_and_repeat_are_not_discarded(self):
+ # type, down, repeat, vk, scan, Unicode, control-state
+ self.evidence["scans"] = [42, 28]
+ records = [[1, 1, 1, 13, 28, 13, 16], [1, 0, 1, 13, 28, 13, 16]]
+ case = self.cases["shift-enter"]
+ self.assertEqual(verdict(case, "native", {**self.evidence, "records": records})[0], "pass")
+ for index, value in [(6, 0), (2, 2), (3, 10), (4, 0), (5, 122)]:
+ bad = copy.deepcopy(records)
+ bad[0][index] = value
+ self.assertEqual(verdict(case, "native", {**self.evidence, "records": bad})[0], "fail")
+ self.assertEqual(verdict(case, "native", {**self.evidence, "records": records[:1]})[0], "fail")
+ extra = [[1, 1, 1, 16, 42, 0, 16]] + records
+ self.assertEqual(verdict(case, "native", {**self.evidence, "records": extra})[0], "fail")
+ balanced = extra + [[1, 0, 1, 16, 42, 0, 0]]
+ self.assertEqual(verdict(case, "native", {**self.evidence, "records": balanced})[0], "pass")
+
+ def test_herdr_native_page_keys_must_be_consumed_before_the_pane(self):
+ case = self.cases["page-up"]
+ evidence = {**self.evidence, "path": "herdr", "scans": [73]}
+ records = [[1, 1, 1, 33, 73, 0, 0], [1, 0, 1, 33, 73, 0, 0]]
+ self.assertEqual(verdict(case, "native", {**evidence, "records": []})[0], "inconclusive")
+ self.assertEqual(verdict(case, "native", {**evidence, "records": [[4, 0, 0, 0, 0, 0, 0]]})[0], "inconclusive")
+ self.assertEqual(verdict(case, "native", {**evidence, "records": records})[0], "fail")
+ self.assertEqual(verdict(case, "native", {**evidence, "records": [None]})[0], "inconclusive")
+ self.assertEqual(verdict(case, "native", {**evidence, "path": "direct", "records": records})[0], "pass")
+ for mode in ("legacy", "mok2", "kitty"):
+ self.assertEqual(verdict(case, mode, {**evidence, "hex": ""})[0], "inconclusive")
+ self.assertEqual(verdict(case, mode, {**evidence, "hex": "1b5b357e"})[0], "fail")
+
+ def test_paste_requires_one_envelope_and_complete_unicode_payload(self):
+ case = self.cases["paste-unicode"]
+ payload = case["text"].replace("\n", "\r\n").encode()
+ framed = b"\x1b[200~" + payload + b"\x1b[201~"
+ for data, expected in [(framed, "pass"), (payload, "fail"), (framed * 2, "fail"),
+ (framed[:-1], "fail"), (framed + b"\r", "fail"),
+ (b"\x1b[200~\xff\x1b[201~", "fail")]:
+ self.assertEqual(verdict(case, "kitty", {**self.evidence, "hex": data.hex()})[0], expected)
+
+ def test_mouse_interleave_requires_ordered_motion_and_paste(self):
+ case = self.cases["mouse-interleave"]
+ motion = b"\x1b[<35;10;5M"
+ paste = b"\x1b[200~mouse\r\npaste\x1b[201~"
+ good = b"a" + motion + paste + motion + b"b"
+ self.assertEqual(verdict(case, "kitty", {**self.evidence, "hex": good.hex()})[0], "pass")
+ for bad in (good.replace(motion, b"", 1), good + b"b", b"a" + motion + motion + paste + b"b"):
+ self.assertEqual(verdict(case, "kitty", {**self.evidence, "hex": bad.hex()})[0], "fail")
+
+ def test_runtime_mode_transition_has_one_exact_order(self):
+ case = self.cases["mode-transitions"]
+ for mode in ("legacy", "mok2", "kitty"):
+ expected = case["expected"][mode]["hex"][0]
+ self.assertEqual(verdict(case, mode, {**self.evidence, "hex": expected})[0], "pass")
+ self.assertEqual(verdict(case, mode, {**self.evidence, "hex": expected + "0d"})[0], "fail")
+ direct = {**self.evidence, "path": "direct"}
+ for observed in ("a\rb\rc\rd\re\rf", "a\rb\rc\x1b[13;2ud\re\rf"):
+ self.assertEqual(verdict(case, "legacy", {**direct, "hex": observed.encode().hex()})[0], "unsupported")
+
+ def test_mouse_focus_refresh_requires_reports_on_both_sides(self):
+ case = self.cases["mouse-focus-refresh"]
+ mouse = b"\x1b[<35;10;5M\x1b[<0;10;5M\x1b[<0;10;5m\x1b[<64;10;5M"
+ good = b"a" + mouse + b"bc" + mouse + b"d"
+ self.assertEqual(verdict(case, "legacy", {**self.evidence, "hex": good.hex()})[0], "pass")
+ for bad in (b"ab" + b"c" + mouse + b"d", b"a" + mouse + b"bcd"):
+ self.assertEqual(verdict(case, "legacy", {**self.evidence, "hex": bad.hex()})[0], "fail")
+
+ def test_qualification_matrix_uses_observed_results_only(self):
+ observations = [
+ {"host": "stable", "case": "shift-enter", "path": "herdr", "mode": "mok2", "status": "pass"},
+ {"host": "stable", "case": "shift-enter", "path": "direct", "mode": "legacy", "status": "unsupported"},
+ {"host": "stable", "case": "shift-enter", "path": "direct", "mode": "kitty", "status": "pass"},
+ {"host": "stable", "case": "shift-enter", "path": "direct", "mode": "kitty", "status": "inconclusive"},
+ ]
+ rows = {row[0]: row[1:] for row in qualification_matrix({"channels": ["stable"], "observations": observations})}
+ self.assertEqual(rows["Shift+Enter"], ("PASS", "X - becomes Enter", "PASS**"))
+ self.assertEqual(rows["Multiline paste"], ("NOT TESTED", "NOT TESTED", "NOT TESTED"))
+ observations.append({"host": "stable", "case": "dead-acute", "path": "herdr", "mode": "mok2", "status": "pass"})
+ observations.extend([
+ {"host": "stable", "case": "letter-a", "path": "direct", "mode": "legacy", "width": 80, "status": "pass"},
+ {"host": "stable", "case": "shift-enter", "path": "direct", "mode": "legacy", "width": 80, "status": "unsupported"},
+ {"host": "stable", "case": "paste-lf", "path": "direct", "mode": "legacy", "width": 80, "status": "pass"},
+ {"host": "stable", "case": "mouse-focus-refresh", "path": "herdr", "mode": "legacy", "width": 80, "status": "pass"},
+ {"host": "stable", "case": "mouse-focus-refresh", "path": "direct", "mode": "legacy", "width": 80, "status": "pass"},
+ {"host": "stable", "case": "mouse-focus-refresh", "path": "direct", "mode": "kitty", "width": 80, "status": "pass"},
+ ])
+ rows = {row[0]: row[1:] for row in qualification_matrix({"channels": ["stable"], "observations": observations})}
+ self.assertEqual(rows["Dead-key composition"][0], "PASS")
+ self.assertEqual(rows["Resize 120 -> 80"][1], "PASS")
+ self.assertEqual(rows["Mouse after resize"], ("PASS", "PASS", "PASS"))
+ self.assertEqual(rows["AltGr"], ("MANUAL", "MANUAL", "MANUAL"))
+ self.assertEqual(rows["IME composition"], ("MANUAL", "MANUAL", "MANUAL"))
+
+ def test_qualification_matrix_requires_every_selected_channel(self):
+ observations = [
+ {"host": "stable", "case": "shift-enter", "path": "herdr", "mode": "mok2", "status": "pass"},
+ ]
+ for result in ({"channels": ["stable", "preview"], "observations": observations},
+ {"observations": observations}):
+ rows = {row[0]: row[1:] for row in qualification_matrix(result)}
+ self.assertEqual(rows["Shift+Enter"][0], "PARTIAL")
+
+ def test_herdr_protocol_label_requires_every_run_to_prove_transport(self):
+ result = {"hosts": [{"channel": "stable", "runs": [{"nonce": "one", "path": "herdr", "mode": "native"},
+ {"nonce": "two", "path": "herdr", "mode": "native"}]}],
+ "observations": [{"host": "stable", "nonce": "one", "path": "herdr", "mode": "native",
+ "input_reader": "windows-console",
+ "input_transport": "win32-serialized"}]}
+ self.assertEqual(herdr_protocol_label(result), "Herdr default (UNKNOWN)*")
+ result["observations"].append({"host": "stable", "nonce": "two", "path": "herdr", "mode": "native",
+ "input_reader": "windows-console",
+ "input_transport": "win32-serialized"})
+ self.assertEqual(herdr_protocol_label(result), "Win32 (Herdr)*")
+ result["hosts"].append({"channel": "preview", "runs": [{"nonce": "one", "path": "herdr", "mode": "native"}]})
+ self.assertEqual(herdr_protocol_label(result), "Herdr default (UNKNOWN)*")
+
+ def test_empty_partial_and_duplicate_reports_never_become_green(self):
+ self.assertFalse(summarize({})["observed_checks_passed"])
+ row = {**self.evidence, "case": "letter-a", "host": "stable", "path": "herdr", "mode": "legacy", "hex": "61", "phase": 1,
+ "width": 120, "height": 30, "outer_geometry": [120, 30], "final_outer_geometry": [120, 30]}
+ self.assertFalse(summarize({"observations": [row]})["observed_checks_passed"])
+ with self.assertRaisesRegex(ValueError, "Duplicate"):
+ summarize({"observations": [row, row]})
+ later = {**row, "phase": 2, "width": 80, "height": 24, "outer_geometry": [80, 24], "final_outer_geometry": [80, 24]}
+ host = {"channel": "stable", "runs": [{"nonce": "owned", "path": "herdr", "mode": "legacy", "pid": 123, "hwnd": 456,
+ "elevated": False, "image_identity": "image-s", "installation_identity": "install-s"}]}
+ row.update(nonce="owned", capture_id="first")
+ later.update(nonce="owned", capture_id="second")
+ self.assertEqual(summarize({"observations": [row, later], "hosts": [host], "controller_elevated": False})["counts"]["pass"], 2)
+ stale = {**later, "capture_id": "first"}
+ self.assertEqual(summarize({"observations": [row, stale], "hosts": [host], "controller_elevated": False})["counts"]["inconclusive"], 1)
+ forged_hosts = [{"channel": name, "runs": [{}]} for name in ("stable", "preview")]
+ partial = summarize({"observations": [row], "hosts": forged_hosts})
+ self.assertFalse(partial["observed_checks_passed"])
+ self.assertGreater(partial["coverage_missing"], 0)
+ for status in ["unsupported", "not_run", "inconclusive"]:
+ self.assertEqual(verdict(self.cases["letter-a"], "legacy", {**row, "status": status})[0], status)
+
+ def test_malformed_native_records_are_inconclusive_not_exceptions(self):
+ evidence = {**self.evidence, "scans": [30]}
+ for records in [None, 7, "records", [None], [[1, 1, 1, 65, 30, 97, None]],
+ [[True, 1, 1, 65, 30, 97, 0]], [[1, 1, 1, 65, 30, 97, "0"]]]:
+ self.assertEqual(verdict(self.cases["letter-a"], "native", {**evidence, "records": records})[0], "inconclusive")
+
+ def test_known_host_gap_only_exempts_observed_terminal_versions_and_cases(self):
+ row = {**self.evidence, "case": "shift-enter", "host": "stable", "path": "direct", "mode": "mok2", "hex": "0d", "phase": 1,
+ "width": 120, "height": 30, "outer_geometry": [120, 30], "final_outer_geometry": [120, 30],
+ "nonce": "owned", "capture_id": "fresh"}
+ for path, version, raw, expected in [("direct", "1.24.11911.0", "0d", "unsupported"),
+ ("direct", "1.25.1912.0", "0d", "unsupported"),
+ ("direct", "1.26.1.0", "0d", "fail"),
+ ("herdr", "1.24.11911.0", "0d", "fail"),
+ ("direct", "1.24.11911.0", "", "fail")]:
+ observation = {**row, "path": path, "hex": raw}
+ run = {"nonce": "owned", "path": path, "mode": "mok2", "pid": 123, "hwnd": 456, "terminal_version": version,
+ "elevated": False, "image_identity": "image-s", "installation_identity": "install-s"}
+ document = {"observations": [observation], "hosts": [{"channel": "stable", "runs": [run]}], "controller_elevated": False}
+ result = summarize(document)["observations"][0]
+ self.assertEqual(result["status"], expected)
+ self.assertEqual(result["failure_scope"], "direct_host" if path == "direct" else "through_herdr_not_yet_attributed")
+ document["controller_elevated"] = True
+ self.assertEqual(summarize(document)["observations"][0]["status"], "inconclusive")
+ document["controller_elevated"] = False
+ run["elevated"] = True
+ self.assertEqual(summarize(document)["observations"][0]["status"], "inconclusive")
+
+ def test_direct_mok_legacy_fallback_is_case_and_version_bounded(self):
+ case = self.cases["shift-tab"]
+ direct = {**self.evidence, "path": "direct", "mode": "mok2", "hex": "1b5b5a"}
+ self.assertTrue(known_host_gap(direct, case, "1.25.2607.10002"))
+ self.assertFalse(known_host_gap(direct, case, "1.26.0.0"))
+ self.assertFalse(known_host_gap({**direct, "path": "herdr"}, case, "1.25.2607.10002"))
+ self.assertFalse(known_host_gap({**direct, "hex": "1b5b32373b323b397e"}, case, "1.25.2607.10002"))
+
+ def test_duplicate_channel_identity_is_rejected_at_both_stages(self):
+ hosts = [{"channel": name, "launcher_identity": name + "-exe", "installation_identity": name + "-dir",
+ "runs": [{"image_identity": name + "-image", "installation_identity": name + "-dir", "process_identity": name + "-pid/start"}]}
+ for name in ("stable", "preview")]
+ self.assertEqual(channel_identity_errors(hosts), [])
+ for field in ("launcher_identity", "installation_identity"):
+ duplicate = copy.deepcopy(hosts)
+ duplicate[1][field] = duplicate[0][field]
+ self.assertTrue(channel_identity_errors(duplicate))
+ self.assertTrue(summarize({"hosts": duplicate})["errors"])
+ for field in ("image_identity", "installation_identity", "process_identity"):
+ duplicate = copy.deepcopy(hosts)
+ duplicate[1]["runs"][0][field] = duplicate[0]["runs"][0][field]
+ self.assertTrue(channel_identity_errors(duplicate))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/windows_input/Bootstrap.ps1 b/scripts/windows_input/Bootstrap.ps1
new file mode 100644
index 00000000..4f6884fb
--- /dev/null
+++ b/scripts/windows_input/Bootstrap.ps1
@@ -0,0 +1,51 @@
+#Requires -Version 7.0
+param([Parameter(Mandatory)][string] $PlanPath)
+. "$PSScriptRoot/Common.ps1"
+$plan = Read-GauntletJson $PlanPath
+# Terminal activation can inherit another process's environment. Normalize here too.
+foreach ($item in @(Get-ChildItem Env:)) {
+ if ($item.Name -like 'HERDR_*' -or $item.Name -in @('SSH_CONNECTION', 'SSH_TTY')) { Remove-Item "Env:$($item.Name)" }
+}
+$env:HERDR_CONFIG_PATH = $plan.config
+$env:XDG_CONFIG_HOME = $plan.config_home
+$env:HERDR_SESSION = $plan.session
+if ($plan.profile -ne 'default') { $env:HERDR_WINDOWS_INPUT_PROBE = $plan.profile }
+Add-Type -Path "$PSScriptRoot/Native.cs"
+$before = [HerdrInputGauntlet.ConsoleProbe]::Geometry()
+$child = $null
+try {
+ if ($plan.path -eq 'herdr') {
+ $child = New-GauntletProcess $plan.exe @('--session', $plan.session) $plan
+ } else {
+ $child = New-GauntletProcess $plan.pwsh @('-NoProfile', '-File', "$PSScriptRoot/Probe.ps1", '-PlanPath', $PlanPath) $plan
+ }
+ $sequence = 0
+ while (-not $child.HasExited -and (Test-GauntletLease $plan.root) -and -not (Test-Path (Join-Path $plan.work 'stop'))) {
+ $sequence++
+ Write-GauntletJson (Join-Path $plan.work 'outer.json') @{
+ nonce = $plan.nonce; pid = $PID; child_pid = $child.Id; sequence = $sequence
+ geometry = [HerdrInputGauntlet.ConsoleProbe]::Geometry(); before = $before
+ }
+ Start-Sleep -Milliseconds 100
+ }
+} finally {
+ $cleanup = @()
+ if ($null -ne $child) {
+ # Allow normal detach / observer disposal before forcing this owned child down.
+ if (-not $child.WaitForExit(5000)) {
+ $cleanup += 'Child required forced termination; normal console restoration was not qualified'
+ $child.Kill($true)
+ if (-not $child.WaitForExit(5000)) { $cleanup += 'Child remained active after forced termination' }
+ }
+ $child.Dispose()
+ }
+ if ($plan.path -eq 'herdr') {
+ foreach ($verb in @('stop', 'delete')) {
+ try { $null = Invoke-GauntletProcess $plan.exe @('session', $verb, $plan.session) $plan }
+ catch { $cleanup += $_.ToString() }
+ }
+ }
+ Write-GauntletJson (Join-Path $plan.work 'bootstrap-exit.json') @{
+ nonce = $plan.nonce; before = $before; after = [HerdrInputGauntlet.ConsoleProbe]::Geometry(); cleanup_errors = $cleanup
+ }
+}
diff --git a/scripts/windows_input/Common.ps1 b/scripts/windows_input/Common.ps1
new file mode 100644
index 00000000..537b66ae
--- /dev/null
+++ b/scripts/windows_input/Common.ps1
@@ -0,0 +1,88 @@
+# Shared test helpers. Dot-sourcing does not touch the desktop or console.
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+function Write-GauntletJson($Path, $Value) {
+ $temporary = "$Path.$([guid]::NewGuid().ToString('N')).tmp"
+ [IO.File]::WriteAllText($temporary, ($Value | ConvertTo-Json -Depth 30), [Text.UTF8Encoding]::new($false))
+ for ($attempt = 0; ; $attempt++) {
+ try { [IO.File]::Move($temporary, $Path, $true); return }
+ catch [IO.IOException], [UnauthorizedAccessException] {
+ if ($attempt -ge 99) { throw }
+ Start-Sleep -Milliseconds 10
+ }
+ }
+}
+
+function Read-GauntletJson($Path) {
+ if (Test-Path -LiteralPath $Path) {
+ $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, ([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete))
+ try {
+ $reader = [IO.StreamReader]::new($stream)
+ try { return $reader.ReadToEnd() | ConvertFrom-Json -AsHashtable }
+ finally { $reader.Dispose() }
+ } finally { $stream.Dispose() }
+ }
+ return $null
+}
+
+function Quote-GauntletPS([string] $Text) { return "'" + $Text.Replace("'", "''") + "'" }
+
+function New-GauntletProcess($Exe, $Arguments, $Plan, [switch] $Capture) {
+ $info = [Diagnostics.ProcessStartInfo]::new($Exe)
+ $info.UseShellExecute = $false
+ foreach ($argument in $Arguments) { $info.ArgumentList.Add([string]$argument) }
+ foreach ($name in @($info.Environment.Keys)) {
+ if ($name -like 'HERDR_*' -or $name -in @('SSH_CONNECTION', 'SSH_TTY')) { $null = $info.Environment.Remove($name) }
+ }
+ if ($null -ne $Plan) {
+ $info.Environment['HERDR_CONFIG_PATH'] = $Plan.config
+ $info.Environment['XDG_CONFIG_HOME'] = $Plan.config_home
+ $info.Environment['HERDR_SESSION'] = $Plan.session
+ if ($Plan.profile -ne 'default') { $info.Environment['HERDR_WINDOWS_INPUT_PROBE'] = $Plan.profile }
+ if ($Plan.path -eq 'herdr') { $info.Environment['HERDR_WINDOWS_INPUT_TRACE_FILE'] = $Plan.input_trace }
+ }
+ $info.RedirectStandardOutput = $Capture.IsPresent
+ $info.RedirectStandardError = $Capture.IsPresent
+ $info.CreateNoWindow = $Capture.IsPresent
+ return [Diagnostics.Process]::Start($info)
+}
+
+function Invoke-GauntletProcess($Exe, $Arguments, $Plan = $null, [int] $Timeout = 30) {
+ $process = New-GauntletProcess $Exe $Arguments $Plan -Capture
+ try {
+ $stdout = $process.StandardOutput.ReadToEndAsync()
+ $stderr = $process.StandardError.ReadToEndAsync()
+ $timer = [Diagnostics.Stopwatch]::StartNew()
+ if (-not $process.WaitForExit($Timeout * 1000)) {
+ $process.Kill($true)
+ throw "Command timed out: $Exe $($Arguments -join ' ')"
+ }
+ $remaining = [Math]::Max(0, $Timeout * 1000 - [int]$timer.ElapsedMilliseconds)
+ if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout, $stderr), $remaining)) {
+ throw "Output streams stayed open after exit: $Exe $($Arguments -join ' ')"
+ }
+ if ($process.ExitCode -ne 0) { throw "Command failed ($($process.ExitCode)): $Exe`n$($stderr.GetAwaiter().GetResult())" }
+ return $stdout.GetAwaiter().GetResult()
+ } finally { $process.Dispose() }
+}
+
+function Update-GauntletLease($Root) {
+ [IO.File]::WriteAllText((Join-Path $Root 'lease'), [DateTime]::UtcNow.ToString('O'))
+}
+
+function Test-GauntletLease($Root) {
+ $lease = Get-Item -LiteralPath (Join-Path $Root 'lease') -ErrorAction SilentlyContinue
+ return $null -ne $lease -and ([DateTime]::UtcNow - $lease.LastWriteTimeUtc).TotalSeconds -lt 90
+}
+
+function Wait-GauntletJson($Path, $Root, $Predicate, [int] $Timeout = 20) {
+ $deadline = [DateTime]::UtcNow.AddSeconds($Timeout)
+ do {
+ Update-GauntletLease $Root
+ $value = Read-GauntletJson $Path
+ if ($null -ne $value -and (& $Predicate $value)) { return $value }
+ Start-Sleep -Milliseconds 100
+ } while ([DateTime]::UtcNow -lt $deadline)
+ throw "Timed out waiting for fresh acknowledgement: $Path"
+}
diff --git a/scripts/windows_input/Native.cs b/scripts/windows_input/Native.cs
new file mode 100644
index 00000000..5f941f1a
--- /dev/null
+++ b/scripts/windows_input/Native.cs
@@ -0,0 +1,439 @@
+// Test-only Windows desktop/console adapter. No calls occur when this file is compiled.
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading;
+
+namespace HerdrInputGauntlet {
+ [ComImport, Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C")]
+ class ApplicationActivationManager { }
+
+ [ComImport, Guid("2E941141-7F97-4756-BA1D-9DECDE894A3D"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ interface IApplicationActivationManager {
+ [PreserveSig]
+ int ActivateApplication([MarshalAs(UnmanagedType.LPWStr)] string appUserModelId,
+ [MarshalAs(UnmanagedType.LPWStr)] string arguments, uint options, out uint processId);
+ }
+
+ public static class Desktop {
+ [StructLayout(LayoutKind.Sequential)] public struct Point { public int X, Y; }
+ [StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left, Top, Right, Bottom; }
+ [StructLayout(LayoutKind.Sequential)] struct Mouse { public int X, Y; public uint Data, Flags, Time; public UIntPtr Extra; }
+ [StructLayout(LayoutKind.Sequential)] struct Key { public ushort Vk, Scan; public uint Flags, Time; public UIntPtr Extra; }
+ [StructLayout(LayoutKind.Explicit)] struct Union { [FieldOffset(0)] public Mouse Mouse; [FieldOffset(0)] public Key Key; }
+ [StructLayout(LayoutKind.Sequential)] struct Input { public uint Type; public Union Data; }
+ delegate bool EnumProc(IntPtr hwnd, IntPtr arg);
+ delegate IntPtr HookProc(int code,IntPtr message,IntPtr data);
+ [StructLayout(LayoutKind.Sequential)] struct Message { public IntPtr Window; public uint Id; public UIntPtr WParam; public IntPtr LParam; public uint Time; public Point Point; public uint Private; }
+ [DllImport("user32.dll",SetLastError=true)] static extern IntPtr SetWindowsHookEx(int kind,HookProc callback,IntPtr module,uint thread);
+ [DllImport("user32.dll")] static extern bool UnhookWindowsHookEx(IntPtr hook);
+ [DllImport("user32.dll")] static extern IntPtr CallNextHookEx(IntPtr hook,int code,IntPtr message,IntPtr data);
+ [DllImport("user32.dll")] static extern int GetMessage(out Message message,IntPtr window,uint min,uint max);
+ [DllImport("user32.dll")] static extern bool PostThreadMessage(uint thread,uint message,UIntPtr wparam,IntPtr lparam);
+ [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId();
+ [DllImport("kernel32.dll",CharSet=CharSet.Unicode)] static extern IntPtr GetModuleHandle(string name);
+ static volatile bool stopped;
+ static Thread stopThread;
+ static uint stopThreadId;
+ static HookProc stopHook;
+ static readonly ManualResetEventSlim stopReady=new ManualResetEventSlim();
+ static string stopError;
+ public static void StartEmergencyStop() {
+ stopThread=new Thread(()=> {
+ IntPtr hook=IntPtr.Zero;
+ try {
+ stopThreadId=GetCurrentThreadId();
+ stopHook=(code,message,data)=> {
+ if(code>=0 && (message.ToInt64()==0x100 || message.ToInt64()==0x104) && Marshal.ReadInt32(data)==0x7B) stopped=true;
+ return CallNextHookEx(IntPtr.Zero,code,message,data);
+ };
+ hook=SetWindowsHookEx(13,stopHook,GetModuleHandle(null),0);
+ if(hook==IntPtr.Zero) throw new Win32Exception();
+ stopReady.Set();
+ Message message;
+ while(GetMessage(out message,IntPtr.Zero,0,0)>0) {}
+ } catch(Exception e) { stopError=e.Message; stopped=true; stopReady.Set(); }
+ finally { if(hook!=IntPtr.Zero) UnhookWindowsHookEx(hook); }
+ }) { IsBackground=true };
+ stopThread.Start();
+ if(!stopReady.Wait(3000) || stopError!=null) throw new Exception("Cannot install emergency-stop latch: "+stopError);
+ }
+ public static void StopEmergencyStop() {
+ if(stopThread!=null) { PostThreadMessage(stopThreadId,0x12,UIntPtr.Zero,IntPtr.Zero); stopThread.Join(1000); }
+ }
+ [DllImport("kernel32.dll",SetLastError=true)] static extern IntPtr OpenProcess(uint access,bool inherit,int pid);
+ [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token);
+ [DllImport("advapi32.dll",SetLastError=true)] static extern bool GetTokenInformation(IntPtr token,int kind,out uint value,int size,out int returned);
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
+ [StructLayout(LayoutKind.Sequential)] struct FileInformation {
+ public uint Attributes,CreationLow,CreationHigh,AccessLow,AccessHigh,WriteLow,WriteHigh;
+ public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow;
+ }
+ [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern Microsoft.Win32.SafeHandles.SafeFileHandle CreateFile(string path,uint access,uint share,IntPtr security,uint creation,uint flags,IntPtr template);
+ [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(Microsoft.Win32.SafeHandles.SafeFileHandle file,out FileInformation info);
+ public static string FileIdentity(string path) {
+ using(var file=CreateFile(path,0,7,IntPtr.Zero,3,0x02000000,IntPtr.Zero)) {
+ FileInformation info;
+ if(file.IsInvalid || !GetFileInformationByHandle(file,out info)) throw new Win32Exception(Marshal.GetLastWin32Error(),"Cannot establish Terminal file/installation identity");
+ return info.Volume.ToString("x8")+":"+info.IndexHigh.ToString("x8")+info.IndexLow.ToString("x8");
+ }
+ }
+ public static void AssertNotElevated(int pid) {
+ var process=OpenProcess(0x1000,false,pid);
+ if(process==IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(),"Cannot verify process elevation; refusing desktop input");
+ IntPtr token=IntPtr.Zero;
+ try {
+ uint elevated; int returned;
+ if(!OpenProcessToken(process,8,out token) || !GetTokenInformation(token,20,out elevated,4,out returned) || returned!=4)
+ throw new Win32Exception(Marshal.GetLastWin32Error(),"Cannot verify process elevation; refusing desktop input");
+ if(elevated!=0) throw new Exception("Elevated controller/Terminal is not allowed. Start PowerShell and Terminal without Run as administrator.");
+ } finally { if(token!=IntPtr.Zero) CloseHandle(token); CloseHandle(process); }
+ }
+ [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc callback, IntPtr arg);
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetWindowText(IntPtr hwnd, StringBuilder value, int count);
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
+ [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid);
+ [DllImport("user32.dll")] static extern IntPtr GetKeyboardLayout(uint thread);
+ [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr hwnd);
+ [DllImport("user32.dll")] static extern bool GetCursorPos(out Point point);
+ [DllImport("user32.dll")] static extern bool SetCursorPos(int x,int y);
+ [DllImport("user32.dll")] static extern int GetSystemMetrics(int index);
+ [DllImport("user32.dll")] static extern short GetAsyncKeyState(int vk);
+ [DllImport("user32.dll")] static extern uint MapVirtualKeyEx(uint key, uint kind, IntPtr layout);
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int ToUnicodeEx(uint key,uint scan,byte[] state,StringBuilder text,int count,uint flags,IntPtr layout);
+ [DllImport("user32.dll", SetLastError=true)] static extern uint SendInput(uint count, Input[] events, int size);
+ [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr hwnd, out Rect rect);
+ [DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr hwnd, IntPtr after, int x, int y, int width, int height, uint flags);
+ [DllImport("user32.dll")] public static extern int CountClipboardFormats();
+ [DllImport("user32.dll")] public static extern uint GetClipboardSequenceNumber();
+ [DllImport("user32.dll")] static extern IntPtr GetClipboardOwner();
+ [DllImport("user32.dll")] static extern bool OpenClipboard(IntPtr owner);
+ [DllImport("user32.dll")] static extern bool CloseClipboard();
+ [DllImport("user32.dll")] static extern bool EmptyClipboard();
+ [DllImport("user32.dll")] static extern IntPtr SetClipboardData(uint format,IntPtr value);
+ [DllImport("kernel32.dll")] static extern IntPtr GlobalAlloc(uint flags,UIntPtr size);
+ [DllImport("kernel32.dll")] static extern IntPtr GlobalLock(IntPtr memory);
+ [DllImport("kernel32.dll")] static extern bool GlobalUnlock(IntPtr memory);
+ [DllImport("kernel32.dll")] static extern IntPtr GlobalFree(IntPtr memory);
+
+ static string QuoteArgument(string value) {
+ if(value.Length>0 && value.IndexOfAny(new[]{' ', '\t', '\n', '\v', '"'})<0) return value;
+ var result=new StringBuilder("\"");
+ int slashes=0;
+ foreach(char c in value) {
+ if(c=='\\') { slashes++; continue; }
+ if(c=='"') result.Append('\\',slashes*2+1);
+ else result.Append('\\',slashes);
+ result.Append(c); slashes=0;
+ }
+ result.Append('\\',slashes*2).Append('"');
+ return result.ToString();
+ }
+ public static int ActivateApplication(string appUserModelId,string[] arguments) {
+ var manager=(IApplicationActivationManager)new ApplicationActivationManager();
+ uint pid;
+ int result=manager.ActivateApplication(appUserModelId,string.Join(" ",Array.ConvertAll(arguments,QuoteArgument)),0,out pid);
+ if(result<0) Marshal.ThrowExceptionForHR(result);
+ return checked((int)pid);
+ }
+
+ public static uint SetEmptyClipboard(IntPtr owner,string text) {
+ if(!OpenClipboard(owner)) throw new Exception("Clipboard busy; refusing replacement");
+ IntPtr memory=IntPtr.Zero;
+ try {
+ if(CountClipboardFormats()!=0) throw new Exception("Clipboard changed or contains user data; refusing replacement");
+ byte[] data=Encoding.Unicode.GetBytes(text+"\0");
+ memory=GlobalAlloc(2,new UIntPtr((uint)data.Length));
+ if(memory==IntPtr.Zero) throw new Exception("Clipboard allocation failed");
+ var pointer=GlobalLock(memory);
+ if(pointer==IntPtr.Zero) throw new Exception("Clipboard lock failed");
+ try { Marshal.Copy(data,0,pointer,data.Length); } finally { GlobalUnlock(memory); }
+ if(!EmptyClipboard() || SetClipboardData(13,memory)==IntPtr.Zero) throw new Exception("Clipboard write failed");
+ memory=IntPtr.Zero; // ownership transferred to Windows
+ } finally { if(memory!=IntPtr.Zero) GlobalFree(memory); CloseClipboard(); }
+ // Closing can synthesize additional formats and advance the sequence.
+ // Reopen before adopting it so another writer cannot become our lease.
+ if(!OpenClipboard(owner)) throw new Exception("Clipboard busy; cannot establish test ownership");
+ try {
+ if(owner==IntPtr.Zero || GetClipboardOwner()!=owner) throw new Exception("Clipboard ownership changed; refusing cleanup lease");
+ return GetClipboardSequenceNumber();
+ } finally { CloseClipboard(); }
+ }
+ public static bool ClearOwnedClipboard(uint sequence) {
+ if(!OpenClipboard(IntPtr.Zero)) return false;
+ try { return GetClipboardSequenceNumber()!=sequence || EmptyClipboard(); }
+ finally { CloseClipboard(); }
+ }
+ public static string Title(IntPtr hwnd) { var text=new StringBuilder(1024); GetWindowText(hwnd,text,text.Capacity); return text.ToString(); }
+ public static IntPtr Find(string nonce) {
+ var matches=new List<IntPtr>();
+ EnumWindows((h,a)=> { if (Title(h).Contains(nonce)) matches.Add(h); return true; },IntPtr.Zero);
+ if(matches.Count>1) throw new Exception("Ambiguous test window identity");
+ return matches.Count==1 ? matches[0] : IntPtr.Zero;
+ }
+ public static int Pid(IntPtr hwnd) { uint pid; GetWindowThreadProcessId(hwnd,out pid); return checked((int)pid); }
+ public static string Layout(IntPtr hwnd) { uint pid; return GetKeyboardLayout(GetWindowThreadProcessId(hwnd,out pid)).ToInt64().ToString("x"); }
+ public static bool IsOwned(IntPtr hwnd, string nonce, int pid) { return hwnd!=IntPtr.Zero && Pid(hwnd)==pid && Title(hwnd).Contains(nonce); }
+ public static void Guard(IntPtr hwnd, string nonce, int pid) {
+ if(!IsOwned(hwnd,nonce,pid) || GetForegroundWindow()!=hwnd) throw new Exception("Lost owned test-window focus; injection aborted");
+ AssertNotElevated(pid);
+ if(stopped || (GetAsyncKeyState(0x7B)&0x8000)!=0) throw new Exception("F12 emergency stop");
+ }
+ public static void Focus(IntPtr hwnd, string nonce, int pid) {
+ if(!IsOwned(hwnd,nonce,pid)) throw new Exception("Lost window ownership");
+ AssertNotElevated(pid);
+ var deadline=DateTime.UtcNow.AddSeconds(3);
+ do {
+ SetForegroundWindow(hwnd);
+ if(GetForegroundWindow()==hwnd) return;
+ Thread.Sleep(100);
+ } while(DateTime.UtcNow<deadline);
+ throw new Exception("Cannot focus test window; no input sent");
+ }
+ public static void FocusAwayAndBack(IntPtr other,IntPtr hwnd,string nonce,int pid) {
+ if(other==IntPtr.Zero || other==hwnd) throw new Exception("No separate controller window for focus-cycle qualification");
+ AssertNotElevated(Pid(other));
+ var deadline=DateTime.UtcNow.AddSeconds(3);
+ do {
+ SetForegroundWindow(other);
+ if(GetForegroundWindow()==other) break;
+ Thread.Sleep(100);
+ } while(DateTime.UtcNow<deadline);
+ if(GetForegroundWindow()!=other) throw new Exception("Cannot focus controller window for focus-cycle qualification");
+ Thread.Sleep(250);
+ Focus(hwnd,nonce,pid);
+ Thread.Sleep(250);
+ }
+ public static void Neutral() {
+ foreach(int key in new[]{0x10,0x11,0x12,0x5B,0x5C,1,2,4})
+ if((GetAsyncKeyState(key)&0x8000)!=0) throw new Exception("Release physical modifiers and mouse buttons before running");
+ }
+ static Input Event(ushort vk, bool release, IntPtr layout) {
+ uint mapped=MapVirtualKeyEx(vk,4,layout);
+ if(mapped==0) throw new Exception("No scan code for virtual key "+vk);
+ return new Input { Type=1, Data=new Union { Key=new Key { Scan=(ushort)(mapped&255), Flags=8u|(release?2u:0u)|((mapped&0xff00)!=0?1u:0u) } } };
+ }
+ public static int[] Scans(IntPtr hwnd,int[] keys) {
+ uint ignored; var layout=GetKeyboardLayout(GetWindowThreadProcessId(hwnd,out ignored));
+ var scans=new List<int>();
+ foreach(int key in keys) scans.Add(Event((ushort)key,false,layout).Data.Key.Scan);
+ return scans.ToArray();
+ }
+ static int[] LayoutChord(int vk,int modifiers) {
+ var keys=new List<int>();
+ if((modifiers&1)!=0) keys.Add(0x10);
+ if((modifiers&2)!=0) keys.Add(0x11);
+ if((modifiers&4)!=0) keys.Add(0x12);
+ keys.Add(vk);
+ return keys.ToArray();
+ }
+ public static int[] DeadKeyChord(IntPtr hwnd,char accent) {
+ uint ignored; var layout=GetKeyboardLayout(GetWindowThreadProcessId(hwnd,out ignored));
+ for(int modifiers=0;modifiers<8;modifiers++) for(uint vk=1;vk<255;vk++) {
+ uint scan=MapVirtualKeyEx(vk,4,layout);
+ if(scan==0) continue;
+ var state=new byte[256];
+ if((modifiers&1)!=0) state[0x10]=0x80;
+ if((modifiers&2)!=0) state[0x11]=0x80;
+ if((modifiers&4)!=0) state[0x12]=0x80;
+ var text=new StringBuilder(4);
+ // Flag 4 discovers dead keys without changing keyboard state.
+ if(ToUnicodeEx(vk,scan,state,text,text.Capacity,4,layout)<0 && text.Length>0 && text[0]==accent)
+ return LayoutChord((int)vk,modifiers);
+ }
+ throw new Exception("Active layout has no "+accent+" dead key");
+ }
+ // One balanced batch; no modifier is intentionally held between calls.
+ public static int Chord(IntPtr hwnd, string nonce, int pid, int[] keys) {
+ Guard(hwnd,nonce,pid); Neutral();
+ uint ignored; var layout=GetKeyboardLayout(GetWindowThreadProcessId(hwnd,out ignored));
+ var events=new List<Input>();
+ foreach(int key in keys) events.Add(Event(checked((ushort)key),false,layout));
+ for(int i=keys.Length-1;i>=0;i--) events.Add(Event(checked((ushort)keys[i]),true,layout));
+ Guard(hwnd,nonce,pid);
+ uint sent=SendInput((uint)events.Count,events.ToArray(),Marshal.SizeOf<Input>());
+ if(sent!=events.Count) {
+ // Release only keys whose down event was actually submitted and not yet released.
+ var held=new List<Input>();
+ for(int i=0;i<keys.Length && i<sent;i++)
+ if(sent <= 2*keys.Length-1-i) held.Add(Event((ushort)keys[i],true,layout));
+ if(held.Count>0) {
+ uint released=SendInput((uint)held.Count,held.ToArray(),Marshal.SizeOf<Input>());
+ if(released!=held.Count) throw new Exception("SendInput cleanup incomplete: "+released+"/"+held.Count+" key-up events; release test modifiers manually before continuing");
+ }
+ throw new Exception("SendInput incomplete (possible UIPI restriction): "+sent+"/"+events.Count);
+ }
+ return (int)sent;
+ }
+ public static long Cursor() {
+ Point point;
+ if(!GetCursorPos(out point)) throw new Win32Exception();
+ return ((long)(uint)point.X<<32)|(uint)point.Y;
+ }
+ public static void RestoreCursor(long packed) {
+ if(!SetCursorPos(unchecked((int)(packed>>32)),unchecked((int)packed))) throw new Win32Exception();
+ }
+ public static void MouseMoveInside(IntPtr hwnd,string nonce,int pid,int offset) {
+ Guard(hwnd,nonce,pid); Neutral(); Rect rect;
+ if(!GetWindowRect(hwnd,out rect)) throw new Win32Exception();
+ int x=(rect.Left+rect.Right)/2+offset,y=(rect.Top+rect.Bottom)/2;
+ if(x<=rect.Left+20 || x>=rect.Right-20 || y<=rect.Top+20 || y>=rect.Bottom-20) throw new Exception("Mouse target is outside the safe window interior");
+ int left=GetSystemMetrics(76),top=GetSystemMetrics(77),width=GetSystemMetrics(78),height=GetSystemMetrics(79);
+ if(width<2 || height<2) throw new Exception("Virtual desktop geometry unavailable");
+ int nx=(int)Math.Round((x-left)*65535.0/(width-1)),ny=(int)Math.Round((y-top)*65535.0/(height-1));
+ var input=new Input { Type=0, Data=new Union { Mouse=new Mouse { X=nx,Y=ny,Flags=0xC001 } } };
+ if(SendInput(1,new[]{input},Marshal.SizeOf<Input>())!=1) throw new Win32Exception(Marshal.GetLastWin32Error(),"Mouse injection failed");
+ }
+ public static void MouseClickWheelInside(IntPtr hwnd,string nonce,int pid,int offset) {
+ Guard(hwnd,nonce,pid); Neutral(); Rect rect;
+ if(!GetWindowRect(hwnd,out rect)) throw new Win32Exception();
+ int x=(rect.Left+rect.Right)/2+offset,y=(rect.Top+rect.Bottom)/2;
+ if(x<=rect.Left+20 || x>=rect.Right-20 || y<=rect.Top+20 || y>=rect.Bottom-20) throw new Exception("Mouse target is outside the safe window interior");
+ int left=GetSystemMetrics(76),top=GetSystemMetrics(77),width=GetSystemMetrics(78),height=GetSystemMetrics(79);
+ if(width<2 || height<2) throw new Exception("Virtual desktop geometry unavailable");
+ int nx=(int)Math.Round((x-left)*65535.0/(width-1)),ny=(int)Math.Round((y-top)*65535.0/(height-1));
+ var inputs=new[]{
+ new Input { Type=0, Data=new Union { Mouse=new Mouse { X=nx,Y=ny,Flags=0xC001 } } },
+ new Input { Type=0, Data=new Union { Mouse=new Mouse { Flags=0x0002 } } },
+ new Input { Type=0, Data=new Union { Mouse=new Mouse { Flags=0x0004 } } },
+ new Input { Type=0, Data=new Union { Mouse=new Mouse { Data=120,Flags=0x0800 } } }
+ };
+ uint sent=SendInput((uint)inputs.Length,inputs,Marshal.SizeOf<Input>());
+ if(sent!=inputs.Length) {
+ int error=Marshal.GetLastWin32Error();
+ if(sent==2 && SendInput(1,new[]{inputs[2]},Marshal.SizeOf<Input>())!=1)
+ throw new Win32Exception(Marshal.GetLastWin32Error(),"Mouse injection failed after button-down; release the left mouse button manually");
+ throw new Win32Exception(error,"Mouse click/wheel injection failed");
+ }
+ }
+ public static void Resize(IntPtr hwnd,string nonce,int pid,int dx,int dy) {
+ Guard(hwnd,nonce,pid); Rect r;
+ if(!GetWindowRect(hwnd,out r)) throw new Win32Exception();
+ int w=r.Right-r.Left+dx,h=r.Bottom-r.Top+dy;
+ if(w<300 || h<200 || w>10000 || h>10000) throw new Exception("Unsafe resize request");
+ if(!SetWindowPos(hwnd,IntPtr.Zero,0,0,w,h,0x16)) throw new Win32Exception();
+ }
+ }
+
+ public sealed class ConsoleProbe : IDisposable {
+ [StructLayout(LayoutKind.Sequential)] struct Coord { public short X,Y; }
+ [StructLayout(LayoutKind.Sequential)] struct SmallRect { public short Left,Top,Right,Bottom; }
+ [StructLayout(LayoutKind.Sequential)] struct Info { public Coord Size,Cursor; public ushort Attributes; public SmallRect Window; public Coord Maximum; }
+ [StructLayout(LayoutKind.Explicit,Size=20)] struct Record {
+ [FieldOffset(0)] public ushort Type;
+ [FieldOffset(4)] public int Down;
+ [FieldOffset(8)] public ushort Repeat;
+ [FieldOffset(10)] public ushort Vk;
+ [FieldOffset(12)] public ushort Scan;
+ [FieldOffset(14)] public ushort Unicode;
+ [FieldOffset(16)] public uint Control;
+ }
+ [DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int kind);
+ [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetConsoleMode(IntPtr handle,out uint mode);
+ [DllImport("kernel32.dll",SetLastError=true)] static extern bool SetConsoleMode(IntPtr handle,uint mode);
+ [DllImport("kernel32.dll")] static extern bool GetConsoleScreenBufferInfo(IntPtr handle,out Info info);
+ [DllImport("kernel32.dll")] static extern uint GetConsoleCP();
+ [DllImport("kernel32.dll")] static extern bool SetConsoleCP(uint cp);
+ [DllImport("kernel32.dll")] static extern bool ReadFile(IntPtr handle,byte[] buffer,uint size,out uint count,IntPtr overlap);
+ [DllImport("kernel32.dll")] static extern bool WriteFile(IntPtr handle,byte[] buffer,uint size,out uint count,IntPtr overlap);
+ [DllImport("kernel32.dll")] static extern bool ReadConsoleInputW(IntPtr handle,Record[] records,uint size,out uint count);
+ [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId();
+ [DllImport("kernel32.dll")] static extern IntPtr OpenThread(uint access,bool inherit,uint id);
+ [DllImport("kernel32.dll")] static extern bool CancelSynchronousIo(IntPtr thread);
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
+ readonly object gate=new object();
+ readonly List<byte> bytes=new List<byte>();
+ readonly List<long[]> records=new List<long[]>();
+ readonly IntPtr input=GetStdHandle(-10), output=GetStdHandle(-11);
+ readonly uint original, outputMode, cp;
+ readonly bool native;
+ volatile bool running=true;
+ Thread reader;
+ IntPtr threadHandle;
+ string error;
+ public string Error {
+ get { return Volatile.Read(ref error); }
+ private set { Volatile.Write(ref error,value); }
+ }
+ public ConsoleProbe(string mode) {
+ if(!GetConsoleMode(input,out original) || !GetConsoleMode(output,out outputMode)) throw new Exception("Probe needs a real console");
+ cp=GetConsoleCP(); native=mode=="native";
+ try {
+ if(!SetConsoleCP(65001) || !SetConsoleMode(output,outputMode|4) || !SetConsoleMode(input,native ? (original & ~0x247u)|0x98u : (original & ~7u)|0x200u)) throw new Win32Exception();
+ Print("\x1b[?2004h"+(mode=="kitty"?"\x1b[>1u\x1b[?u":mode=="mok2"?"\x1b[>4;2m":""));
+ reader=new Thread(Read) { IsBackground=true }; reader.Start();
+ } catch { SetConsoleMode(input,original); SetConsoleMode(output,outputMode); SetConsoleCP(cp); throw; }
+ }
+ public static long[] Geometry() {
+ Info info; uint mode;
+ if(!GetConsoleScreenBufferInfo(GetStdHandle(-11),out info) || !GetConsoleMode(GetStdHandle(-10),out mode)) throw new Exception("Console geometry unavailable");
+ return new long[]{ info.Window.Right-info.Window.Left+1,info.Window.Bottom-info.Window.Top+1,mode };
+ }
+ public static void Print(string text) {
+ byte[] data=Encoding.UTF8.GetBytes(text); uint count;
+ if(!WriteFile(GetStdHandle(-11),data,(uint)data.Length,out count,IntPtr.Zero) || count!=data.Length) throw new Exception("Console output failed");
+ }
+ public void SetKeyboardMode(string mode) {
+ string sequence="\x1b[<u\x1b[>4;0m";
+ if(mode=="mok2") sequence+="\x1b[>4;2m";
+ else if(mode=="kitty") sequence+="\x1b[>1u";
+ else if(mode!="legacy") throw new Exception("Unknown keyboard mode transition");
+ Print(sequence);
+ }
+ void Read() {
+ threadHandle=OpenThread(1,false,GetCurrentThreadId());
+ if(threadHandle==IntPtr.Zero) { Error="Cannot acquire reader cancellation handle"; return; }
+ try {
+ while(running) {
+ if(Count()>1048576) { Error="Probe input limit exceeded"; break; }
+ uint count;
+ if(native) {
+ var batch=new Record[64];
+ if(!ReadConsoleInputW(input,batch,64,out count)) { if(running) Error="ReadConsoleInputW failed"; break; }
+ lock(gate) for(int i=0;i<count;i++) {
+ var r=batch[i];
+ // Preserve every native record, including non-key event type and payload.
+ records.Add(new long[]{r.Type,r.Down,r.Repeat,r.Vk,r.Scan,r.Unicode,r.Control});
+ }
+ } else {
+ var batch=new byte[4096];
+ if(!ReadFile(input,batch,4096,out count,IntPtr.Zero)) { if(running) Error="ReadFile failed"; break; }
+ lock(gate) for(int i=0;i<count;i++) bytes.Add(batch[i]);
+ }
+ }
+ } catch(Exception e) { Error=e.Message; }
+ }
+ public void Clear() { lock(gate) { bytes.Clear(); records.Clear(); } }
+ public bool ClearIfCount(int expected) {
+ lock(gate) {
+ if(bytes.Count+records.Count!=expected) return false;
+ bytes.Clear(); records.Clear(); return true;
+ }
+ }
+ public string Hex() { lock(gate) return BitConverter.ToString(bytes.ToArray()).Replace("-","").ToLowerInvariant(); }
+ public long[][] Records() { lock(gate) return records.ToArray(); }
+ public int Count() { lock(gate) return bytes.Count+records.Count; }
+ public string[] CleanupErrors { get; private set; } = new string[0];
+ public void Dispose() {
+ var errors=new List<string>();
+ running=false;
+ if(threadHandle!=IntPtr.Zero) CancelSynchronousIo(threadHandle);
+ if(reader!=null && !reader.Join(1000)) errors.Add("Reader did not stop");
+ if(threadHandle!=IntPtr.Zero) CloseHandle(threadHandle);
+ try { Print("\x1b[<u\x1b[>4;0m\x1b[?2004l\x1b[?1000l\x1b[?1006l"); }
+ catch(Exception e) { errors.Add(e.Message); }
+ if(!SetConsoleMode(input,original)) errors.Add("Input-mode restoration failed");
+ if(!SetConsoleMode(output,outputMode)) errors.Add("Output-mode restoration failed");
+ if(!SetConsoleCP(cp)) errors.Add("Code-page restoration failed");
+ uint actual;
+ if(!GetConsoleMode(input,out actual) || actual!=original) errors.Add("Input mode differs after restoration");
+ if(!GetConsoleMode(output,out actual) || actual!=outputMode) errors.Add("Output mode differs after restoration");
+ if(GetConsoleCP()!=cp) errors.Add("Code page differs after restoration");
+ CleanupErrors=errors.ToArray();
+ }
+ }
+}
diff --git a/scripts/windows_input/Probe.ps1 b/scripts/windows_input/Probe.ps1
new file mode 100644
index 00000000..ad3a9c2b
--- /dev/null
+++ b/scripts/windows_input/Probe.ps1
@@ -0,0 +1,70 @@
+#Requires -Version 7.0
+param([Parameter(Mandatory)][string] $PlanPath)
+. "$PSScriptRoot/Common.ps1"
+$plan = Read-GauntletJson $PlanPath
+Add-Type -Path "$PSScriptRoot/Native.cs"
+$probe = $null
+try {
+ $probe = [HerdrInputGauntlet.ConsoleProbe]::new($plan.mode)
+ [HerdrInputGauntlet.ConsoleProbe]::Print("`r`nHerdr input gauntlet observer $($plan.nonce)`r`n")
+ if ($plan.mode -eq 'kitty') {
+ $deadline = [DateTime]::UtcNow.AddSeconds(2)
+ while (-not $probe.Hex().Contains('1b5b3f3175') -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 50 }
+ }
+ Write-GauntletJson (Join-Path $plan.work 'ready.json') @{
+ nonce = $plan.nonce; pid = $PID; mode = $plan.mode; geometry = [HerdrInputGauntlet.ConsoleProbe]::Geometry()
+ negotiation_hex = $probe.Hex(); kitty_acknowledged = $probe.Hex().Contains('1b5b3f3175')
+ }
+ # Negotiation replies are setup evidence, not input for the first case.
+ $probe.Clear()
+ $closedCount = 0
+ $last = ''
+ while ((Test-GauntletLease $plan.root) -and -not (Test-Path (Join-Path $plan.work 'probe-stop'))) {
+ $request = Read-GauntletJson (Join-Path $plan.work 'request.json')
+ if ($null -ne $request -and $request.id -ne $last) {
+ if ($request.nonce -ne $plan.nonce) { throw 'Unexpected observer nonce' }
+ $last = $request.id
+ $quietReached = $false
+ if ($request.action -eq 'mouse-on') {
+ [HerdrInputGauntlet.ConsoleProbe]::Print("`e[?1003h`e[?1006h")
+ Start-Sleep -Milliseconds 200
+ }
+ if ($request.action -eq 'mouse-off') {
+ [HerdrInputGauntlet.ConsoleProbe]::Print("`e[?1003l`e[?1006l")
+ Start-Sleep -Milliseconds 100
+ }
+ if ($request.action -eq 'set-mode') {
+ $probe.SetKeyboardMode([string]$request.value)
+ Start-Sleep -Milliseconds 200
+ }
+ if ($request.action -eq 'begin' -and -not $probe.ClearIfCount($closedCount)) { throw 'Unexpected input arrived between captures' }
+ if ($request.action -eq 'end') {
+ # Capture trailing duplicates/releases as well as the first expected bytes.
+ $deadline = [DateTime]::UtcNow.AddSeconds(3)
+ $quiet = [DateTime]::UtcNow
+ $count = $probe.Count()
+ do {
+ Start-Sleep -Milliseconds 50
+ $newCount = $probe.Count()
+ if ($newCount -ne $count) { $quiet = [DateTime]::UtcNow; $count = $newCount }
+ } while (([DateTime]::UtcNow - $quiet).TotalMilliseconds -lt 350 -and [DateTime]::UtcNow -lt $deadline)
+ $quietReached = ([DateTime]::UtcNow - $quiet).TotalMilliseconds -ge 350
+ $closedCount = $probe.Count()
+ }
+ Write-GauntletJson (Join-Path $plan.work 'reply.json') @{
+ id = $request.id; nonce = $plan.nonce; action = $request.action
+ hex = $probe.Hex(); records = @($probe.Records()); error = $probe.Error; quiet_reached = $quietReached
+ geometry = [HerdrInputGauntlet.ConsoleProbe]::Geometry()
+ }
+ }
+ Start-Sleep -Milliseconds 25
+ }
+} catch {
+ Write-GauntletJson (Join-Path $plan.work 'probe-error.json') @{ error = $_.ToString(); nonce = $plan.nonce }
+ throw
+} finally {
+ if ($null -ne $probe) {
+ $probe.Dispose()
+ Write-GauntletJson (Join-Path $plan.work 'probe-exit.json') @{ nonce = $plan.nonce; cleanup_errors = @($probe.CleanupErrors) }
+ }
+}
diff --git a/scripts/windows_input/README.md b/scripts/windows_input/README.md
new file mode 100644
index 00000000..340ecb0e
--- /dev/null
+++ b/scripts/windows_input/README.md
@@ -0,0 +1,213 @@
+# Local Windows input gauntlet (experimental)
+
+This is local Windows qualification infrastructure, not a claim that all Windows
+input works. It sends real scan-code gestures through a new Windows Terminal
+window, both directly to an observer and through an attached Herdr client. It
+does not substitute `pane send-keys` for host input.
+
+## Safety and prerequisites
+
+Use an **unlocked, isolated interactive desktop** where you will not do other
+work during automated input. The runner changes foreground focus and window
+size. `SendInput` has an unavoidable check-to-use focus race; a foreground check
+is not an OS security boundary. The runner rejects an elevated controller and
+checks the target Terminal process token before focus, resize, and injection.
+An elevation-query failure is also a refusal, not permission to proceed. Start
+PowerShell and Terminal **without Run as administrator**, on a dedicated desktop. There is no unattended CI job or runner permission
+change in this implementation.
+
+Required: Windows, PowerShell **7** (`pwsh`), Python 3, Rust/Cargo, and at least
+one Windows Terminal channel. Stable and Preview are discovered independently
+through their installed packages. Explicit paths are available when discovery
+does not work. No software is installed or updated. The actual Terminal process path/version is recorded,
+not inferred from the requested channel or bundled OpenConsole version. Stable
+and Preview must resolve to distinct installations: the runner compares Windows
+file/directory identities before launch, then checks actual image, installation,
+and PID/start-time identities after activation. Aliases cannot count one install
+twice. The portable report validator rejects duplicate identities too.
+
+**F12** aborts before the next automatic gesture. Moving focus away also aborts
+further automatic input. Each injected chord contains its own releases; there
+are no intentionally held modifiers between calls. Do not hold physical modifiers
+or mouse buttons while starting a run. Normal controller cancellation runs cleanup;
+bootstrap/probe leases also expire if the controller disappears. Lease expiry is
+not a replacement for a secure isolated desktop.
+
+Clipboard tests require an **empty clipboard**. Clear it yourself only after
+saving anything you need. The runner will not replace existing text, images,
+rich formats, or files. It writes its synthetic text while holding the clipboard
+lock and clears it afterward only if its sequence number is unchanged. A later
+user clipboard update is left alone. No original clipboard contents are logged.
+
+## Run
+
+From the repository in PowerShell 7:
+
+```powershell
+just test-windows-input
+```
+
+The full catalogue normally exits `2` because operator-assisted and explicitly
+unimplemented qualification cases remain. That is incomplete coverage, not an
+automated test failure; inspect the printed matrix and retained `report.json`.
+
+The recipe itself is the explicit opt-in to foreground input injection. It builds
+the current checkout in release mode, stages that exact binary with the pinned
+ConPTY runtime, and prints its path and hash. Use `-ExePath` only to compare a
+specific old/new packaged binary.
+
+Or invoke the script directly:
+
+```powershell
+pwsh -NoProfile -File scripts/test_windows_input.ps1 `
+ -ExePath 'C:\test-app\herdr.exe' -AllowInputInjection `
+ -StablePath 'C:\TerminalStable\WindowsTerminal.exe' `
+ -PreviewPath 'C:\TerminalPreview\WindowsTerminal.exe'
+```
+
+The default tests current Herdr's **default** input policy. Diagnostic runs may
+use `-Profile win32` or `-Profile vt`; they do not replace the default run.
+`-Modes native,legacy,mok2,kitty`, `-Channels`, `-Paths`, `-Cases`, `-Widths`, and
+`-Heights` select a focused campaign. In a direct PowerShell invocation, supply
+arrays normally:
+
+```powershell
+.\scripts\test_windows_input.ps1 -ExePath 'C:\test-app\herdr.exe' `
+ -AllowInputInjection -Modes legacy,kitty -Cases mouse-interleave,mode-transitions `
+ -Widths 120 -Heights 30
+```
+
+Dead-key acute composition is automatic when the target Terminal thread's
+active layout exposes that physical mapping. The runner discovers and injects
+the real scan-code chord; it never substitutes pasted or Unicode-packet text.
+`-Manual` enables AltGr, IME, and the remaining guided composition cases. The operator must activate the
+indicated layout/IME, perform the gesture in the test window, then return to the
+controller and press Enter. Finish each prompt within the 90-second lease.
+Record the layout you actually selected in your qualification notes; the report
+also includes the observed foreground thread layout handle. Do not paste text
+for a composition case. The runner does not install/change global layouts.
+
+You can inspect the full catalogue without Windows or desktop interaction:
+
+```powershell
+pwsh -NoProfile -File scripts/test_windows_input.ps1 -ExePath unused -MatrixOnly
+```
+
+Every run needs a **new** output directory. By default it is
+`.local/windows-input/<unique-id>/`. Do not reuse an old report directory.
+
+## What the implementation measures
+
+- Native console records, including down/up, repeat, virtual/scan codes, Unicode,
+ modifier state, and raw payloads for other native record types.
+- Legacy VT, modifyOtherKeys level 2, and Kitty disambiguation observer modes.
+ These are **pane consumer modes**, not names for Herdr's outer reader.
+- Physical-style Enter/Shift+Enter and other modifier chords, navigation and word
+ movement chords, control keys, and editing keys. The oracle checks bytes or
+ records; it does not claim that a particular editor implements word selection
+ correctly merely because Ctrl+Shift+Right reached it.
+- Actual Windows clipboard paste via Ctrl+V, including LF/CRLF/CR, whitespace,
+ BMP Unicode/combining characters, and escape-looking text.
+ A host binding or multiline-paste confirmation dialog can intercept the gesture;
+ the runner does not dismiss unexpected dialogs or rebind Terminal shortcuts.
+- A full case pass at an observed 120×30 host size; keyboard/paste sentinels at
+ **80, 119, 120, 121, 132, 160, 240 columns**, at 24 and 50 rows; then return to
+ 80 columns. This exercises narrow→wide→narrow resizing of the actual outer
+ window. Both outer and pane dimensions are captured. Herdr chrome means pane
+ width is not the same as outer width. An unreachable size is not a pass.
+- Automatic active-layout dead-key acute input, plus guided AltGr, extended
+ accent, and IME commits, with exact expected committed text.
+- Ordered typing, mouse-motion reports, and bracketed paste in one capture, plus
+ click/wheel reporting before and after a real focus cycle, the same mouse
+ sentinel after resize, and legacy→modifyOtherKeys→Kitty→modifyOtherKeys→legacy
+ transitions without restart.
+
+The focus/resize mouse check runs in Windows Terminal. It guards Herdr's recovery
+sequence but does not certify the Tabby/Alacritty host-specific report in #4284.
+The catalogue also lists explicit **qualification gaps**: mouse drag and
+right-edge coordinate mapping; visual reflow/wrapping; native held-key repeat;
+lock/keypad combinations; dead-key cancellation; IME cancellation; capture/config
+reload and attach cycles; injected setup/recovery faults; supplementary-plane and
+confirmation-triggering burst paste; image/file clipboard integrations; and
+positive host-scrollback evidence for native PageUp/PageDown. These are recorded
+`not_run` or `inconclusive`, not fabricated successes. They need
+separate fixtures/oracles before becoming automated assertions. The catalogue is
+broad; the automated run does **not cover every row**.
+
+Injected PageUp/PageDown remains `inconclusive` through Herdr. On Windows
+Terminal 1.24, both `SendInput` and `keybd_event` produced zeroed non-key records
+even in the direct-host baseline, so absence of pane input cannot prove that
+Herdr consumed the key. Conclusive qualification requires literal physical input
+plus a positive pane scroll-offset change; that guided check is not automated yet.
+
+## Evidence and verdicts
+
+The retained directory contains:
+
+- `matrix.json`: all cases, profiles, and declared byte/record expectations;
+- `observations.json`: raw captures, per-case identity, measured dimensions,
+ readiness/focus flags, errors, and cleanup results;
+- `report.json`: interpreted verdicts and counts;
+- per-window plans, nonce-bound observer acknowledgements, initial/final console
+ modes, and bootstrap/probe error records.
+
+The console ends with a capability matrix derived only from that run's captured
+observations. Filtered, unavailable, or operator-assisted cases remain
+`NOT TESTED` or `MANUAL`; the full evidence and reasons remain in `report.json`.
+The Herdr column is labelled Win32 only after the current binary's selected
+reader decodes a real nonce-owned Win32 serialized record. Configuration defaults
+alone do not select the label; missing runtime evidence prints `UNKNOWN`.
+
+Direct-host and through-Herdr observations are labelled separately. A direct-host
+failure is **not automatically a Herdr bug**. Each row labels its failure scope as
+`direct_host` or `through_herdr_not_yet_attributed`. For example, a terminal may
+intercept Alt+Enter or use a different control-key encoding.
+
+One explicit known-gap rule labels nonce-bound, complete **WT 1.24/1.25
+direct-host mOK captures** of Shift/Ctrl/Ctrl+Shift+Enter or Shift+Tab as
+`unsupported` only when they contain the exact legacy bytes. It does not exempt
+other tests or versions, an empty/malformed capture, Kitty, or any through-Herdr
+failure. Through-Herdr still must preserve the requested gesture.
+Unsupported remains incomplete coverage, not green. Review captures before
+adding other capability exceptions; never bless lost Shift information in Herdr
+just to make a report green.
+
+Comparisons consume the **complete captured sequence**, including a quiet interval
+to catch trailing duplicates/releases. Paste must have one intact bracketed
+wrapper and the complete payload. CR/LF variants are compared as logical newlines;
+this is not proof of byte-exact line-ending policy. Native keys require their
+non-modifier down/up pair, modifier state, and repeat count.
+
+Exit codes:
+
+- `0`: all observed assertions passed and no recorded coverage gaps/errors;
+- `1`: an assertion, harness, or cleanup failed;
+- `2`: coverage is incomplete (including deliberately unimplemented catalogue
+ rows, missing hosts, or unavailable geometry).
+
+An empty or missing report never means success. None of these statuses certifies
+an entire Windows host.
+
+## Cleanup and native handoff
+
+Every window has a fresh nonce, named session, isolated config root, known
+PowerShell pane shell, and observer acknowledgement. Inherited Herdr socket,
+backend, session, and SSH variables are cleared in the actual launched process,
+not just in the controller. Setup APIs only create/focus the observer pane.
+
+Cleanup targets the named session, retained child-process handles, and the exact
+nonce-bearing Terminal window. There is no process-name-wide kill or sweep of
+newly appeared OpenConsole processes. Artifacts are retained. Forced cleanup and
+console-mode mismatches are reported, not hidden behind a passing key test.
+
+For release-risk input changes, qualify Stable and Preview, real focus-loss/F12
+interruption, partial startup, clipboard changes, cleanup, and measured width
+boundaries. Use old and new Herdr binaries in **separate runs** to establish that
+a known regression is caught. Do not overwrite installations or reuse an earlier
+server. Portable unit tests validate the catalogue and verdict logic; they do not
+replace native Windows evidence.
+
+The existing `windows_conpty_enhanced_input_probe.ps1` remains the downstream API
+control. Existing Rust keyboard/Windows-translator tests remain the deterministic
+parser controls. This runner complements them rather than cross-producting their
+fixtures or pretending they establish host behavior.
diff --git a/scripts/windows_input/report.py b/scripts/windows_input/report.py
new file mode 100644
index 00000000..7c15c149
--- /dev/null
+++ b/scripts/windows_input/report.py
@@ -0,0 +1,446 @@
+"""Portable case catalogue and strict evidence verdicts for the local Windows gauntlet.
+
+This is not a host simulator. A report cannot turn absent Windows evidence into a pass.
+"""
+import argparse
+import json
+import re
+from pathlib import Path
+
+WIDTHS = [80, 119, 120, 121, 132, 160, 240]
+HEIGHTS = [24, 50]
+MODES = ["native", "legacy", "mok2", "kitty"]
+
+
+def hex_of(text):
+ return text.encode("utf-8").hex()
+
+
+def catalogue():
+ cases = []
+
+ def key(name, vk, text, modifiers=(), enhanced=None, legacy=None):
+ mask = (1 if 16 in modifiers else 0) | (2 if 18 in modifiers else 0) | (4 if 17 in modifiers else 0)
+ unicode = (vk + 32 if 65 <= vk <= 90 else vk if vk in (8, 9, 13, 27) else 0)
+ if 65 <= vk <= 90 and 16 in modifiers:
+ unicode = vk
+ if 17 in modifiers:
+ unicode = vk - 64 if 65 <= vk <= 90 else 10 if vk == 13 else 127 if vk == 8 else 0
+ expected = {"native": {"vk": vk, "modifiers": mask, "unicode": unicode, "modifier_keys": list(modifiers)}}
+ if text is not None:
+ expected.update({mode: {"hex": [hex_of(text)]} for mode in MODES[1:]})
+ if legacy is not None:
+ expected["legacy"] = {"hex": [hex_of(legacy)]}
+ if enhanced is not None:
+ expected["mok2"] = {"hex": [hex_of(f"\x1b[27;{mask + 1};{enhanced}~")]}
+ expected["kitty"] = {"hex": [hex_of(f"\x1b[{enhanced};{mask + 1}u")]}
+ cases.append(dict(id=name, kind="key", chords=[[*modifiers, vk]], expected=expected))
+
+ key("letter-a", 65, "a")
+ key("shift-letter", 65, "A", (16,))
+ key("enter", 13, "\r")
+ key("shift-enter", 13, None, (16,), 13)
+ cases[-1]["expected"]["legacy"] = {"loss": "enter", "hex": ["0d"]}
+ key("ctrl-enter", 13, None, (17,), 13)
+ cases[-1]["expected"]["legacy"] = {"loss": "modifier", "hex": ["0a"]}
+ key("ctrl-shift-enter", 13, None, (17, 16), 13)
+ cases[-1]["expected"]["legacy"] = {"loss": "modifier", "hex": ["0a"]}
+ key("tab", 9, "\t")
+ key("shift-tab", 9, "\x1b[Z", (16,))
+ cases[-1]["expected"]["mok2"] = {"hex": [hex_of("\x1b[27;2;9~")]}
+ cases[-1]["expected"]["kitty"]["hex"].append(hex_of("\x1b[9;2u"))
+ key("backspace", 8, "\x7f")
+ key("ctrl-backspace", 8, None, (17,), 127, "\x08")
+ key("alt-backspace", 8, None, (18,), 127, "\x1b\x7f")
+ key("escape", 27, "\x1b")
+ # Kitty's disambiguation flag encodes Escape distinctly.
+ cases[-1]["expected"]["kitty"] = {"hex": [hex_of("\x1b[27u")]}
+ for name, vk, seq, kitty in [("up", 38, "A", 57419), ("down", 40, "B", 57420), ("right", 39, "C", 57418),
+ ("left", 37, "D", 57417), ("home", 36, "H", 57423), ("end", 35, "F", 57424)]:
+ for prefix, modifiers, modifier in [("", (), 1), ("shift-", (16,), 2), ("ctrl-", (17,), 5), ("ctrl-shift-", (17, 16), 6)]:
+ if prefix == "ctrl-shift-" and name in ("up", "down", "home", "end"):
+ continue
+ key(prefix + name, vk, "\x1b[" + ("" if modifier == 1 else f"1;{modifier}") + seq, modifiers)
+ cases[-1]["expected"]["kitty"]["hex"].append(hex_of(f"\x1b[{kitty}{'' if modifier == 1 else f';{modifier}'}u"))
+ for name, vk, code, kitty in [("insert", 45, 2, 57425), ("delete", 46, 3, 57426),
+ ("page-up", 33, 5, 57421), ("page-down", 34, 6, 57422)]:
+ key(name, vk, f"\x1b[{code}~")
+ cases[-1]["expected"]["kitty"]["hex"].append(hex_of(f"\x1b[{kitty}u"))
+ for letter in "acdj lmqsu z".replace(" ", ""):
+ key("ctrl-" + letter, ord(letter.upper()), None, (17,), ord(letter), chr(ord(letter) - 96))
+ key("alt-v", 86, None, (18,), ord("v"), "\x1bv")
+ for name, text in [
+ ("paste-lf", "line 1\nline 2"),
+ ("paste-crlf", "line 1\r\nline 2\r\n"),
+ ("paste-cr", "line 1\rline 2"),
+ ("paste-whitespace", " one\t\n\n two \n"),
+ ("paste-unicode", "é e\u0301 日本語\nnext"),
+ ("paste-escape-looking", "literal [200~ and \\x1b[31m\nend"),
+ ]:
+ cases.append(dict(id=name, kind="paste", text=text, expected={mode: {"paste": text} for mode in MODES[1:]}))
+ cases.append(dict(id="mouse-interleave", kind="mouse-interleave", text="mouse\npaste",
+ expected={mode: {"mouse_interleave": True} for mode in MODES[1:]}))
+ cases.append(dict(id="mouse-focus-refresh", kind="mouse-focus-refresh",
+ expected={mode: {"mouse_focus_refresh": True} for mode in MODES[1:]}))
+ transitions = "a\r" + "b\x1b[27;2;13~" + "c\x1b[13;2u" + "d\x1b[27;2;13~" + "e\r" + "f"
+ cases.append(dict(id="mode-transitions", kind="mode-transitions",
+ expected={mode: {"hex": [hex_of(transitions)]} for mode in MODES[1:]}))
+ cases.append(dict(id="dead-acute", kind="layout-key", dead="´", base_vk=69,
+ expected={mode: {"hex": [hex_of("é")]} for mode in MODES[1:]}))
+ for name, prompt, text in [
+ ("dead-grave", "With US-International active, type grave then e, once; no Enter.", "è"),
+ ("dead-circumflex", "With US-International active, type circumflex then e, once; no Enter.", "ê"),
+ ("dead-tilde", "With US-International active, type tilde then n, once; no Enter.", "ñ"),
+ ("dead-diaeresis", "With US-International active, type diaeresis then u, once; no Enter.", "ü"),
+ ("dead-space", "With US-International active, type acute then Space, once; no Enter.", "'"),
+ ("altgr-euro", "With your declared euro-producing AltGr layout, type € using its AltGr chord, once; no Enter.", "€"),
+ ("ime-commit", "Using your declared IME, compose and commit 日本語 (not paste); do not submit the prompt.", "日本語"),
+ ]:
+ cases.append(dict(id=name, kind="manual", prompt=prompt, expected={mode: {"hex": [hex_of(text)]} for mode in MODES[1:]}))
+ # Explicit qualification tasks, never auto-passed by key/byte checks.
+ for name, prompt in [
+ ("dead-cancel-repeat", "Qualify dead-key cancellation, repeated accent and non-composing next character against the direct-host baseline."),
+ ("ime-cancel", "Qualify IME cancel and partial composition without committed/duplicate text."),
+ ("native-repeat-release", "Qualify held-key repeat, release identity, and modifier interleaving using native records."),
+ ("locks-keypad", "Qualify CapsLock/NumLock, keypad Enter/operators/decimal and restore lock states."),
+ ("mouse-right-edge", "Click/drag/wheel at a marked pane column above 120; compare actual report coordinates and visual target."),
+ ("wrap-rendering", "Verify the displayed ruler and wrapped multiline text at both window heights and every observed width."),
+ ("capture-refresh", "Toggle mouse capture/config reload, refocus, detach/reattach; repeat paste and Shift+Enter."),
+ ("setup-recovery", "Inject a recoverable setup failure and late VT activation; verify mode restoration and the same input sentinels."),
+ ("ctrl-v", "Qualify raw Ctrl+V only with the Windows Terminal paste binding explicitly removed."),
+ ("alt-enter", "Qualify Alt+Enter with the Windows Terminal fullscreen binding explicitly controlled."),
+ ("ctrl-shift-up", "Qualify Ctrl+Shift+Up with the Windows Terminal scroll binding explicitly controlled."),
+ ("ctrl-shift-down", "Qualify Ctrl+Shift+Down with the Windows Terminal scroll binding explicitly controlled."),
+ ("ctrl-shift-home", "Qualify Ctrl+Shift+Home with the Windows Terminal scroll binding explicitly controlled."),
+ ("ctrl-shift-end", "Qualify Ctrl+Shift+End with the Windows Terminal scroll binding explicitly controlled."),
+ ("paste-supplementary", "Qualify supplementary-plane clipboard text, including emoji, against the direct-host baseline."),
+ ("paste-burst", "Qualify a 200-line clipboard burst with the host multiline-paste warning configured or handled explicitly."),
+ ("clipboard-nontext", "Qualify supported image/file clipboard integrations separately; do not infer from text paste."),
+ ]:
+ cases.append(dict(id=name, kind="qualification", prompt=prompt, expected={}))
+ return dict(schema=1, widths=WIDTHS, heights=HEIGHTS, modes=MODES, cases=cases)
+
+
+def verdict(case, mode, evidence):
+ """The full capture, not a matching prefix, is the primary assertion."""
+ if evidence.get("status") in {"not_run", "unsupported", "inconclusive"}:
+ return evidence["status"], evidence.get("reason", "No qualifying observation")
+ expected = case["expected"].get(mode)
+ if expected is None:
+ return "not_run", "No automatic oracle for this case/profile; requires qualification"
+ if not evidence.get("ready") or not evidence.get("focus_verified") or not evidence.get("complete"):
+ return "inconclusive", "Missing readiness, focus, or complete capture"
+ if evidence.get("error"):
+ return "inconclusive", evidence["error"]
+ if evidence.get("path") == "herdr" and case["id"] in ("page-up", "page-down") and "vk" not in expected:
+ expected = {"hex": [""]} # Plain page keys intentionally control Herdr's host scrollback.
+ def geometry(name):
+ value = evidence.get(name)
+ return value[:2] if isinstance(value, list) and len(value) >= 2 and all(type(v) is int for v in value[:2]) else None
+ outer, pane = geometry("outer_geometry"), geometry("pane_geometry")
+ final_pane, final_outer = geometry("final_pane_geometry"), geometry("final_outer_geometry")
+ if outer != [evidence.get("width"), evidence.get("height")]:
+ return "inconclusive", "Requested geometry was not observed"
+ if pane is None or min(pane) <= 0:
+ return "inconclusive", "Missing actual pane dimensions"
+ if final_pane != pane:
+ return "inconclusive", "Pane geometry changed during capture"
+ if final_outer != outer:
+ return "inconclusive", "Outer geometry changed during capture"
+ if evidence.get("path") == "herdr" and case["id"] in ("page-up", "page-down") and "vk" in expected:
+ records = evidence.get("records")
+ if not isinstance(records, list) or any(not isinstance(record, list) or len(record) != 7
+ or not all(type(field) is int for field in record) for record in records):
+ return "inconclusive", "Malformed native record"
+ return (("inconclusive", "No positive host-scrollback evidence for the consumed page key")
+ if all(record[0] in (4, 16) for record in records)
+ else ("fail", "Plain page key unexpectedly reached the pane"))
+ if (evidence.get("path") == "herdr" and case["id"] in ("page-up", "page-down")
+ and evidence.get("hex") == ""):
+ return "inconclusive", "No positive host-scrollback evidence for the consumed page key"
+ if "vk" in expected:
+ records = evidence.get("records")
+ scans = evidence.get("scans", [])
+ chord = case["chords"][0]
+ if not isinstance(records, list):
+ return "inconclusive", "Malformed native record"
+ if not isinstance(scans, list) or len(scans) != len(chord) or not all(type(scan) is int for scan in scans):
+ return "inconclusive", "Missing injected scan-code evidence"
+ expected_scans = dict(zip(chord, scans))
+ aliases = {160: 16, 161: 16, 162: 17, 163: 17, 164: 18, 165: 18}
+ held = set()
+ keys = []
+ for record in records:
+ if not isinstance(record, list) or len(record) != 7 or not all(type(field) is int for field in record):
+ return "inconclusive", "Malformed native record"
+ if record[0] in (4, 16): # Resize/focus notifications carry no typed text.
+ continue
+ if record[0] != 1:
+ return "fail", "Unexpected non-key native input"
+ vk = aliases.get(record[3], record[3])
+ if vk not in (16, 17, 18):
+ keys.append(record)
+ continue
+ if vk not in expected["modifier_keys"] or record[5] != 0 or record[2] != 1 or record[4] != expected_scans[vk]:
+ return "fail", "Unexpected/corrupted modifier record"
+ if record[1] == 1 and vk not in held:
+ held.add(vk)
+ elif record[1] == 0 and vk in held:
+ held.remove(vk)
+ else:
+ return "fail", "Unbalanced modifier sequence"
+ if held:
+ return "fail", "Modifier release missing"
+ if len(keys) != 2 or [r[1] for r in keys] != [1, 0]:
+ return "fail", "Expected exactly one non-modifier down/up pair"
+ for record in keys:
+ control = record[6]
+ modifiers = bool(control & 16) + 2 * bool(control & 3) + 4 * bool(control & 12)
+ if (record[3] != expected["vk"] or modifiers != expected["modifiers"] or record[2] != 1
+ or record[5] != expected["unicode"] or record[4] != expected_scans[expected["vk"]]):
+ return "fail", "Native identity/modifiers/repeat differ"
+ return "pass", "Native down/up pair matches"
+ try:
+ raw = bytes.fromhex(evidence["hex"])
+ except (KeyError, ValueError, TypeError):
+ return "inconclusive", "Missing or malformed raw bytes"
+ if expected.get("mouse_interleave"):
+ motion = rb"(?:\x1b\[<35;\d+;\d+M)+"
+ newline = rb"(?:\r\n|\r|\n)"
+ pattern = b"a" + motion + rb"\x1b\[200~mouse" + newline + rb"paste\x1b\[201~" + motion + b"b"
+ return ("pass", "Typing, mouse motion and paste remained ordered") if re.fullmatch(pattern, raw) else ("fail", "Mouse interleave order or payload differs")
+ if expected.get("mouse_focus_refresh"):
+ coords = rb"\d+;\d+"
+ gesture = (rb"(?:\x1b\[<35;" + coords + rb"M)*"
+ + rb"\x1b\[<0;" + coords + rb"M"
+ + rb"\x1b\[<0;" + coords + rb"m"
+ + rb"\x1b\[<64;" + coords + rb"M")
+ pattern = b"a" + gesture + b"b" + b"c" + gesture + b"d"
+ return ("pass", "Click/wheel reporting survived focus loss and regain") if re.fullmatch(pattern, raw) else ("fail", "Mouse reporting failed before or after focus regain")
+ if case["id"] == "mode-transitions" and evidence.get("path") == "direct":
+ legacy_only = hex_of("a\rb\rc\rd\re\rf")
+ kitty_only = hex_of("a\rb\rc\x1b[13;2u" + "d\re\rf")
+ if raw.hex() in {legacy_only, kitty_only}:
+ return "unsupported", "Direct Windows Terminal ignored modifyOtherKeys during the transition chain"
+ return "fail", "Unexpected direct-host runtime transition bytes"
+ if "loss" in expected:
+ return ("unsupported", "Plain VT cannot preserve modified Enter") if raw.hex() in expected["hex"] else ("fail", "Unexpected plain-VT modified Enter result")
+ if "hex" in expected:
+ return ("pass", "Exact bytes match") if raw.hex() in expected["hex"] else ("fail", "Bytes differ (including any duplicates/trailing input)")
+ if not raw.startswith(b"\x1b[200~") or not raw.endswith(b"\x1b[201~"):
+ return "fail", "Missing bracketed-paste envelope; newlines may be key events"
+ try:
+ actual = raw[6:-6].decode("utf-8")
+ except UnicodeDecodeError:
+ return "fail", "Paste is not intact UTF-8"
+ # Line-ending conversion is expected on Windows; all other payload bytes matter.
+ normalize = lambda text: text.replace("\r\n", "\n").replace("\r", "\n")
+ return ("pass", "One complete paste matches") if normalize(actual) == normalize(expected["paste"]) else ("fail", "Paste payload differs")
+
+
+def known_host_gap(observation, case, terminal_version):
+ """A narrowly observed host limitation, never a blanket version exemption."""
+ legacy = case["expected"].get("legacy", {}).get("hex", [])
+ mok2 = case["expected"].get("mok2", {}).get("hex", [])
+ return (observation.get("path") == "direct"
+ and observation.get("mode") == "mok2"
+ and case["id"] in {"shift-enter", "ctrl-enter", "ctrl-shift-enter", "shift-tab"}
+ and re.match(r"^1\.(?:24|25)\.", str(terminal_version)) and set(legacy) != set(mok2)
+ and observation.get("hex") in legacy)
+
+
+def channel_identity_errors(hosts):
+ """Compare both launcher identities and the processes actually activated."""
+ identities = {}
+ for host in hosts:
+ values = identities.setdefault(host.get("channel"), set())
+ for field, kind in (("launcher_identity", "file"), ("installation_identity", "installation")):
+ if host.get(field):
+ values.add((kind, host[field]))
+ for run in host.get("runs", []):
+ for field, kind in (("image_identity", "file"), ("installation_identity", "installation"), ("process_identity", "process")):
+ if run.get(field):
+ values.add((kind, run[field]))
+ return ["Stable and Preview share a Terminal executable, installation, or process identity"] if identities.get("stable", set()) & identities.get("preview", set()) else []
+
+
+def summarize(document):
+ matrix = catalogue()
+ cases = {case["id"]: case for case in matrix["cases"]}
+ rows = []
+ seen = set()
+ captures = set()
+ for observation in document.get("observations", []):
+ identity = tuple(observation.get(k) for k in ("host", "path", "mode", "phase", "width", "height", "case"))
+ if identity in seen:
+ raise ValueError(f"Duplicate observation identity: {identity}")
+ seen.add(identity)
+ case = cases[observation["case"]]
+ status, reason = verdict(case, observation["mode"], observation)
+ scope = "direct_host" if observation.get("path") == "direct" else "through_herdr_not_yet_attributed"
+ if status in ("pass", "fail"):
+ bound = [run for host in document.get("hosts", []) if host.get("channel") == observation.get("host")
+ for run in host.get("runs", [])
+ if run.get("nonce") == observation.get("nonce") and run.get("path") == observation.get("path")
+ and run.get("mode") == observation.get("mode") and run.get("pid", 0) > 0 and run.get("hwnd", 0) != 0
+ and run.get("elevated") is False and run.get("image_identity") and run.get("installation_identity")]
+ capture = observation.get("capture_id")
+ if len(bound) != 1 or document.get("controller_elevated") is not False or not capture or capture in captures:
+ status, reason = "inconclusive", "Missing non-elevated owned-run binding or fresh capture identity"
+ else:
+ captures.add(capture)
+ if status == "fail" and known_host_gap(observation, case, bound[0].get("terminal_version")):
+ status, reason = "unsupported", "Direct host ignored mOK and emitted the case's legacy bytes"
+ rows.append({**observation, "status": status, "reason": reason, "failure_scope": scope})
+ counts = {status: sum(r["status"] == status for r in rows) for status in ("pass", "fail", "not_run", "unsupported", "inconclusive")}
+ # No run may claim all-green just because it produced zero/missing observations.
+ planned = set()
+ geometries = [(120, 30, True)] + [(w, h, False) for h in document.get("heights", HEIGHTS) for w in document.get("widths", WIDTHS)] + [(80, 30, False)]
+ selected_cases = document.get("cases") or list(cases)
+ for host in document.get("channels") or ("stable", "preview"):
+ for path in document.get("paths") or ("direct", "herdr"):
+ for mode in document.get("modes", MODES):
+ for phase, (width, height, full) in enumerate(geometries, 1):
+ for case_id in selected_cases if full else (case_id for case_id in ("letter-a", "shift-enter", "paste-lf", "mouse-focus-refresh") if case_id in selected_cases):
+ planned.add((host, path, mode, phase, width, height, case_id))
+ missing = planned - seen
+ if seen - planned:
+ raise ValueError("Observations outside the declared run matrix")
+ hosts = document.get("hosts", [])
+ errors = list(document.get("errors", [])) + channel_identity_errors(hosts)
+ expected_hosts = set(document.get("channels") or ("stable", "preview"))
+ complete = (bool(rows) and not missing and {h.get("channel") for h in hosts} == expected_hosts
+ and all(h.get("runs") for h in hosts)
+ and not errors and not document.get("cleanup_errors")
+ and all(r["status"] == "pass" for r in rows))
+ return {**document, "errors": errors, "observations": rows, "counts": counts, "coverage_missing": len(missing), "observed_checks_passed": complete,
+ "native_qualification": "Required; this report is not a full Windows support certificate"}
+
+
+def qualification_matrix(result):
+ """Collapse real observations into the user-facing capability summary."""
+ rows = result.get("observations", [])
+ required_channels = set(result.get("channels") or ("stable", "preview"))
+ manual_cases = {case["id"] for case in catalogue()["cases"] if case["kind"] == "manual"}
+ groups = [
+ ("Printable keys", {"letter-a", "shift-letter"}, None),
+ ("Shift+Enter", {"shift-enter"}, None),
+ ("Ctrl+Enter", {"ctrl-enter"}, None),
+ ("Ctrl+Shift+Enter", {"ctrl-shift-enter"}, None),
+ ("Navigation/editing", {"up", "down", "left", "right", "home", "end", "insert", "delete", "tab", "shift-tab", "page-up", "page-down"}, None),
+ ("Multiline paste", {"paste-lf"}, None),
+ ("CR/LF/CRLF paste", {"paste-lf", "paste-crlf", "paste-cr"}, None),
+ ("Unicode/whitespace paste", {"paste-unicode", "paste-whitespace"}, None),
+ ("Paste framing/ordering", {case["id"] for case in catalogue()["cases"] if case["kind"] == "paste"}, None),
+ ("Resize 120 -> 80", {"letter-a", "shift-enter", "paste-lf"}, 80),
+ ("Mouse while typing/pasting", {"mouse-interleave"}, None),
+ ("Mouse after focus regain", {"mouse-focus-refresh"}, None),
+ ("Mouse after resize", {"mouse-focus-refresh"}, 80),
+ ("Dead-key composition", {"dead-acute"}, None),
+ ("AltGr", {"altgr-euro"}, None),
+ ("IME composition", {"ime-commit"}, None),
+ ("Runtime mode transitions", {"mode-transitions"}, None),
+ ]
+
+ def cell(case_ids, width, path, modes):
+ matched = [row for row in rows if row.get("case") in case_ids and row.get("path") == path
+ and row.get("mode") in modes and (width is None or row.get("width") == width)]
+ statuses = {row.get("status") for row in matched}
+ if "fail" in statuses:
+ return "FAIL"
+ if case_ids <= manual_cases and not any(status == "pass" for status in statuses):
+ return "MANUAL"
+ if {row.get("case") for row in matched} != case_ids:
+ return "PARTIAL" if "pass" in statuses else "INCONCLUSIVE" if "inconclusive" in statuses else "NOT TESTED"
+ if required_channels and {(row.get("host"), row.get("case")) for row in matched} != {
+ (channel, case_id) for channel in required_channels for case_id in case_ids}:
+ return "PARTIAL"
+ if case_ids == {"mode-transitions"} and path == "direct" and statuses <= {"unsupported", "inconclusive"}:
+ return "X - mOK ignored"
+ if path == "direct" and modes == {"legacy"} and statuses == {"unsupported"}:
+ return "X - becomes Enter" if case_ids == {"shift-enter"} else "X - loses modifier"
+ def case_passed(case_id):
+ statuses_for_case = {row.get("status") for row in matched if row.get("case") == case_id}
+ return "pass" in statuses_for_case or (width == 80 and path == "direct" and modes == {"legacy"}
+ and case_id == "shift-enter" and statuses_for_case == {"unsupported"})
+ per_case_passed = all(case_passed(case_id) for case_id in case_ids)
+ allowed = {"pass", "inconclusive"}
+ if width == 80 and path == "direct" and modes == {"legacy"}:
+ allowed.add("unsupported")
+ if per_case_passed and statuses <= allowed:
+ return "PASS**" if "inconclusive" in statuses else "PASS"
+ if "inconclusive" in statuses:
+ return "INCONCLUSIVE"
+ if "unsupported" in statuses:
+ return "UNSUPPORTED"
+ return "NOT TESTED"
+
+ table = []
+ for name, case_ids, width in groups:
+ herdr_modes = {"legacy"} if name in {"Mouse after resize", "Runtime mode transitions"} else {"mok2"} if "Enter" in name or name in {"Resize 120 -> 80", "Dead-key composition", "AltGr", "IME composition"} else {"legacy"}
+ table.append((name, cell(case_ids, width, "herdr", herdr_modes),
+ cell(case_ids, width, "direct", {"legacy"}), cell(case_ids, width, "direct", {"kitty"})))
+ return table
+
+
+def herdr_protocol_label(result):
+ runs = {(host.get("channel"), run.get("path"), run.get("mode"), run.get("nonce"))
+ for host in result.get("hosts", []) for run in host.get("runs", [])
+ if run.get("path") == "herdr" and run.get("nonce")}
+ proven = {(row.get("host"), row.get("path"), row.get("mode"), row.get("nonce"))
+ for row in result.get("observations", [])
+ if row.get("path") == "herdr" and row.get("input_reader") == "windows-console"
+ and row.get("input_transport") == "win32-serialized" and row.get("nonce")}
+ return "Win32 (Herdr)*" if runs and runs <= proven else "Herdr default (UNKNOWN)*"
+
+
+def print_qualification_matrix(result):
+ table = [("Thing", herdr_protocol_label(result), "Plain VT", "Kitty"), *qualification_matrix(result)]
+ widths = [max(len(str(row[column])) for row in table) for column in range(4)]
+ line = lambda row: " | ".join(str(value).ljust(widths[index]) for index, value in enumerate(row))
+ print("\nWindows input qualification results")
+ print(line(table[0]))
+ print("-+-".join("-" * width for width in widths))
+ for row in table[1:]:
+ print(line(row))
+ print("* Herdr protocol label comes from runtime evidence; UNKNOWN is never treated as Win32.")
+ print("** At least one capable host passed; host capability gaps remain visible in report.json.")
+ print("MANUAL requires an operator-assisted -Manual run; no automated result is claimed.")
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("command", choices=["matrix", "report"])
+ parser.add_argument("--input", type=Path)
+ parser.add_argument("--output", type=Path, required=True)
+ args = parser.parse_args()
+ result = catalogue() if args.command == "matrix" else summarize(json.loads(args.input.read_text(encoding="utf-8-sig")))
+ args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ if args.command == "report":
+ counts = result["counts"]
+ through_failures = sum(row["status"] == "fail" and row.get("path") == "herdr" for row in result["observations"])
+ direct_failures = sum(row["status"] == "fail" and row.get("path") == "direct" for row in result["observations"])
+ print(f"Observed: {counts['pass']} pass, {counts['fail']} fail, {counts['unsupported']} unsupported, "
+ f"{counts['inconclusive']} inconclusive, {counts['not_run']} not run; "
+ f"{result['coverage_missing']} planned rows missing")
+ if through_failures:
+ print(f"Assessment: {through_failures} through-Herdr failures need attribution; inspect report.json before treating them as product regressions")
+ elif result.get("errors") or result.get("cleanup_errors"):
+ print("Assessment: harness or cleanup failed; this run does not qualify input behavior")
+ elif direct_failures:
+ print(f"Assessment: {direct_failures} direct-host differences observed; these are not automatically Herdr bugs")
+ elif counts["inconclusive"] or counts["not_run"] or counts["unsupported"] or result["coverage_missing"]:
+ print("Assessment: observed assertions passed, but host limitations or qualification gaps remain")
+ else:
+ print("Assessment: all planned observed assertions passed")
+ if result.get("errors"):
+ print("Run errors: " + " | ".join(result["errors"]))
+ if result.get("cleanup_errors"):
+ print("Cleanup errors: " + " | ".join(result["cleanup_errors"]))
+ print_qualification_matrix(result)
+ return 1 if result["counts"]["fail"] or result.get("errors") or result.get("cleanup_errors") else 2 if not result["observed_checks_passed"] else 0
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())