diff --git a/Tools/windows/Invoke-MatchedActionSupplement.ps1 b/Tools/windows/Invoke-MatchedActionSupplement.ps1 index 9fbd902a..8fb19d04 100644 --- a/Tools/windows/Invoke-MatchedActionSupplement.ps1 +++ b/Tools/windows/Invoke-MatchedActionSupplement.ps1 @@ -2,7 +2,8 @@ param( [Parameter(Mandatory)][string]$Repository, [Parameter(Mandatory)][string]$OwnedRoot, [Parameter(Mandatory)][string]$ActionPlanPath, - [Parameter(Mandatory)][ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$ActionPlanSha256 + [Parameter(Mandatory)][ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$ActionPlanSha256, + [switch]$DryRun ) $ErrorActionPreference='Stop' $root=[IO.Path]::GetFullPath($OwnedRoot).TrimEnd('\') @@ -44,11 +45,18 @@ foreach ($binding in @( } } $granted=[DateTimeOffset]::UtcNow.AddSeconds(-1) +$actionRunDirectory=Join-Path $root ($(if ($DryRun) {'dry-run-runs\'} else {'action-runs\'}) + + [guid]::NewGuid().ToString('N')) +$authorizationPath=Join-Path $root $(if ($DryRun) {'dry-run-binding.json'} else {'action-authorization.json'}) $authorization=[ordered]@{ schemaVersion=1L authorizationId=[guid]::NewGuid().ToString() - status='AUTHORIZED' - authorizedAction='matched-action-supplement' + status=$(if ($DryRun) {'DRY_RUN_NO_AUTHORITY'} else {'AUTHORIZED'}) + authorizedAction=$(if ($DryRun) {'no-product-import-dry-run'} else {'matched-action-supplement'}) + ownedRoot=$root + authorizationPath=$authorizationPath + runDirectory=$actionRunDirectory + processJournalPath=(Join-Path $actionRunDirectory 'process-journal') group=$plan.group sourceHead=$head sourceTree=$tree @@ -64,22 +72,26 @@ $authorization=[ordered]@{ [long]$plan.authorizationBounds.requestedMaxMinutes).ToString('o') maxMinutes=[long]$plan.authorizationBounds.requestedMaxMinutes } -if ($plan.group -ceq 'FinalBoundedActions') {$authorization.pngAuthorized=$true} -$authorizationPath=Join-Path $root 'action-authorization.json' +if (-not $DryRun -and $plan.group -ceq 'FinalBoundedActions') {$authorization.pngAuthorized=$true} if (Test-Path $authorizationPath) {throw 'Action supplement authorization already consumed.'} [IO.File]::WriteAllText($authorizationPath,($authorization|ConvertTo-Json -Depth 20), [Text.UTF8Encoding]::new($false)) -$output=Join-Path $root 'action-output' +$authorizationSha256=(Get-FileHash -LiteralPath $authorizationPath -Algorithm SHA256).Hash +$output=Join-Path $root $(if ($DryRun) {'dry-run-output'} else {'action-output'}) if (Test-Path $output) {throw 'Action supplement output already exists.'} $null=New-Item -ItemType Directory -Path $output Import-Module $modulePath -Force -WarningAction SilentlyContinue . $workerPath -OwnedRoot $root -DefinitionsOnly +$authorizationHandle=Open-CapActionAuthorization -Path $authorizationPath -Sha256 $authorizationSha256 ` + -Expected ([pscustomobject]$authorization) -DryRun:$DryRun +$selfProcess=$null +try { $job=Get-Content $plan.jobPath -Raw | ConvertFrom-Json -DateKind String -$matchedCapturePlan=Get-CapMatchedCapturePlan -Job $job +if (-not $DryRun) {$matchedCapturePlan=Get-CapMatchedCapturePlan -Job $job} $fixturePath=Join-Path $root 'fixtures\fixture.json' $peerScript=Join-Path $root 'inputs\offline-peer-source.ps1' -$runDirectory=Join-Path $root ('action-runs\' + [guid]::NewGuid().ToString('N')) +$runDirectory=$actionRunDirectory $null=New-Item -ItemType Directory -Path $runDirectory $journalPath=Join-Path $runDirectory 'process-journal' $null=New-Item -ItemType Directory -Path $journalPath @@ -87,9 +99,7 @@ $outputDirectory=$output $workerLog=Join-Path $output 'action-worker.log' [IO.File]::WriteAllText($workerLog,'',[Text.UTF8Encoding]::new($false)) Set-CapTracePath -Path (Join-Path $output 'action-calls.jsonl') -$leaseWindow=Get-CapLeaseWindow -GrantedUtc $authorization.grantedUtc ` - -ExpiresUtc $authorization.expiresUtc -MaximumMinutes ([double]$authorization.maxMinutes) ` - -NowUtc ([DateTime]::UtcNow) +$leaseWindow=$authorizationHandle.Window $leaseDeadlineUtc=$leaseWindow.WorkerDeadlineUtc $focusedCapture=$true $actionSupplementMode=$true @@ -114,10 +124,43 @@ Write-CapWorkerJournalSnapshot -Kind 'Bootstrap' $startedUtc=[DateTime]::UtcNow $primaryFailure=$null $independent=@() +$dryRunLauncher=$null +$dryRunProof=$null try { - $matchedFixture=Get-Content $fixturePath -Raw | ConvertFrom-Json -DateKind String - Initialize-CapMatchedWorktrees -Fixture $matchedFixture - if ($plan.group -ceq 'TerminalActions') { + Assert-CapWorkerDeadline 'action entry' + if ($DryRun) { + # Exercise the real custody gate with a harmless child, never a product/provider image. + $dryRunLauncher=Start-CapJobLauncher -Root $root -OutputDirectory $outputDirectory ` + -FilePath (Join-Path $PSHOME 'pwsh.exe') ` + -ArgumentList @('-NoProfile','-NonInteractive','-Command','exit 0') ` + -WorkingDirectory $root -Role 'import-dry-run-launcher' ` + -Environment @{TEMP=$runDirectory;TMP=$runDirectory} + $runs.Add($dryRunLauncher) + Write-CapProcessJournal -Identity $dryRunLauncher.Identity -Phase 'dry-run' + $releaseSequence=$journalState.Sequence + $target=Release-CapJobLauncher -Run $dryRunLauncher -JournalState $journalState + $dryRunProof=[ordered]@{ + authorizationPath=$authorizationPath + authorizationSha256=$authorizationSha256 + journalDirectory=$journalState.Directory + journalBinding=$journalState.Binding + releaseSequence=$releaseSequence + target=$target + productLaunches=0L + providerLaunches=0L + windowIdentityValidation='REQUIRES_PRODUCT_WINDOW_NOT_EXECUTED' + geometry=$null + preconditionsEvaluated=$true + } + if (-not (Test-CapMatchedDpiAdapter)) {throw 'Action dry run requires the production matched DPI contract.'} + $dryRunProof.geometry=Get-CapMatchedWindowGeometry -Phase 'after' + } else { + $matchedFixture=Get-Content $fixturePath -Raw | ConvertFrom-Json -DateKind String + Initialize-CapMatchedWorktrees -Fixture $matchedFixture + } + if ($DryRun) { + Assert-CapWorkerDeadline 'dry-run preconditions evaluated' + } elseif ($plan.group -ceq 'TerminalActions') { $bindings=@{} foreach ($property in $plan.imageBindings.PSObject.Properties) { $bindings[$property.Name]=[string]$property.Value @@ -160,6 +203,10 @@ try { } catch { $primaryFailure=$_ } finally { + if ($dryRunLauncher) { + try {$null=Close-CapMatchedJob -Run $dryRunLauncher -Phase 'dry-run'} + catch {$cleanupFailures.Add("Dry-run launcher cleanup failed: $($_.Exception.Message)")} + } foreach ($appRun in @($applicationRuns | Sort-Object {$_.Run.StartedUtc} -Descending)) { if (-not $appRun.CleanupComplete) { try {Close-CapGraphCodeApp -AppRun $appRun} @@ -199,7 +246,9 @@ $manifest=[ordered]@{ sourceHead=$head sourceTree=$tree actionPlanSha256=$ActionPlanSha256.ToUpperInvariant() - authorizationSha256=(Get-FileHash $authorizationPath -Algorithm SHA256).Hash + authorizationSha256=$authorizationHandle.Sha256 + executionMode=$(if ($DryRun) {'DRY_RUN_NO_AUTHORITY'} else {'MATCHED_ACTION_SUPPLEMENT'}) + dryRun=$dryRunProof startedUtc=$startedUtc.ToString('o') finishedUtc=[DateTime]::UtcNow.ToString('o') imageBindings=$plan.imageBindings @@ -217,7 +266,7 @@ $manifest=[ordered]@{ cleanup=@($cleanupReceipts) cleanupFailures=@($cleanupFailures) independentSurvivors=@($independent) - failures=$(if ($primaryFailure) {@($primaryFailure.Exception.Message)} else {@()}) + failures=@(if ($primaryFailure) {$primaryFailure.Exception.Message}) pngCount=[long]@(Get-ChildItem $root -Recurse -File -Filter '*.png').Count } $manifestPath=Join-Path $output 'action-manifest.json' @@ -245,7 +294,7 @@ $releasePath=Join-Path $output 'action-release.json' if ($manifest.status -cne 'COMPLETE' -or $release.status -cne 'RELEASED_ZERO_SURVIVORS') { throw "Action supplement failed: $($manifest.failures -join '; ') cleanup=$($cleanupFailures -join '; ') survivors=$($independent.Count)" } -if ($manifest.pngCount -ne [long]$plan.maximumResultPngs) { +if ($manifest.pngCount -ne $(if ($DryRun) {0L} else {[long]$plan.maximumResultPngs})) { throw "Action supplement result image count differs: expected=$($plan.maximumResultPngs) actual=$($manifest.pngCount)" } [ordered]@{ @@ -259,3 +308,7 @@ if ($manifest.pngCount -ne [long]$plan.maximumResultPngs) { cleanupState=$release.cleanupState pngCount=$manifest.pngCount } | ConvertTo-Json -Depth 10 +} finally { + $authorizationHandle.Pin.Dispose() + if ($selfProcess) {$selfProcess.Dispose()} +} diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index 5a98ed9c..2187268e 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -1,7 +1,7 @@ param( [Parameter(Mandatory)][string]$OwnedRoot, - [Parameter()][string]$AuthorizationPath, - [Parameter()][string]$AuthorizationSha256, + [Parameter()][Alias('AuthorizationPath')][string]$WorkerEntryAuthorizationPath, + [Parameter()][Alias('AuthorizationSha256')][string]$WorkerEntryAuthorizationSha256, [switch]$AwaitSupervisorGate, [Parameter()][switch]$DefinitionsOnly ) @@ -84,6 +84,58 @@ function Get-CapWorkerAuthorityId { throw 'Worker authority identity is missing.' } +function Open-CapActionAuthorization { + param( + [AllowNull()][AllowEmptyString()][string]$Path, + [Parameter(Mandatory)][string]$Sha256, + [Parameter(Mandatory)]$Expected, + [switch]$DryRun + ) + if ([string]::IsNullOrWhiteSpace($Path)) { throw 'Action authorization path is null or empty.' } + $fullPath = Assert-CapArtifactPath -Root $root -Path $Path + if ($fullPath -ine $Expected.authorizationPath) { throw 'Action authorization path differs from this run.' } + # Deny replacement/writes until manifest and release publication have finished. + $pin = [IO.File]::Open($fullPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + $record = Read-CapBoundJson -Root $root -Reference ([pscustomobject]@{path=$fullPath;sha256=$Sha256}) + foreach ($name in @('authorizationId','ownedRoot','authorizationPath','runDirectory', + 'processJournalPath','actionPlanSha256','jobSha256','moduleSha256','workerSha256', + 'actionRunnerSha256','sourceHead','sourceTree','group','grantedUtc','expiresUtc','maxMinutes','pngAuthorized')) { + if (-not $record.PSObject.Properties[$name] -or + [string]$record.$name -cne [string]$Expected.$name) { + throw "Action authorization binding differs: $name" + } + } + $expectedStatus = if ($DryRun) {'DRY_RUN_NO_AUTHORITY'} else {'AUTHORIZED'} + $expectedAction = if ($DryRun) {'no-product-import-dry-run'} else {'matched-action-supplement'} + if ($record.status -cne $expectedStatus -or $record.authorizedAction -cne $expectedAction -or + ($DryRun -and $record.pngAuthorized -ne $false)) { + throw 'Action authorization authority differs.' + } + $window = Get-CapLeaseWindow -GrantedUtc $record.grantedUtc -ExpiresUtc $record.expiresUtc ` + -MaximumMinutes ([double]$record.maxMinutes) -NowUtc ([DateTime]::UtcNow) + if ([DateTime]::UtcNow -ge $window.WorkerDeadlineUtc) { throw 'Action authorization worker deadline expired.' } + return [pscustomobject]@{Record=$record;Pin=$pin;Sha256=$Sha256;Window=$window} + } catch { + $pin.Dispose() + throw + } +} + +function Get-CapMatchedWindowGeometry { + param([Parameter(Mandatory)][string]$Phase) + $area=Get-CapPrimaryPhysicalWorkArea + $contract = $job.captureContract + $identity = $contract.windowIdentities.$Phase + return Get-CapDpiGeometry -LogicalClientWH @($contract.logicalClientWH) ` + -ExpectedDpi ([uint32]$contract.expectedWindowDpi) ` + -WindowStyle ([long]$identity.windowStyleForAdjustment) ` + -WindowExStyle ([long]$identity.windowExStyleForAdjustment) ` + -HasMenu ([bool]$identity.hasMenu) ` + -WorkArea @($area.Left,$area.Top,$area.Right,$area.Bottom) ` + -ExpectedAdjustedOuterWH @($identity.expectedAdjustedOuterWH) +} + function ConvertTo-CapWorkerImagePath { param([Parameter(Mandatory)][string]$Path) $value=$Path.Trim() @@ -1345,18 +1397,11 @@ function Start-CapGraphCodeApp { $appRun | Add-Member -NotePropertyName Placement -NotePropertyValue $placement $appRun | Add-Member -NotePropertyName Roots -NotePropertyValue $Roots } elseif (Test-CapMatchedDpiAdapter) { - $area=Get-CapPrimaryPhysicalWorkArea $beforeMetrics=Get-CapWindowMetrics -Window $window -ProcessIdentity $run.Identity $contract=$job.captureContract $identity=$contract.windowIdentities.$Phase $null=Assert-CapDpiWindowIdentity -Metrics $beforeMetrics -Identity $identity -Initial - $geometry=Get-CapDpiGeometry -LogicalClientWH @($contract.logicalClientWH) ` - -ExpectedDpi ([uint32]$contract.expectedWindowDpi) ` - -WindowStyle ([long]$identity.windowStyleForAdjustment) ` - -WindowExStyle ([long]$identity.windowExStyleForAdjustment) ` - -HasMenu ([bool]$identity.hasMenu) ` - -WorkArea @($area.Left,$area.Top,$area.Right,$area.Bottom) ` - -ExpectedAdjustedOuterWH @($identity.expectedAdjustedOuterWH) + $geometry=Get-CapMatchedWindowGeometry -Phase $Phase Set-CapWindowPosition -Window $window -ProcessIdentity $run.Identity ` -X $geometry.OuterBounds[0] -Y $geometry.OuterBounds[1] ` -Width $geometry.OuterWH[0] -Height $geometry.OuterWH[1] @@ -3067,21 +3112,21 @@ function Invoke-CapRealSeedRehearsal { if ($DefinitionsOnly) { return } try { - if (-not $AuthorizationPath -or -not $AuthorizationSha256) { + if (-not $WorkerEntryAuthorizationPath -or -not $WorkerEntryAuthorizationSha256) { throw 'Live capture requires the supervisor-bound authorization path and hash.' } - $authorizationFullPath = [IO.Path]::GetFullPath($AuthorizationPath) + $authorizationFullPath = [IO.Path]::GetFullPath($WorkerEntryAuthorizationPath) if (-not $authorizationFullPath.StartsWith($root + '\', [StringComparison]::OrdinalIgnoreCase)) { throw 'Worker authorization path escaped its explicit owned root.' } - if (-not (Test-Path -LiteralPath $AuthorizationPath -PathType Leaf)) { - throw "Supervisor run authorization is missing: $AuthorizationPath" + if (-not (Test-Path -LiteralPath $WorkerEntryAuthorizationPath -PathType Leaf)) { + throw "Supervisor run authorization is missing: $WorkerEntryAuthorizationPath" } - $actualAuthorizationHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $AuthorizationPath).Hash - if ($actualAuthorizationHash -ne $AuthorizationSha256) { + $actualAuthorizationHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $WorkerEntryAuthorizationPath).Hash + if ($actualAuthorizationHash -ne $WorkerEntryAuthorizationSha256) { throw 'Supervisor run authorization hash mismatch.' } - $auth = Get-Content -LiteralPath $AuthorizationPath -Raw | ConvertFrom-Json -DateKind String + $auth = Get-Content -LiteralPath $WorkerEntryAuthorizationPath -Raw | ConvertFrom-Json -DateKind String if ($auth.ownedRoot -ine $root -or $auth.executionMode -notin @('LIVE_CAPTURE','FOCUSED_LIVE_CAPTURE', 'NO_DESKTOP_REAL_LAUNCH_REHEARSAL','LIVE_TRANSITION_REHEARSAL')) { @@ -3101,7 +3146,7 @@ try { [CapProcessJob]::ClearStandardHandleInheritance() if (-not $AwaitSupervisorGate) { throw 'Worker requires the assigned supervisor job gate.' } $gate = [Console]::In.ReadLineAsync() - if (-not $gate.Wait(30000) -or $gate.Result -cne $AuthorizationSha256) { + if (-not $gate.Wait(30000) -or $gate.Result -cne $WorkerEntryAuthorizationSha256) { throw 'Supervisor job gate was not released.' } $safeWorkerOutput = Assert-CapArtifactPath -Root $root -Path $auth.outputDirectory @@ -3130,7 +3175,7 @@ try { $leaseHash -ne $auth.authoritySha256) { throw 'A reviewed capture control or authorization input changed after supervisor launch.' } - if ($AuthorizationPath -ine $auth.authorizationPath) { + if ($WorkerEntryAuthorizationPath -ine $auth.authorizationPath) { throw 'Worker command-line authorization does not match the supervisor-bound record.' } $job = Read-CapBoundJson -Root $root -Reference ([pscustomobject]@{ path = $jobPath; sha256 = $jobHash }) diff --git a/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 b/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 new file mode 100644 index 00000000..47e5d48f --- /dev/null +++ b/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 @@ -0,0 +1,204 @@ +param( + [string]$ScratchRoot = (Join-Path ([IO.Path]::GetTempPath()) ('ai' + [guid]::NewGuid().ToString('N').Substring(0,8))) +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$driverDirectory = Split-Path -Parent $PSScriptRoot +$workerPath = Join-Path $driverDirectory 'Invoke-UiCaptureWorker.ps1' +$caseRoot = [IO.Path]::GetFullPath($ScratchRoot) +if (Test-Path -LiteralPath $caseRoot) { throw "Refusing to replace import evidence: $caseRoot" } +$null = New-Item -ItemType Directory -Path $caseRoot +$authorizationPath = Join-Path $caseRoot 'action-authorization.json' +$authorizationSha256 = 'A' * 64 +$expectedAuthorizationPath = $authorizationPath +$expectedAuthorizationSha256 = $authorizationSha256 +. $workerPath -OwnedRoot $caseRoot -DefinitionsOnly +if ($authorizationPath -cne $expectedAuthorizationPath -or + $authorizationSha256 -cne $expectedAuthorizationSha256) { + throw "Worker definitions import reset caller authorization: path='$authorizationPath' hash='$authorizationSha256'." +} +Import-Module $modulePath -Force -WarningAction SilentlyContinue +Set-CapTracePath -Path (Join-Path $caseRoot 'calls.jsonl') +$results = [Collections.Generic.List[object]]::new() +$results.Add(@{name='caller authorization survives production definitions import';passed=$true}) + +function Assert-ImportCase { + param([bool]$Condition,[string]$Message) + if (-not $Condition) {throw $Message} +} + +function Write-ImportJson { + param([string]$Path,$Value) + [IO.File]::WriteAllText($Path,($Value | ConvertTo-Json -Depth 40),[Text.UTF8Encoding]::new($false)) + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash +} + +function Reject-ImportCase { + param([string]$Name,[scriptblock]$Action,[string]$Message) + $failure=$null + try {& $Action | Out-Null} catch {$failure=$_} + Assert-ImportCase ($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}) +} + +$now=[DateTimeOffset]::UtcNow.AddSeconds(-1) +$expected=[pscustomobject][ordered]@{ + authorizationId=[guid]::NewGuid().ToString() + ownedRoot=$caseRoot + authorizationPath=$authorizationPath + runDirectory=(Join-Path $caseRoot 'runs\one') + processJournalPath=(Join-Path $caseRoot 'runs\one\process-journal') + actionPlanSha256=('A'*64);jobSha256=('B'*64);moduleSha256=('C'*64) + workerSha256=('D'*64);actionRunnerSha256=('E'*64) + sourceHead=('a'*40);sourceTree=('b'*40);group='FinalBoundedActions' + grantedUtc=$now.ToString('o');expiresUtc=$now.AddMinutes(3).ToString('o');maxMinutes=3L + status='AUTHORIZED';authorizedAction='matched-action-supplement';pngAuthorized=$true +} +$hash=Write-ImportJson $authorizationPath $expected +Reject-ImportCase 'null path' {Open-CapActionAuthorization -Path $null -Sha256 $hash -Expected $expected} 'null or empty' +Reject-ImportCase 'empty path' {Open-CapActionAuthorization -Path '' -Sha256 $hash -Expected $expected} 'null or empty' +Reject-ImportCase 'reset path' { + $reset=$null + Open-CapActionAuthorization -Path $reset -Sha256 $hash -Expected $expected +} 'null or empty' +Reject-ImportCase 'foreign path' { + Open-CapActionAuthorization -Path (Join-Path (Split-Path -Parent $caseRoot) 'foreign.json') -Sha256 $hash -Expected $expected +} 'escaped' +Reject-ImportCase 'wrong hash' {Open-CapActionAuthorization -Path $authorizationPath -Sha256 ('0'*64) -Expected $expected} 'hash mismatch' +foreach ($field in @('runDirectory','processJournalPath','authorizationId','status','authorizedAction')) { + $record=$expected | ConvertTo-Json | ConvertFrom-Json -DateKind String + $record.$field='foreign' + $changedHash=Write-ImportJson $authorizationPath $record + Reject-ImportCase "wrong $field" { + Open-CapActionAuthorization -Path $authorizationPath -Sha256 $changedHash -Expected $expected + } $(if ($field -in @('status','authorizedAction')) {'authority differs'} else {"binding differs: $field"}) +} +foreach ($timing in @('stale','deadline')) { + $record=$expected | ConvertTo-Json | ConvertFrom-Json -DateKind String + $expiry=if ($timing -ceq 'stale') {[DateTimeOffset]::UtcNow.AddMinutes(-1)} else {[DateTimeOffset]::UtcNow.AddSeconds(60)} + $record.grantedUtc=$expiry.AddMinutes(-3).ToString('o') + $record.expiresUtc=$expiry.ToString('o') + $changedHash=Write-ImportJson $authorizationPath $record + Reject-ImportCase $timing { + Open-CapActionAuthorization -Path $authorizationPath -Sha256 $changedHash -Expected $record + } $(if ($timing -ceq 'stale') {'expired'} else {'deadline expired'}) +} +$hash=Write-ImportJson $authorizationPath $expected +$held=Open-CapActionAuthorization -Path $authorizationPath -Sha256 $hash -Expected $expected +try { + Reject-ImportCase 'replacement while held' { + [IO.File]::Move($authorizationPath,($authorizationPath+'.replaced')) + } 'used by another process' + Reject-ImportCase 'write while held' {[IO.File]::WriteAllText($authorizationPath,'{}')} 'used by another process' +} finally {$held.Pin.Dispose()} +[IO.File]::WriteAllText($authorizationPath,'{}') +Reject-ImportCase 'replaced bytes before open' { + Open-CapActionAuthorization -Path $authorizationPath -Sha256 $hash -Expected $expected +} 'hash mismatch' + +# A clean fixture repository binds unmodified production entry scripts, not a wrapper or AST projection. +$fixtureRepository=Join-Path $caseRoot 'repository' +$fixtureTools=Join-Path $fixtureRepository 'Tools\windows' +$null=New-Item -ItemType Directory -Path $fixtureTools +foreach ($name in @('Invoke-MatchedActionSupplement.ps1','Invoke-UiCaptureWorker.ps1','UiCaptureDriver.psm1', + 'Stub-Daemon.ps1','Start-MatchedActionSessions.ps1')) { + Copy-Item -LiteralPath (Join-Path $driverDirectory $name) -Destination (Join-Path $fixtureTools $name) +} +& git init --quiet $fixtureRepository +& git -C $fixtureRepository config core.autocrlf false +& git -C $fixtureRepository add . +& git -C $fixtureRepository -c user.name=Contract -c user.email=contract@example.invalid ` + -c commit.gpgsign=false commit --quiet -s -m 'Production entry fixture' +if ($LASTEXITCODE -ne 0) {throw 'Could not commit production entry fixture.'} +$fixtureHead=(& git -C $fixtureRepository rev-parse HEAD).Trim() +$fixtureTree=(& git -C $fixtureRepository rev-parse 'HEAD^{tree}').Trim() +foreach ($scenario in @('accepted','setup-refusal')) { + $owned=Join-Path $caseRoot $scenario + $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)}) + }} + }} + $jobDigest=Write-ImportJson $jobFile $jobValue + $preflightFile=Join-Path $owned 'preflight.json' + $preflightDigest=Write-ImportJson $preflightFile @{status='FILE_ONLY_TEST_NOT_LIVE_PREFLIGHT'} + $sceneFile=Join-Path $owned 'scenes.json' + $sceneDigest=Write-ImportJson $sceneFile @{} + $actionPlan=[ordered]@{ + status='PREPARED_NO_AUTHORITY';pngArtifacts=0L;processLaunches=0L + sourceHead=$fixtureHead;sourceTree=$fixtureTree;group='FinalBoundedActions' + 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 + actionRunnerSha256=(Get-FileHash (Join-Path $fixtureTools 'Invoke-MatchedActionSupplement.ps1')).Hash + actionSeedHelperSha256=(Get-FileHash (Join-Path $fixtureTools 'Start-MatchedActionSessions.ps1')).Hash + jobPath=$jobFile;jobSha256=$jobDigest + preflightReceiptPath=$preflightFile;preflightReceiptSha256=$preflightDigest + existingSceneManifestPath=$sceneFile;existingSceneManifestSha256=$sceneDigest + authorizationBounds=@{requestedMaxMinutes=3L} + fixture=@{canonicalSourceSha256=$fixtureHashValue;mappingSha256=$fixtureHashValue} + imageBindings=@{};maximumResultPngs=1L + } + $planFile=Join-Path $owned 'action-plan.json' + $planDigest=Write-ImportJson $planFile $actionPlan + $entryOutput=@(& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File ` + (Join-Path $fixtureTools 'Invoke-MatchedActionSupplement.ps1') ` + -Repository $fixtureRepository -OwnedRoot $owned -ActionPlanPath $planFile -ActionPlanSha256 $planDigest -DryRun 2>&1) + $entryExit=$LASTEXITCODE + $entryOutput | Set-Content -LiteralPath (Join-Path $owned 'entry.log') -Encoding utf8NoBOM + $manifestFile=Join-Path $owned 'dry-run-output\action-manifest.json' + Assert-ImportCase (Test-Path -LiteralPath $manifestFile) "Production entry did not publish manifest: $entryOutput" + $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') + 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 + ($launchSpec.arguments -join '|') -ceq '-NoProfile|-NonInteractive|-Command|exit 0' -and + $manifest.processJournal.Count -ge 1 -and + @($manifest.processJournal | Where-Object { + $_.expectedImagePath -ine (Join-Path $PSHOME 'pwsh.exe') -and + -not ($_.role -ceq 'job-member' -and + $_.expectedImagePath -ieq (Join-Path ([Environment]::SystemDirectory) 'conhost.exe')) + }).Count -eq 0) 'Dry run launched or journaled a non-harmless image.' + 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.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 + $manifest.cleanup[0].launcherStdoutEof -and $manifest.cleanup[0].launcherStderrEof -and + $manifest.numberedJournal.published.Count -ge 3 -and $null -ne $manifest.numberedJournal.terminal -and + $manifest.dryRun.journalDirectory -ceq $manifest.numberedJournal.directory -and + $manifest.dryRun.releaseSequence -ge 2 -and $release.journalPublicationCount -ge 3 -and + $release.manifestSha256 -ceq (Get-FileHash $manifestFile).Hash -and + -not (Test-Path (Join-Path $owned 'action-authorization.json'))) 'Dry-run custody/no-authority/cleanup evidence differs.' + $binding=Get-Content (Join-Path $owned 'dry-run-binding.json') -Raw | ConvertFrom-Json -DateKind String + Assert-ImportCase ($binding.status -ceq 'DRY_RUN_NO_AUTHORITY' -and + $binding.pngAuthorized -eq $false -and $binding.authorizationPath -ceq $manifest.dryRun.authorizationPath -and + $binding.authorizationId -ceq $manifest.numberedJournal.binding.leaseId -and + (Split-Path -Leaf $binding.runDirectory) -ceq $manifest.numberedJournal.binding.runId) 'Dry-run authorization/run binding differs.' + if ($scenario -ceq 'accepted') { + 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}) +} +$report=[ordered]@{suite='ActionWorkerImport';executed=$results.Count;passed=$results.Count;failed=0;skipped=0 + productLaunches=0;providerLaunches=0;cases=@($results)} +$null=Write-ImportJson (Join-Path $caseRoot 'report.json') $report +[pscustomobject]$report | Select-Object suite,executed,passed,failed,skipped | ConvertTo-Json -Compress +if ($results.Count -ne 18) {throw "Unexpected action import contract count: $($results.Count)"} diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index f1b85993..c1b5bd41 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -6050,6 +6050,18 @@ foreach ($name in $matchedGroups) { throw "RED: matched capture group must pass exactly once: $name" } } +$actionImportOutput = @(& $pwsh -NoProfile -File (Join-Path $PSScriptRoot "ActionWorkerImport.Tests.ps1")) +$actionImportOutput | ForEach-Object { Write-Host $_ } +if ($LASTEXITCODE -ne 0) { throw "RED: action worker import contract child failed" } +$actionImportSummaries = @($actionImportOutput | Where-Object { + $_ -is [string] -and $_.StartsWith('{"suite":"ActionWorkerImport",') + }) +if ($actionImportSummaries.Count -ne 1) { throw "RED: action import structured count missing or duplicated" } +$actionImportSummary = ConvertFrom-Json $actionImportSummaries[0] +if ($actionImportSummary.executed -ne 18 -or $actionImportSummary.passed -ne 18 -or + $actionImportSummary.failed -ne 0 -or $actionImportSummary.skipped -ne 0) { + throw "RED: action import requires all 18 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 7ca6afa0..61a1b9b8 100644 --- a/Tools/windows/UI-CAPTURE-SCENARIOS.md +++ b/Tools/windows/UI-CAPTURE-SCENARIOS.md @@ -47,6 +47,47 @@ contract, making current registration **182 default / 185 with zero skips, the ten existing matched/S2 identities once, and all four DPI identities once. Historical reports retain their original denominators. +## Action worker import and no-product dry run + +The action runner imports the production worker definitions into its scope. +Worker entry authorization parameters have private names (with the original +command-line aliases) so importing definitions cannot reset the caller's +authorization path or hash. Action authorization is hash-bound to its exact +owned path, run, numbered journal, authority ID, source, controls, and deadline; +the record is held read-only against writes/replacement through final publication. +The cleanup reserve remains 90 seconds. + +Before granting an action run, invoke the production entry with `-DryRun`: + +```powershell +pwsh -NoProfile -File Tools\windows\Invoke-MatchedActionSupplement.ps1 ` + -Repository D:\owned\GraphCode -OwnedRoot D:\owned\action-check ` + -ActionPlanPath D:\owned\action-check\action-plan.json ` + -ActionPlanSha256 ACTUAL_64_HEX_SHA256 -DryRun +``` + +This still requires an unchanged clean, hash-bound action plan. It creates only +`dry-run-binding.json` (`DRY_RUN_NO_AUTHORITY`), `dry-run-runs`, and +`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. +The shared physical-work-area and `AdjustWindowRectExForDpi` preconditions run +without a product window. Actual 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 +is a bounded tooling/setup blocker, never permission to retry a product action. +No prior W1 setup exception is reconstructed by this dry run. + +`Tests\ActionWorkerImport.Tests.ps1` adds **18 separately counted contracts** +(including two actual production-entry dry runs and null/reset/path/hash/run/ +authority/deadline/replacement negatives). `ValidationRunner.Tests.ps1` requires +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. + ## Live transition rehearsal `LIVE_TRANSITION_REHEARSAL` is a no-lease, non-evidence mode for exercising the