mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-21 16:01:04 +00:00
* 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>
416 lines
31 KiB
PowerShell
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
|