From 2251361e4fa10b8e276a7cec1279ee2830854d25 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 29 Oct 2026 10:10:51 -0700 Subject: [PATCH] Preserve action authority across worker imports Bind and pin action authorization through final publication, and exercise the real entry journal, harmless launcher custody, geometry preconditions and cleanup without product or provider launches. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Invoke-MatchedActionSupplement.ps1 | 87 ++++++-- Tools/windows/Invoke-UiCaptureWorker.ps1 | 83 +++++-- .../Tests/ActionWorkerImport.Tests.ps1 | 204 ++++++++++++++++++ .../windows/Tests/ValidationRunner.Tests.ps1 | 12 ++ Tools/windows/UI-CAPTURE-SCENARIOS.md | 41 ++++ 5 files changed, 391 insertions(+), 36 deletions(-) create mode 100644 Tools/windows/Tests/ActionWorkerImport.Tests.ps1 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