Files
herdr/scripts/test_windows_input.ps1
T
b88e8116e9 test: add local windows input gauntlet (#4298)
* test: add local windows input gauntlet

refs #4251

* test: add local windows input gauntlet

Harden elevation and channel identity checks, distinguish known host limitations, and surface incomplete cleanup.

refs #4251

* test(windows): harden input gauntlet execution

refs #4251

* test(windows): make input gauntlet self-contained

refs #4251

* test(windows): bound input gauntlet failure paths

refs #4251

* test(windows): extend input qualification gauntlet

refs #4251

* test(windows): complete input qualification matrix

refs #4251

---------

Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Co-authored-by: Jonathan Liebig <jonathan.liebig@gmail.com>
2026-09-18 01:13:13 +02:00

416 lines
31 KiB
PowerShell

#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