diff --git a/Tools/windows/Invoke-MatchedActionSupplement.ps1 b/Tools/windows/Invoke-MatchedActionSupplement.ps1 index 8fb19d04..4acbdeac 100644 --- a/Tools/windows/Invoke-MatchedActionSupplement.ps1 +++ b/Tools/windows/Invoke-MatchedActionSupplement.ps1 @@ -128,6 +128,18 @@ $dryRunLauncher=$null $dryRunProof=$null try { Assert-CapWorkerDeadline 'action entry' + $boundWindows=@(Assert-CapActionWindowContext) + $windowPreflight=@(foreach ($binding in $boundWindows) { + foreach ($scene in $binding.sceneNames) { + $identity=Get-CapWorkerWindowIdentity -Phase $binding.actionPhase -SceneName $scene + $geometry=Get-CapMatchedWindowGeometry -Phase $binding.actionPhase -SceneName $scene + [pscustomobject]@{ + actionPhase=$binding.actionPhase;capturePhase=$binding.capturePhase + sceneName=$scene;actionIds=@($binding.actionIds);identity=$identity;geometry=$geometry + validation='NONNULL_SOURCE_BOUND_IDENTITY_BEFORE_LAUNCH' + } + } + }) if ($DryRun) { # Exercise the real custody gate with a harmless child, never a product/provider image. $dryRunLauncher=Start-CapJobLauncher -Root $root -OutputDirectory $outputDirectory ` @@ -148,12 +160,15 @@ try { target=$target productLaunches=0L providerLaunches=0L - windowIdentityValidation='REQUIRES_PRODUCT_WINDOW_NOT_EXECUTED' + windowIdentityValidation='SOURCE_PHASE_BINDINGS_VALIDATED_BEFORE_LAUNCH_RUNTIME_WINDOW_NOT_EXECUTED' + windowBindings=$windowPreflight + toolingSourceHead=$head + captureSourceHead=$job.head geometry=$null preconditionsEvaluated=$true } if (-not (Test-CapMatchedDpiAdapter)) {throw 'Action dry run requires the production matched DPI contract.'} - $dryRunProof.geometry=Get-CapMatchedWindowGeometry -Phase 'after' + $dryRunProof.geometry=$windowPreflight[0].geometry } else { $matchedFixture=Get-Content $fixturePath -Raw | ConvertFrom-Json -DateKind String Initialize-CapMatchedWorktrees -Fixture $matchedFixture @@ -276,7 +291,9 @@ $release=[ordered]@{ schemaVersion=1L status=$(if ($manifest.status -ceq 'COMPLETE') {'RELEASED_ZERO_SURVIVORS'} elseif ($independent.Count -eq 0) {'FAILED_ZERO_SURVIVORS'} else {'CLEANUP_UNPROVEN'}) - cleanupState=$(if ($independent.Count -eq 0 -and $cleanupFailures.Count -eq 0) { + cleanupState=$(if ($runs.Count -eq 0 -and $independent.Count -eq 0 -and $cleanupFailures.Count -eq 0) { + 'NO_PROCESS_LAUNCHES' + } elseif ($independent.Count -eq 0 -and $cleanupFailures.Count -eq 0) { 'HELD_EXIT_AND_EOF_PROVEN' } else {'UNPROVEN'}) group=$plan.group diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index 2187268e..ea6a2ea9 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -122,11 +122,55 @@ function Open-CapActionAuthorization { } } +function Assert-CapActionWindowContext { + $held=Open-CapActionAuthorization -Path $authorizationPath -Sha256 $authorizationSha256 ` + -Expected $authorization -DryRun:($authorization.status -ceq 'DRY_RUN_NO_AUTHORITY') + try { + if ($runDirectory -cne $held.Record.runDirectory -or + $plan.group -cne $held.Record.group -or + $plan.sourceHead -cne $held.Record.sourceHead -or + $plan.sourceTree -cne $held.Record.sourceTree -or + ($held.Record.status -ceq 'AUTHORIZED' -and $job.head -cne $plan.sourceHead)) { + throw 'Action window binding run/authority/source/plan/job differs.' + } + foreach ($inputBinding in @( + @($planPath,$held.Record.actionPlanSha256,$plan), + @($plan.jobPath,$held.Record.jobSha256,$job) + )) { + $record=Read-CapBoundJson -Root $root -Reference ([pscustomobject]@{ + path=$inputBinding[0];sha256=$inputBinding[1]}) + if (-not [Text.Json.Nodes.JsonNode]::DeepEquals( + [Text.Json.Nodes.JsonNode]::Parse(($record|ConvertTo-Json -Depth 50 -Compress)), + [Text.Json.Nodes.JsonNode]::Parse(($inputBinding[2]|ConvertTo-Json -Depth 50 -Compress)))) { + throw 'Action window binding in-memory plan/job differs from bound bytes.' + } + } + return @(Assert-CapActionWindowBindings -Job $job -Plan $plan) + } finally {$held.Pin.Dispose()} +} + +function Get-CapWorkerWindowIdentity { + param([Parameter(Mandatory)][string]$Phase,[string]$SceneName='') + if ($actionSupplementMode) { + $bindings=@(Assert-CapActionWindowContext) + $matches=@($bindings | Where-Object { + $_.actionPhase -ceq $Phase -and $SceneName -cin $_.sceneNames + }) + if ($matches.Count -ne 1) {throw "Unknown or foreign action phase/scene window binding: $Phase/$SceneName"} + return $matches[0].identity + } + if ($Phase -cnotin @('before','after')) {throw "Unknown capture window identity phase: $Phase"} + $contract=$job.captureContract + $identity=$contract.windowIdentities.$Phase + if ($null -eq $identity) {throw "Capture window identity is missing: $Phase"} + return $identity +} + function Get-CapMatchedWindowGeometry { - param([Parameter(Mandatory)][string]$Phase) + param([Parameter(Mandatory)][string]$Phase,[string]$SceneName='') + $identity=Get-CapWorkerWindowIdentity -Phase $Phase -SceneName $SceneName $area=Get-CapPrimaryPhysicalWorkArea $contract = $job.captureContract - $identity = $contract.windowIdentities.$Phase return Get-CapDpiGeometry -LogicalClientWH @($contract.logicalClientWH) ` -ExpectedDpi ([uint32]$contract.expectedWindowDpi) ` -WindowStyle ([long]$identity.windowStyleForAdjustment) ` @@ -1322,10 +1366,15 @@ function Start-CapGraphCodeApp { [Parameter(Mandatory)][string]$Phase, [Parameter(Mandatory)][string]$PipeName, [Parameter(Mandatory)][string]$SessionPrefix, + [string]$SceneName='', [switch]$ActionsFixture ) Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + if ($actionSupplementMode) { + $null=Get-CapWorkerWindowIdentity -Phase $Phase -SceneName $SceneName + $null=Get-CapMatchedWindowGeometry -Phase $Phase -SceneName $SceneName + } $binaryKey = if ($Phase -eq 'before') { 'graphcode-before.exe' } else { 'graphcode-after.exe' } $binaryPath = if ($rehearsal) { Assert-CapOwnedPath $job.rehearsalTarget.path } else { Join-Path $root "inputs\$binaryKey" } @@ -1399,9 +1448,9 @@ function Start-CapGraphCodeApp { } elseif (Test-CapMatchedDpiAdapter) { $beforeMetrics=Get-CapWindowMetrics -Window $window -ProcessIdentity $run.Identity $contract=$job.captureContract - $identity=$contract.windowIdentities.$Phase + $identity=Get-CapWorkerWindowIdentity -Phase $Phase -SceneName $SceneName $null=Assert-CapDpiWindowIdentity -Metrics $beforeMetrics -Identity $identity -Initial - $geometry=Get-CapMatchedWindowGeometry -Phase $Phase + $geometry=Get-CapMatchedWindowGeometry -Phase $Phase -SceneName $SceneName Set-CapWindowPosition -Window $window -ProcessIdentity $run.Identity ` -X $geometry.OuterBounds[0] -Y $geometry.OuterBounds[1] ` -Width $geometry.OuterWH[0] -Height $geometry.OuterWH[1] @@ -1412,7 +1461,7 @@ function Start-CapGraphCodeApp { $metrics = Get-CapWindowMetrics -Window $window -ProcessIdentity $run.Identity if (Test-CapMatchedDpiAdapter) { $geometry=$appRun.Placement - $identity=$job.captureContract.windowIdentities.$Phase + $identity=Get-CapWorkerWindowIdentity -Phase $Phase -SceneName $SceneName $null=Assert-CapDpiWindowIdentity -Metrics $metrics -Identity $identity ` -ExpectedOuterBounds @($geometry.OuterBounds) } elseif ($metrics.Dpi -ne 96) { @@ -2397,6 +2446,7 @@ function Invoke-CapMatchedTerminalSceneSupplement { function Invoke-CapWireCompletionSupplement { param([Parameter(Mandatory)][string]$StubDaemonPath) $phase='wire-completion' + $null=Get-CapWorkerWindowIdentity -Phase $phase -SceneName 's-goal-unwired-unselected' $roots=Initialize-CapPhaseRoot -PhaseRoot (Join-Path $runDirectory $phase) $projectRoot=Join-Path $root 'fixtures\wire-project' $null=New-Item -ItemType Directory -Path $projectRoot -Force @@ -2414,7 +2464,8 @@ function Invoke-CapWireCompletionSupplement { '-ApplyGraphCommands','-StubProjectPath',$projectRoot ) -WorkingDirectory $roots.Peer -Role 'wire-supplement-stub' $runs.Add($stub) - $app=Start-CapGraphCodeApp -Roots $roots -Phase $phase -PipeName $pipe -SessionPrefix 'wire-supplement' + $app=Start-CapGraphCodeApp -Roots $roots -Phase $phase -PipeName $pipe -SessionPrefix 'wire-supplement' ` + -SceneName 's-goal-unwired-unselected' $app | Add-Member -NotePropertyMembers @{ SActions=[Collections.Generic.List[object]]::new() SObservation=$null @@ -2770,6 +2821,11 @@ function Invoke-CapMatchedScenes { $tabExpectedDpi=if ($expectedDpiProperty) {[long]$expectedDpiProperty.Value} else {[long]$job.captureContract.dpi} $tabDpiBase=if ($dpiBaseProperty) {[long]$dpiBaseProperty.Value} else {96L} foreach ($scene in $SceneNames) { + $sceneBinding=@{} + if ($ActionSupplement) { + $null=Get-CapWorkerWindowIdentity -Phase $Phase -SceneName $scene + $sceneBinding.SceneName=$scene + } Assert-CapWorkerDeadline "matched scene $scene" if ([DateTime]::UtcNow -ge $leaseDeadlineUtc.AddSeconds(-90)) {throw 'Matched scenes reached the required cleanup reserve.'} $roots=Initialize-CapPhaseRoot -PhaseRoot (Join-Path $runDirectory "phases\$Phase\$scene") @@ -2802,7 +2858,7 @@ function Invoke-CapMatchedScenes { Start-CapOfflineSeed -Roots $roots -Phase "$Phase-$scene" -SessionName "graphcode-$($node.id)" } } - $app=Start-CapGraphCodeApp -Roots $roots -Phase $Phase -PipeName $pipe -SessionPrefix "s-$Phase" + $app=Start-CapGraphCodeApp -Roots $roots -Phase $Phase -PipeName $pipe -SessionPrefix "s-$Phase" @sceneBinding $app | Add-Member -NotePropertyMembers @{SActions=[Collections.Generic.List[object]]::new();SObservation=$null} $null=Invoke-CapSceneAction -AppRun $app -Action 'open-project' -Name 'Matched' if (-not $workspace) { diff --git a/Tools/windows/Prepare-MatchedActionSupplement.ps1 b/Tools/windows/Prepare-MatchedActionSupplement.ps1 index 39eec803..7c655c89 100644 --- a/Tools/windows/Prepare-MatchedActionSupplement.ps1 +++ b/Tools/windows/Prepare-MatchedActionSupplement.ps1 @@ -161,6 +161,12 @@ foreach ($scene in $expectedScenes) { } $stubPath=Join-Path $repositoryRoot 'Tools\windows\Stub-Daemon.ps1' $actionRunnerPath=Join-Path $repositoryRoot 'Tools\windows\Invoke-MatchedActionSupplement.ps1' +Import-Module (Join-Path $repositoryRoot 'Tools\windows\UiCaptureDriver.psm1') -Force -WarningAction SilentlyContinue +if ((Get-FileHash -LiteralPath $focused.jobPath -Algorithm SHA256).Hash -cne $focused.jobSha256) { + throw 'Action window binding job hash mismatch.' +} +$actionJob=Get-Content -LiteralPath $focused.jobPath -Raw | ConvertFrom-Json -DateKind String +$actionWindowBindings=@(Get-CapActionWindowBindings -Job $actionJob -Group $Group) $plan=[ordered]@{ schemaVersion=1L status='PREPARED_NO_AUTHORITY' @@ -189,6 +195,7 @@ $plan=[ordered]@{ existingSceneManifestPath=$sceneManifestPath existingSceneManifestSha256=$ExistingSceneManifestSha256.ToUpperInvariant() expectedScenes=$expectedScenes + actionWindowBindings=$actionWindowBindings imageBindings=$bindings expectedReceiptCount=[long]$expectedScenes.Count qualifiedAuthority=$(if ($Group -ceq 'FinalBoundedActions') { diff --git a/Tools/windows/Tests/ActionWindowIdentity.Tests.ps1 b/Tools/windows/Tests/ActionWindowIdentity.Tests.ps1 new file mode 100644 index 00000000..ef21c95e --- /dev/null +++ b/Tools/windows/Tests/ActionWindowIdentity.Tests.ps1 @@ -0,0 +1,202 @@ +param( + [string]$ScratchRoot = (Join-Path ([IO.Path]::GetTempPath()) ('ab' + [guid]::NewGuid().ToString('N').Substring(0,8))) +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +$driverDirectory=Split-Path -Parent $PSScriptRoot +$caseRoot=[IO.Path]::GetFullPath($ScratchRoot) +if (Test-Path -LiteralPath $caseRoot) {throw "Refusing to replace binding evidence: $caseRoot"} +$null=New-Item -ItemType Directory -Path $caseRoot +. (Join-Path $driverDirectory 'Invoke-UiCaptureWorker.ps1') -OwnedRoot $caseRoot -DefinitionsOnly +Import-Module $modulePath -Force -WarningAction SilentlyContinue +Set-CapTracePath -Path (Join-Path $caseRoot 'calls.jsonl') +$sourceHead='a'*40 +$identity=Get-CapGraphCodeWindowIdentity ` + -SourceText ([IO.File]::ReadAllText((Join-Path $PSScriptRoot 'fixtures\c8r-window-after.zig'))) ` + -SourceCommit $sourceHead -ExpectedDpi 96 -LogicalClientWH @(320,240) +$job=[pscustomobject]@{ + sceneAdapter='S_MATCHED_V12_DPI_V1' + head=$sourceHead + binaries=[pscustomobject]@{after=[pscustomobject]@{source=$sourceHead;binaryBuiltAtSource=$sourceHead}} + captureContract=[pscustomobject]@{ + logicalClientWH=@(320L,240L);expectedWindowDpi=96L + windowIdentities=[pscustomobject]@{before=$identity;after=$identity} + } +} +$baseJob=$job|ConvertTo-Json -Depth 30 +$results=[Collections.Generic.List[object]]::new() +function Write-BindingJson { + param([string]$Path,$Value) + [IO.File]::WriteAllText($Path,($Value|ConvertTo-Json -Depth 50),[Text.UTF8Encoding]::new($false)) + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash +} +function Save-BindingCase { + $script:plan.jobSha256=Write-BindingJson $plan.jobPath $job + $script:authorization.jobSha256=$plan.jobSha256 + $script:authorization.actionPlanSha256=Write-BindingJson $planPath $plan + $script:authorizationSha256=Write-BindingJson $authorizationPath $authorization +} +function Initialize-BindingCase { + param([string]$Group='WireCompletion') + $script:job=$baseJob|ConvertFrom-Json -DateKind String + $bindings=@(Get-CapActionWindowBindings -Job $job -Group $Group) + $script:plan=[pscustomobject]@{ + group=$Group;sourceHead=$sourceHead;sourceTree=('b'*40) + expectedScenes=@($bindings|ForEach-Object {$_.sceneNames}) + actionWindowBindings=$bindings;jobPath=(Join-Path $caseRoot 'job.json');jobSha256='' + } + $script:planPath=Join-Path $caseRoot 'plan.json' + $script:authorizationPath=Join-Path $caseRoot 'authorization.json' + $script:runDirectory=Join-Path $caseRoot 'runs\one' + $now=[DateTimeOffset]::UtcNow.AddSeconds(-1) + $script:authorization=[pscustomobject]@{ + authorizationId=[guid]::NewGuid().ToString() + ownedRoot=$caseRoot;authorizationPath=$authorizationPath;runDirectory=$runDirectory + processJournalPath=(Join-Path $runDirectory 'process-journal') + actionPlanSha256='';jobSha256='';moduleSha256=('C'*64) + workerSha256=('D'*64);actionRunnerSha256=('E'*64) + sourceHead=$sourceHead;sourceTree=('b'*40);group=$Group + grantedUtc=$now.ToString('o');expiresUtc=$now.AddMinutes(3).ToString('o');maxMinutes=3L + status='DRY_RUN_NO_AUTHORITY';authorizedAction='no-product-import-dry-run';pngAuthorized=$false + } + Save-BindingCase +} +function Assert-BindingCase { + param([bool]$Condition,[string]$Message) + if (-not $Condition) {throw $Message} +} +function Reject-BindingCase { + param([string]$Name,[scriptblock]$Mutation,[string]$Message,[switch]$Persist) + Initialize-BindingCase + & $Mutation + if ($Persist) {Save-BindingCase} + $failure=$null + try {Get-CapMatchedWindowGeometry -Phase 'wire-completion' -SceneName 's-goal-unwired-unselected'|Out-Null} + catch {$failure=$_} + Assert-BindingCase ($null -ne $failure -and $failure.Exception.Message.Contains($Message)) ` + "Expected $Name refusal '$Message'; actual=$failure" + $results.Add(@{name=$Name;passed=$true;refusal=$failure.Exception.Message}) +} +$actionSupplementMode=$true +foreach ($group in @('WireCompletion','TerminalActions','FinalBoundedActions')) { + Initialize-BindingCase $group + foreach ($binding in $plan.actionWindowBindings) { + foreach ($scene in $binding.sceneNames) { + $bound=Get-CapWorkerWindowIdentity -Phase $binding.actionPhase -SceneName $scene + $geometry=Get-CapMatchedWindowGeometry -Phase $binding.actionPhase -SceneName $scene + Assert-BindingCase ($null -ne $bound -and $bound.sourceCommit -ceq $sourceHead -and + ($geometry.PhysicalClientWH -join ',') -ceq '320,240') 'Action identity/geometry differs.' + $results.Add(@{name="$group/$($binding.actionPhase)/$scene";passed=$true}) + } + } +} +foreach ($field in @('actionPhase','capturePhase','sourceCommit')) { + Reject-BindingCase "foreign $field" {$plan.actionWindowBindings[0].$field='foreign'} 'binding differs' -Persist +} +Reject-BindingCase 'wrong scene' {$plan.actionWindowBindings[0].sceneNames=@('foreign')} 'binding differs' -Persist +Reject-BindingCase 'wrong action' {$plan.actionWindowBindings[0].actionIds=@('W6')} 'binding differs' -Persist +Reject-BindingCase 'missing binding' {$plan.PSObject.Properties.Remove('actionWindowBindings')} 'binding differs' -Persist +Reject-BindingCase 'duplicate binding' {$plan.actionWindowBindings+= $plan.actionWindowBindings[0]} 'binding differs' -Persist +Reject-BindingCase 'mismatched known capture phase' {$plan.actionWindowBindings[0].capturePhase='before'} 'binding differs' -Persist +Reject-BindingCase 'missing identity' {$job.captureContract.windowIdentities.PSObject.Properties.Remove('after')} 'identity is missing' -Persist +Reject-BindingCase 'null identity' {$job.captureContract.windowIdentities.after=$null} 'identity is missing' -Persist +Reject-BindingCase 'foreign identity source' {$job.captureContract.windowIdentities.after.sourceCommit='f'*40} 'identity differs' -Persist +Reject-BindingCase 'foreign binary source' {$job.binaries.after.source='f'*40} 'identity differs' -Persist +Reject-BindingCase 'wrong source head' {$plan.sourceHead='f'*40} 'run/authority/source/plan/job differs' -Persist +Reject-BindingCase 'wrong source tree' {$plan.sourceTree='f'*40} 'run/authority/source/plan/job differs' -Persist +Reject-BindingCase 'wrong group' {$plan.group='TerminalActions'} 'run/authority/source/plan/job differs' -Persist +Reject-BindingCase 'noncanonical empty group' { + $plan.group='wirecompletion';$authorization.group=$plan.group + $plan.actionWindowBindings=@();$plan.expectedScenes=@() +} 'ValidateSet' -Persist +Reject-BindingCase 'wrong run' {$script:runDirectory=Join-Path $caseRoot 'runs\foreign'} 'run/authority/source/plan/job differs' +Reject-BindingCase 'wrong authority' {$authorization.authorizationId=[guid]::NewGuid().ToString()} 'binding differs: authorizationId' +Reject-BindingCase 'wrong authority hash' {$script:authorizationSha256='0'*64} 'hash mismatch' +Reject-BindingCase 'authorized source mismatch' { + $authorization.status='AUTHORIZED';$authorization.authorizedAction='matched-action-supplement' + $plan.sourceHead='c'*40;$authorization.sourceHead=$plan.sourceHead +} 'run/authority/source/plan/job differs' -Persist +Reject-BindingCase 'wrong job hash' {[IO.File]::AppendAllText($plan.jobPath,' ')} 'hash mismatch' +Reject-BindingCase 'replaced plan' {[IO.File]::WriteAllText($planPath,'{}')} 'hash mismatch' +Reject-BindingCase 'stale authorization' { + $authorization.expiresUtc=[DateTimeOffset]::UtcNow.AddMinutes(-1).ToString('o') + $authorization.grantedUtc=[DateTimeOffset]::UtcNow.AddMinutes(-4).ToString('o') +} 'expired' -Persist +Reject-BindingCase 'in-memory identity drift on reacquire' { + $null=Get-CapWorkerWindowIdentity -Phase 'wire-completion' -SceneName 's-goal-unwired-unselected' + $job.captureContract.windowIdentities.after.expectedRuntimeWindowStyle++ +} 'in-memory plan/job differs' +Initialize-BindingCase +$plan.sourceHead='c'*40;$authorization.sourceHead=$plan.sourceHead +Save-BindingCase +$historical=Get-CapWorkerWindowIdentity -Phase 'wire-completion' -SceneName 's-goal-unwired-unselected' +Assert-BindingCase ($historical.sourceCommit -ceq $sourceHead -and + $authorization.status -ceq 'DRY_RUN_NO_AUTHORITY') 'No-authority historical inspection lost its explicit source.' +$results.Add(@{name='no-authority historical inspection preserves distinct capture source';passed=$true}) +Initialize-BindingCase +foreach ($request in @(@('foreign','s-goal-unwired-unselected'),@('after','s-goal-unwired-unselected'), + @('wire-completion','foreign'),@('wire-completion',''))) { + $failure=$null + try {Start-CapGraphCodeApp -Roots @{} -Phase $request[0] -SceneName $request[1] ` + -PipeName 'NO_LAUNCH' -SessionPrefix 'NO_LAUNCH'|Out-Null} catch {$failure=$_} + Assert-BindingCase ($failure.Exception.Message.Contains('Unknown or foreign action phase/scene')) ` + "Actual app startup did not reject before launch: $failure" + $results.Add(@{name="startup rejects $($request -join '/') before launch";passed=$true}) +} +$bound=Get-CapWorkerWindowIdentity -Phase 'wire-completion' -SceneName 's-goal-unwired-unselected' +$metrics=[pscustomobject]@{ + Dpi=96L;Style=$bound.expectedRuntimeWindowStyle;ExStyle=$bound.expectedRuntimeWindowExStyle + HasMenu=$bound.hasMenu;OuterBounds=@(0L,0L,1280L,820L) + ClientBounds=@(0L,0L,[long]$bound.expectedInitialClientWH[0],[long]$bound.expectedInitialClientWH[1]) +} +$null=Assert-CapDpiWindowIdentity -Metrics $metrics -Identity $bound -Initial +$results.Add(@{name='bound initial identity guard accepts source-derived metrics model';passed=$true}) +foreach ($field in @('Dpi','Style','ExStyle','HasMenu','OuterBounds','ClientBounds')) { + $changed=$metrics|ConvertTo-Json|ConvertFrom-Json + switch ($field) { + 'HasMenu' {$changed.HasMenu=-not $changed.HasMenu} + 'OuterBounds' {$changed.OuterBounds[2]++} + 'ClientBounds' {$changed.ClientBounds[2]++} + default {$changed.$field++} + } + $failure=$null + try {$null=Assert-CapDpiWindowIdentity -Metrics $changed -Identity ( + Get-CapWorkerWindowIdentity -Phase 'wire-completion' -SceneName 's-goal-unwired-unselected') -Initial} + catch {$failure=$_} + Assert-BindingCase ($null -ne $failure -and $failure.Exception.Message.Contains('differs')) "Guard accepted $field drift." + $results.Add(@{name="reacquired identity guard rejects $field drift in metrics model";passed=$true}) +} +$geometry=Get-CapMatchedWindowGeometry -Phase 'wire-completion' -SceneName 's-goal-unwired-unselected' +$metrics.OuterBounds=@($geometry.OuterBounds|ForEach-Object {[long]$_}) +$metrics.ClientBounds=@(0L,0L,[long]$geometry.PhysicalClientWH[0],[long]$geometry.PhysicalClientWH[1]) +$null=Assert-CapDpiWindowIdentity -Metrics $metrics -Identity $bound -ExpectedOuterBounds $geometry.OuterBounds +$results.Add(@{name='placed guard accepts bound physical metrics model';passed=$true}) +$metrics.Style++ +$failure=$null +try {$null=Assert-CapDpiWindowIdentity -Metrics $metrics -Identity ( + Get-CapWorkerWindowIdentity -Phase 'wire-completion' -SceneName 's-goal-unwired-unselected') ` + -ExpectedOuterBounds $geometry.OuterBounds} catch {$failure=$_} +Assert-BindingCase ($null -ne $failure -and $failure.Exception.Message.Contains('identity differs')) 'Placed guard accepted drift.' +$results.Add(@{name='placed guard rejects drift after identity reacquire in metrics model';passed=$true}) +$reachedLaunch=$false +& { + function Start-CapJobLauncher { + param($Root,$OutputDirectory,$FilePath,$ArgumentList,$WorkingDirectory,$Role,$Environment) + $script:reachedLaunch=$true + throw 'TEST_LAUNCH_INTERCEPTED_NO_PROCESS_STARTED' + } + $roots=Initialize-CapPhaseRoot -PhaseRoot (Join-Path $runDirectory 'launch-intercept') + $failure=$null + try {Start-CapGraphCodeApp -Roots $roots -Phase 'wire-completion' ` + -SceneName 's-goal-unwired-unselected' -PipeName 'NO_LAUNCH' -SessionPrefix 'NO_LAUNCH'|Out-Null} + catch {$failure=$_} + Assert-BindingCase ($failure.Exception.Message.Contains('TEST_LAUNCH_INTERCEPTED_NO_PROCESS_STARTED')) ` + "Valid production startup failed before intercepted launch: $failure" +} +Assert-BindingCase $reachedLaunch 'Valid production startup did not reach the intercepted launcher.' +$results.Add(@{name='actual production startup resolves binding before intercepted launcher';passed=$true}) +$report=[ordered]@{suite='ActionWindowIdentity';executed=$results.Count;passed=$results.Count;failed=0;skipped=0 + productLaunches=0;providerLaunches=0;runtimeWindowEvidence=$false;cases=@($results)} +$null=Write-BindingJson (Join-Path $caseRoot 'report.json') $report +[pscustomobject]$report|Select-Object suite,executed,passed,failed,skipped|ConvertTo-Json -Compress +if ($results.Count -ne 57) {throw "Unexpected binding contract count: $($results.Count)"} diff --git a/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 b/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 index 47e5d48f..1ee2ea2f 100644 --- a/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 +++ b/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 @@ -118,13 +118,18 @@ foreach ($scenario in @('accepted','setup-refusal')) { $null=New-Item -ItemType Directory -Path (Join-Path $owned 'fixtures'),(Join-Path $owned 'inputs') $fixtureHashValue=Write-ImportJson (Join-Path $owned 'fixtures\fixture.json') @{} $jobFile=Join-Path $owned 'job.json' - $adjusted=[CapNative]::AdjustWindowRectForDpi(320,240,13565952L,$false,0L,96) - $jobValue=@{sceneAdapter='S_MATCHED_V12_DPI_V1';captureContract=@{ - logicalClientWH=@(320L,240L);expectedWindowDpi=96L;windowIdentities=@{after=@{ - windowStyleForAdjustment=13565952L;windowExStyleForAdjustment=0L;hasMenu=$false - expectedAdjustedOuterWH=$(if ($scenario -ceq 'accepted') {@($adjusted[4],$adjusted[5])} else {@(1L,1L)}) - }} + $windowIdentity=Get-CapGraphCodeWindowIdentity ` + -SourceText ([IO.File]::ReadAllText((Join-Path $PSScriptRoot 'fixtures\c8r-window-after.zig'))) ` + -SourceCommit $fixtureHead -ExpectedDpi 96 -LogicalClientWH @(320,240) + if ($scenario -ceq 'setup-refusal') {$windowIdentity.expectedAdjustedOuterWH=@(1L,1L)} + $jobValue=@{sceneAdapter='S_MATCHED_V12_DPI_V1';head=$fixtureHead + binaries=@{after=@{source=$fixtureHead;binaryBuiltAtSource=$fixtureHead}} + captureContract=@{ + logicalClientWH=@(320L,240L);expectedWindowDpi=96L + windowIdentities=@{before=$windowIdentity;after=$windowIdentity} }} + $jobValue=$jobValue|ConvertTo-Json -Depth 30|ConvertFrom-Json -DateKind String + $windowBindings=@(Get-CapActionWindowBindings -Job $jobValue -Group FinalBoundedActions) $jobDigest=Write-ImportJson $jobFile $jobValue $preflightFile=Join-Path $owned 'preflight.json' $preflightDigest=Write-ImportJson $preflightFile @{status='FILE_ONLY_TEST_NOT_LIVE_PREFLIGHT'} @@ -133,6 +138,8 @@ foreach ($scenario in @('accepted','setup-refusal')) { $actionPlan=[ordered]@{ status='PREPARED_NO_AUTHORITY';pngArtifacts=0L;processLaunches=0L sourceHead=$fixtureHead;sourceTree=$fixtureTree;group='FinalBoundedActions' + expectedScenes=@($windowBindings|ForEach-Object {$_.sceneNames}) + actionWindowBindings=$windowBindings moduleSha256=(Get-FileHash (Join-Path $fixtureTools 'UiCaptureDriver.psm1')).Hash workerSha256=(Get-FileHash (Join-Path $fixtureTools 'Invoke-UiCaptureWorker.ps1')).Hash stubDaemonSha256=(Get-FileHash (Join-Path $fixtureTools 'Stub-Daemon.ps1')).Hash @@ -157,6 +164,20 @@ foreach ($scenario in @('accepted','setup-refusal')) { $manifest=Get-Content $manifestFile -Raw | ConvertFrom-Json -DateKind String $release=Get-Content (Join-Path $owned 'dry-run-output\action-release.json') -Raw | ConvertFrom-Json -DateKind String $launchSpecs=@(Get-ChildItem (Join-Path $owned 'dry-run-output') -File -Filter '*-launch.json') + if ($scenario -ceq 'setup-refusal') { + Assert-ImportCase ($entryExit -ne 0 -and $manifest.status -ceq 'FAILED' -and + $release.status -ceq 'FAILED_ZERO_SURVIVORS' -and $manifest.failures.Count -eq 1 -and + $release.cleanupState -ceq 'NO_PROCESS_LAUNCHES' -and + $manifest.failures[0].Contains('adjusted outer size differs') -and + ($entryOutput -join "`n").Contains('adjusted outer size differs') -and + $launchSpecs.Count -eq 0 -and $manifest.processJournal.Count -eq 0 -and + $manifest.receiptCount -eq 0 -and $manifest.providerReceipts.Count -eq 0 -and + $manifest.cleanupFailures.Count -eq 0 -and $manifest.independentSurvivors.Count -eq 0 -and + $manifest.pngCount -eq 0) 'Primary prelaunch setup refusal was masked or launched a process.' + $results.Add(@{name="production entry $scenario";passed=$true;manifestPath=$manifestFile + manifestSha256=(Get-FileHash $manifestFile).Hash;publications=$manifest.numberedJournal.published.Count}) + continue + } Assert-ImportCase ($launchSpecs.Count -eq 1) 'Dry run must have exactly one harmless target specification.' $launchSpec=Get-Content $launchSpecs[0].FullName -Raw | ConvertFrom-Json -DateKind String Assert-ImportCase ($launchSpec.filePath -ieq (Join-Path $PSHOME 'pwsh.exe') -and @@ -170,6 +191,14 @@ foreach ($scenario in @('accepted','setup-refusal')) { Assert-ImportCase ($manifest.executionMode -ceq 'DRY_RUN_NO_AUTHORITY' -and $manifest.dryRun.preconditionsEvaluated -and $manifest.dryRun.productLaunches -eq 0 -and $manifest.dryRun.providerLaunches -eq 0 -and $manifest.receiptCount -eq 0 -and + $manifest.dryRun.windowBindings.Count -eq 6 -and + $manifest.dryRun.windowBindings[0].actionPhase -ceq 'wire-completion' -and + $manifest.dryRun.windowBindings[0].capturePhase -ceq 'after' -and + $null -ne $manifest.dryRun.windowBindings[0].identity -and + @($manifest.dryRun.windowBindings | Where-Object { + $_.validation -cne 'NONNULL_SOURCE_BOUND_IDENTITY_BEFORE_LAUNCH' -or + $null -eq $_.identity -or $_.identity.sourceCommit -cne $fixtureHead + }).Count -eq 0 -and $manifest.providerReceipts.Count -eq 0 -and $manifest.pngCount -eq 0 -and $manifest.cleanupFailures.Count -eq 0 -and $manifest.independentSurvivors.Count -eq 0 -and $manifest.cleanup.Count -eq 1 -and $manifest.cleanup[0].jobActive0 -and @@ -188,11 +217,6 @@ foreach ($scenario in @('accepted','setup-refusal')) { Assert-ImportCase ($entryExit -eq 0 -and $manifest.status -ceq 'COMPLETE' -and $release.status -ceq 'RELEASED_ZERO_SURVIVORS' -and $manifest.dryRun.geometry.GeometryDerivation -ceq 'AdjustWindowRectExForDpi') "Dry-run failed: $entryOutput" - } else { - Assert-ImportCase ($entryExit -ne 0 -and $manifest.status -ceq 'FAILED' -and - $release.status -ceq 'FAILED_ZERO_SURVIVORS' -and $manifest.failures.Count -eq 1 -and - $manifest.failures[0].Contains('adjusted outer size differs') -and - ($entryOutput -join "`n").Contains('adjusted outer size differs')) 'Primary setup refusal was masked.' } $results.Add(@{name="production entry $scenario";passed=$true;manifestPath=$manifestFile manifestSha256=(Get-FileHash $manifestFile).Hash;publications=$manifest.numberedJournal.published.Count}) diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 991d4d9f..2a276cd2 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -3261,6 +3261,7 @@ function Invoke-CapMatchedDpiContracts { $workerSource=[IO.File]::ReadAllText($workerPath) $supervisorSource=[IO.File]::ReadAllText($supervisorPath) Assert-CapTest ($workerSource.Contains('$identity=$contract.windowIdentities.$Phase') -and + $workerSource.Contains('Get-CapWorkerWindowIdentity -Phase $Phase -SceneName $SceneName') -and $workerSource.Contains('$area=Get-CapPrimaryPhysicalWorkArea') -and $supervisorSource.Contains('foreach ($phase in @($contract.phases))')) ` 'Worker/supervisor phase-specific physical identity consumers differ.' diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index c1b5bd41..b9367978 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -6062,6 +6062,18 @@ if ($actionImportSummary.executed -ne 18 -or $actionImportSummary.passed -ne 18 $actionImportSummary.failed -ne 0 -or $actionImportSummary.skipped -ne 0) { throw "RED: action import requires all 18 contracts without skips" } +$actionBindingOutput = @(& $pwsh -NoProfile -File (Join-Path $PSScriptRoot "ActionWindowIdentity.Tests.ps1")) +$actionBindingOutput | ForEach-Object { Write-Host $_ } +if ($LASTEXITCODE -ne 0) {throw "RED: action window binding contract child failed"} +$actionBindingSummaries=@($actionBindingOutput | Where-Object { + $_ -is [string] -and $_.StartsWith('{"suite":"ActionWindowIdentity",') + }) +if ($actionBindingSummaries.Count -ne 1) {throw "RED: action binding structured count missing or duplicated"} +$actionBindingSummary=ConvertFrom-Json $actionBindingSummaries[0] +if ($actionBindingSummary.executed -ne 57 -or $actionBindingSummary.passed -ne 57 -or + $actionBindingSummary.failed -ne 0 -or $actionBindingSummary.skipped -ne 0) { + throw "RED: action window binding requires all 57 contracts without skips" +} $agentCommandOutput = @(& $pwsh -NoProfile -File ( Join-Path $PSScriptRoot "Invoke-AgentCommand.Tests.ps1")) $agentCommandSummary = "Invoke-AgentCommand.Tests.ps1: PASS; executed=6; cases=argument-stream-marker,zero-arguments,existing-console,existing-marker,parse-failure,child-nonzero" diff --git a/Tools/windows/UI-CAPTURE-SCENARIOS.md b/Tools/windows/UI-CAPTURE-SCENARIOS.md index 61a1b9b8..52876322 100644 --- a/Tools/windows/UI-CAPTURE-SCENARIOS.md +++ b/Tools/windows/UI-CAPTURE-SCENARIOS.md @@ -71,13 +71,29 @@ This still requires an unchanged clean, hash-bound action plan. It creates only `dry-run-output`; it never creates `action-authorization.json` or grants PNG, product, or provider authority. It uses a contained harmless `pwsh` child to exercise the actual launcher release with the action-owned numbered journal. +Before any launcher is created, the entry resolves every explicit +`actionWindowBindings` phase/scene through the same worker resolver used by +app startup and geometry. `wire-completion` binds W1's unwired scene to the +`after` capture identity; terminal phases bind their declared scenes/actions +to that identity separately. There is no unknown-phase or missing-key fallback. +The hash-bound plan carries the source-derived identity, and each resolution +revalidates plan/job bytes, in-memory snapshots, source/binary identity, group, +scene/action inventory, held authorization, run and deadline. Startup's initial +and placed window guards reacquire through this resolver. + The shared physical-work-area and `AdjustWindowRectExForDpi` preconditions run -without a product window. Actual window identity/metrics, native actions, and +without a product window. The dry-run manifest records each nonnull resolved +identity and geometry before the harmless launcher release. A no-authority dry +run may inspect a hash-bound historical job, recording its capture source +separately from current tooling source; an authorized product run requires both +sources to match. Actual runtime window identity/metrics, native actions, and product behavior are explicitly not evaluated. `dry-run-output\action-manifest.json` and `action-release.json` preserve both success and setup refusal, including the primary exception, terminal numbered -journal and positive held-job-empty/launcher-EOF cleanup. A failed precondition +journal and, when a harmless launcher was started, positive held-job-empty/ +launcher-EOF cleanup. A binding/geometry refusal now occurs before any launcher. +A failed precondition is a bounded tooling/setup blocker, never permission to retry a product action. No prior W1 setup exception is reconstructed by this dry run. @@ -88,6 +104,14 @@ all 18 with zero skips, in addition to the unchanged **182 default / 185 real-zmx** UiCaptureDriver baseline. The new cases are not omitted or used to replace baseline cases. +`Tests\ActionWindowIdentity.Tests.ps1` adds **57 separately counted contracts** +for every action group's explicit phases/scenes, missing/foreign/mismatched +bindings, source/hash/run/authority/deadline/replacement failures, actual +production startup refusal before launch, and initial/placed identity guard +drift with modeled metrics. Its accepted startup intercepts the launcher without +starting a process; it is not native-window evidence. ValidationRunner requires +all 57 with zero skips, alongside the 18 import and unchanged 182/185 baseline. + ## Live transition rehearsal `LIVE_TRANSITION_REHEARSAL` is a no-lease, non-evidence mode for exercising the diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index 4000863d..95e0f982 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -3769,6 +3769,73 @@ function Get-CapMatchedCapturePlan { } } +function Get-CapActionWindowBindings { + param([Parameter(Mandatory)]$Job, + [Parameter(Mandatory)][ValidateSet('WireCompletion','TerminalActions','FinalBoundedActions',IgnoreCase=$false)][string]$Group) + if ($Job.sceneAdapter -cne 'S_MATCHED_V12_DPI_V1') { + throw 'Action window binding requires the matched DPI adapter.' + } + $property=$Job.captureContract.windowIdentities.PSObject.Properties['after'] + if (-not $property -or $null -eq $property.Value) {throw 'Action capture phase identity is missing: after.'} + $identity=$property.Value + Assert-CapDataRecord $identity @{ + sourceContract=[string];sourceCommit=[string];expectedWindowDpi=[long];requestedOuterWH=[array] + expectedInitialClientWH=[array];expectedInitialOuterWH=[array] + expectedPhysicalClientWH=[array];expectedAdjustedOuterWH=[array] + windowStyleForAdjustment=[long];windowExStyleForAdjustment=[long];expectedRuntimeWindowStyle=[long] + expectedRuntimeWindowExStyle=[long];hasMenu=[bool];geometryDerivation=[string] + } 'Action source window identity' + if ($identity.sourceContract -cne 'GRAPHCODE_MAIN_WINDOW_V1' -or + $identity.sourceCommit -cnotmatch '^[a-f0-9]{40}$' -or + $identity.sourceCommit -cne $Job.head -or + $identity.sourceCommit -cne $Job.binaries.after.source -or + $identity.sourceCommit -cne $Job.binaries.after.binaryBuiltAtSource -or + $identity.expectedWindowDpi -ne $Job.captureContract.expectedWindowDpi) { + throw 'Action source window identity differs from the capture job/binary.' + } + $specifications=@() + if ($Group -cin @('WireCompletion','FinalBoundedActions')) { + $specifications+= [pscustomobject]@{actionPhase='wire-completion' + sceneNames=@('s-goal-unwired-unselected');actionIds=@('W1')} + } + if ($Group -ceq 'FinalBoundedActions') { + $specifications+= [pscustomobject]@{actionPhase='after' + sceneNames=@('s-tabs-two-agent-selected','s-tabs-split-right-focused','s-tabs-last-guard', + 's-tabs-many-first','s-tabs-hover-close');actionIds=@('W2','W3','W4','W5','W6')} + } elseif ($Group -ceq 'TerminalActions') { + $specifications+= [pscustomobject]@{actionPhase='after' + sceneNames=@(Get-CapMatchedFocusedSceneNames -Profile TerminalChrome) + actionIds=@('terminal-actions')} + } + foreach ($specification in $specifications) { + [pscustomobject][ordered]@{ + actionPhase=$specification.actionPhase + capturePhase='after' + sceneNames=@($specification.sceneNames) + actionIds=@($specification.actionIds) + sourceCommit=$identity.sourceCommit + identity=$identity + } + } +} + +function Assert-CapActionWindowBindings { + param([Parameter(Mandatory)]$Job,[Parameter(Mandatory)]$Plan) + $expected=@(Get-CapActionWindowBindings -Job $Job -Group $Plan.group) + $property=$Plan.PSObject.Properties['actionWindowBindings'] + if (-not $property -or $null -eq $property.Value -or + -not [Text.Json.Nodes.JsonNode]::DeepEquals( + [Text.Json.Nodes.JsonNode]::Parse((ConvertTo-Json -InputObject @($property.Value) -Depth 30 -Compress)), + [Text.Json.Nodes.JsonNode]::Parse((ConvertTo-Json -InputObject $expected -Depth 30 -Compress)))) { + throw 'Action phase/capture phase/scene/action/source window binding differs.' + } + $scenes=@($expected | ForEach-Object {$_.sceneNames}) + if (($Plan.expectedScenes -join ',') -cne ($scenes -join ',')) { + throw 'Action window binding scene inventory differs.' + } + return $expected +} + function Assert-CapFocusedPrelaunchPaths { param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)]$Paths) Assert-CapDataRecord $Paths @{outputDirectory=[string];runDirectory=[string] @@ -6087,6 +6154,7 @@ function Assert-CapNoWorkerTransientArtifacts { Export-ModuleMember -Function @( 'Get-CapMatchedSceneNames','Get-CapMatchedFocusedSceneNames','Get-CapMatchedFocusedClassification', 'Get-CapMatchedCapturePlan', + 'Get-CapActionWindowBindings','Assert-CapActionWindowBindings', 'Assert-CapFocusedPrelaunchPaths', 'Assert-CapMatchedFixture','Get-CapMatchedAutomationId', 'Get-CapMatchedVisibleTabIndexes','Get-CapMatchedTabObservation','Get-CapMatchedChromeControlObservation',