diff --git a/Tools/windows/Invoke-UiCapture.ps1 b/Tools/windows/Invoke-UiCapture.ps1 index 04dfe9ff..eb21fc7c 100644 --- a/Tools/windows/Invoke-UiCapture.ps1 +++ b/Tools/windows/Invoke-UiCapture.ps1 @@ -4,10 +4,12 @@ param( [Parameter(Mandatory)][string]$JobPath, [Parameter()][string]$JobSha256, [Parameter()][ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$FixtureMappingSha256, - [ValidateSet('LIVE_CAPTURE', 'NO_DESKTOP_REAL_LAUNCH_REHEARSAL')] + [ValidateSet('LIVE_CAPTURE', 'NO_DESKTOP_REAL_LAUNCH_REHEARSAL', 'LIVE_TRANSITION_REHEARSAL')] [string]$ExecutionMode = 'LIVE_CAPTURE', [Parameter()][string]$LeaseReceiptPath, [Parameter()][string]$LeaseReceiptSha256, + [Parameter()][string]$RehearsalAuthorizationPath, + [Parameter()][string]$RehearsalAuthorizationSha256, [Parameter()][string]$PreflightReceiptPath, [Parameter()][string]$PreflightReceiptSha256, [switch]$TimeoutProbe @@ -20,6 +22,7 @@ $requestedOutputDirectory = $OutputDirectory $modulePath = Join-Path $PSScriptRoot 'UiCaptureDriver.psm1' $workerPath = Join-Path $PSScriptRoot 'Invoke-UiCaptureWorker.ps1' $rehearsal = $ExecutionMode -ceq 'NO_DESKTOP_REAL_LAUNCH_REHEARSAL' +$transitionRehearsal = $ExecutionMode -ceq 'LIVE_TRANSITION_REHEARSAL' $fixtureMappingHashExpected = if ($FixtureMappingSha256) { $FixtureMappingSha256.ToUpperInvariant() } else { $null } $jobIdentity = $null @@ -52,6 +55,9 @@ $outputDirectory = $null $runDirectory = $null $authorizationPath = $null $authorizationHash = $null +$authorityId = $null +$authorityHash = $null +$authorityPath = $null $deadlineUtc = [DateTime]::MinValue $jobHash = $null $moduleHash = $null @@ -170,6 +176,61 @@ function Assert-CapGreenJob { return $jobIdentity.sourceRepository } +function Invoke-CapTransitionGit { + param([Parameter(Mandatory)][string]$Repository,[Parameter(Mandatory)][string[]]$Arguments) + $output=@(& git -C $Repository @Arguments 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "Live transition rehearsal git failed (exit=$LASTEXITCODE): git -C $Repository $($Arguments -join ' '): $($output -join ' ')" + } + return @($output) +} + +function Assert-CapTransitionJobIdentity { + Assert-CapDataRecord $job @{ head = [string]; productSourceHead = [string] + sourceRepository = [string]; executionMode = [string]; desktopAuthorized = [bool] + authority = [pscustomobject]; binaries = [pscustomobject] } 'Live transition rehearsal job' + Assert-CapDataRecord $job.authority @{ liveTransitionRehearsalApproved = [bool] + captureEvidenceAuthorized = [bool]; pngAuthorized = [bool] } 'Live transition rehearsal authority' + if ($job.executionMode -cne 'LIVE_TRANSITION_REHEARSAL' -or -not $job.desktopAuthorized -or + -not $job.authority.liveTransitionRehearsalApproved -or + $job.authority.captureEvidenceAuthorized -or $job.authority.pngAuthorized -or + $job.productSourceHead -cnotmatch '^[a-f0-9]{40}$' -or $job.head -cnotmatch '^[a-f0-9]{40}$') { + throw 'Live transition rehearsal job is not explicitly authorized as non-capture evidence.' + } + $repository = [IO.Path]::GetFullPath($job.sourceRepository) + if ([string](Invoke-CapTransitionGit $repository @('rev-parse','HEAD')) -cne $job.head -or + @(Invoke-CapTransitionGit $repository @('status','--porcelain','--untracked-files=all')).Count -ne 0) { + throw 'Live transition rehearsal source repository is not clean at its exact tooling head.' + } + foreach ($commit in @($job.head,$job.productSourceHead,$job.base)) { + if ([string](Invoke-CapTransitionGit $repository @('rev-parse','--verify',"$commit`^{commit}")) -cne $commit) { + throw 'Live transition rehearsal names a missing source commit.' + } + } + $delta = @(Invoke-CapTransitionGit $repository @( + 'diff','--name-only','--no-renames','--no-ext-diff','--no-textconv', + $job.productSourceHead,$job.head,'--' + )) + foreach ($path in $delta) { + if ($path -cnotmatch '^(Tools/windows/(UiCaptureDriver\.psm1|Invoke-UiCapture(?:Worker)?\.ps1|UI-CAPTURE-SCENARIOS\.md|Tests/UiCaptureDriver\.Tests\.ps1)|\.github/PULL_REQUEST_TEMPLATE\.md)$') { + throw "Live transition rehearsal tooling head changes a product or unrelated path: $path" + } + } + if ($job.binaries.after.source -cne $job.productSourceHead -or + $job.binaries.after.binaryBuiltAtSource -cne $job.productSourceHead -or + $job.binaries.before.source -cne $job.base -or + $job.binaries.before.binaryBuiltAtSource -cne $job.base) { + throw 'Live transition rehearsal binary source identities differ from the frozen product sources.' + } + return [pscustomobject]@{ + sourceRepository = $repository + toolingHead = $job.head + productSourceHead = $job.productSourceHead + toolingDeltaPaths = $delta + attestation = 'NON_CAPTURE_REHEARSAL_HASH_AND_SOURCE_BINDING' + } +} + function Assert-CapLiveNativeBindings { if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } @@ -422,46 +483,86 @@ function Assert-CapPreflightReport { function Assert-CapLease { if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } - if (-not $LeaseReceiptPath -or -not $LeaseReceiptSha256) { - throw 'No coordinator lease was supplied. Offline preflight readiness does not authorize desktop use.' - } - $leasePath = Assert-CapSupervisorPath $LeaseReceiptPath - if (-not (Test-Path -LiteralPath $leasePath -PathType Leaf)) { throw "Coordinator lease receipt is missing: $leasePath" } - $leaseHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $leasePath).Hash - $script:leaseHash = $leaseHash - if ($leaseHash -ne $LeaseReceiptSha256.ToUpperInvariant()) { throw 'Coordinator lease receipt hash mismatch.' } - $lease = Read-CapBoundJson -Root $root -Reference ([pscustomobject]@{ path = $leasePath; sha256 = $leaseHash }) - Assert-CapDataRecord $lease @{ schemaVersion = [long]; leaseId = [string]; lane = [string] - status = [string]; authorizedAction = [string]; head = [string]; jobManifestSha256 = [string] - moduleSha256 = [string]; workerSha256 = [string]; supervisorSha256 = [string] - grantedUtc = [string]; expiresUtc = [string]; maxMinutes = [long] } 'Coordinator lease' - if ($rehearsal) { - Assert-CapDataRecord $lease @{ desktopAuthorized = [bool] } 'Rehearsal lease' + if ($transitionRehearsal) { + if (-not $RehearsalAuthorizationPath -or -not $RehearsalAuthorizationSha256) { + throw 'Live transition rehearsal requires an explicit non-capture rehearsal authorization.' + } + $leasePath = Assert-CapSupervisorPath $RehearsalAuthorizationPath + if (-not (Test-Path -LiteralPath $leasePath -PathType Leaf)) { + throw "Rehearsal authorization is missing: $leasePath" + } + $leaseHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $leasePath).Hash + if ($leaseHash -ne $RehearsalAuthorizationSha256.ToUpperInvariant()) { + throw 'Rehearsal authorization hash mismatch.' + } + $lease = Read-CapBoundJson -Root $root -Reference ([pscustomobject]@{ path = $leasePath; sha256 = $leaseHash }) + Assert-CapDataRecord $lease @{ schemaVersion = [long]; authorizationId = [string]; status = [string] + authorizedAction = [string]; evidenceClassification = [string]; desktopAuthorized = [bool] + captureEvidenceAuthorized = [bool]; pngAuthorized = [bool]; head = [string] + productSourceHead = [string]; jobManifestSha256 = [string]; moduleSha256 = [string] + workerSha256 = [string]; supervisorSha256 = [string]; preflightReceiptSha256 = [string] + grantedUtc = [string]; expiresUtc = [string]; maxMinutes = [long] } 'Rehearsal authorization' + if ($lease.schemaVersion -ne 1 -or $lease.status -cne 'AUTHORIZED' -or + $lease.authorizedAction -cne 'live-transition-rehearsal' -or + $lease.evidenceClassification -cne 'LIVE_TRANSITION_REHEARSAL_NOT_CAPTURE_EVIDENCE' -or + -not $lease.desktopAuthorized -or $lease.captureEvidenceAuthorized -or $lease.pngAuthorized -or + $lease.head -cne $job.head -or $lease.productSourceHead -cne $job.productSourceHead -or + $lease.jobManifestSha256 -ne $jobHash -or $lease.moduleSha256 -ne $moduleHash -or + $lease.workerSha256 -ne $workerHash -or $lease.supervisorSha256 -ne $supervisorHash -or + $lease.preflightReceiptSha256 -ne $preflightHash) { + throw 'Rehearsal authorization does not bind the exact non-capture job, controls, and preflight.' + } + $null = [guid]::Parse([string]$lease.authorizationId) + $script:authorityId = $lease.authorizationId + $script:authorityHash = $leaseHash + $script:authorityPath = $leasePath } else { - Assert-CapDataRecord $lease @{ centralPreflightAccepted = [bool]; preflightReceiptSha256 = [string] - fixtureMappingSha256 = [string] } 'Capture lease' - } - $expectedStatus = if ($rehearsal) { 'REHEARSAL_ONLY' } else { 'GRANTED' } - $expectedAction = if ($rehearsal) { 'no-desktop-real-launch-rehearsal' } else { 'central-cap-capture' } - if ($lease.schemaVersion -ne 1 -or $lease.status -ne $expectedStatus -or - $lease.lane -cne $job.lane -or $lease.authorizedAction -ne $expectedAction -or - (-not $rehearsal -and $lease.centralPreflightAccepted -ne $true) -or - ($rehearsal -and $lease.desktopAuthorized -ne $false) -or - $lease.head -ne $job.head -or $lease.jobManifestSha256 -ne $jobHash -or - $lease.moduleSha256 -ne $moduleHash -or $lease.workerSha256 -ne $workerHash -or - $lease.supervisorSha256 -ne $supervisorHash -or $lease.preflightReceiptSha256 -ne $preflightHash) { - throw 'Coordinator lease does not bind the job lane/head and exact worker/preflight hashes.' - } - if (-not $rehearsal -and $lease.fixtureMappingSha256 -ne $fixtureMappingHashExpected) { - throw 'Live lease does not bind the exact owned-root fixture mapping.' - } - $null = [guid]::Parse([string]$lease.leaseId) + if (-not $LeaseReceiptPath -or -not $LeaseReceiptSha256) { + throw 'No coordinator lease was supplied. Offline preflight readiness does not authorize desktop use.' + } + $leasePath = Assert-CapSupervisorPath $LeaseReceiptPath + if (-not (Test-Path -LiteralPath $leasePath -PathType Leaf)) { throw "Coordinator lease receipt is missing: $leasePath" } + $leaseHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $leasePath).Hash + $script:leaseHash = $leaseHash + if ($leaseHash -ne $LeaseReceiptSha256.ToUpperInvariant()) { throw 'Coordinator lease receipt hash mismatch.' } + $lease = Read-CapBoundJson -Root $root -Reference ([pscustomobject]@{ path = $leasePath; sha256 = $leaseHash }) + } + if (-not $transitionRehearsal) { + Assert-CapDataRecord $lease @{ schemaVersion = [long]; leaseId = [string]; lane = [string] + status = [string]; authorizedAction = [string]; head = [string]; jobManifestSha256 = [string] + moduleSha256 = [string]; workerSha256 = [string]; supervisorSha256 = [string] + grantedUtc = [string]; expiresUtc = [string]; maxMinutes = [long] } 'Coordinator lease' + if ($rehearsal) { + Assert-CapDataRecord $lease @{ desktopAuthorized = [bool] } 'Rehearsal lease' + } else { + Assert-CapDataRecord $lease @{ centralPreflightAccepted = [bool]; preflightReceiptSha256 = [string] + fixtureMappingSha256 = [string] } 'Capture lease' + } + $expectedStatus = if ($rehearsal) { 'REHEARSAL_ONLY' } else { 'GRANTED' } + $expectedAction = if ($rehearsal) { 'no-desktop-real-launch-rehearsal' } else { 'central-cap-capture' } + if ($lease.schemaVersion -ne 1 -or $lease.status -ne $expectedStatus -or + $lease.lane -cne $job.lane -or $lease.authorizedAction -ne $expectedAction -or + (-not $rehearsal -and $lease.centralPreflightAccepted -ne $true) -or + ($rehearsal -and $lease.desktopAuthorized -ne $false) -or + $lease.head -ne $job.head -or $lease.jobManifestSha256 -ne $jobHash -or + $lease.moduleSha256 -ne $moduleHash -or $lease.workerSha256 -ne $workerHash -or + $lease.supervisorSha256 -ne $supervisorHash -or $lease.preflightReceiptSha256 -ne $preflightHash) { + throw 'Coordinator lease does not bind the job lane/head and exact worker/preflight hashes.' + } + if (-not $rehearsal -and $lease.fixtureMappingSha256 -ne $fixtureMappingHashExpected) { + throw 'Live lease does not bind the exact owned-root fixture mapping.' + } + $null = [guid]::Parse([string]$lease.leaseId) + $script:authorityId = $lease.leaseId + $script:authorityHash = $leaseHash + $script:authorityPath = $leasePath + } $leaseWindow = Get-CapLeaseWindow -GrantedUtc $lease.grantedUtc -ExpiresUtc $lease.expiresUtc ` -MaximumMinutes ([double]$lease.maxMinutes) -NowUtc ([DateTime]::UtcNow) $script:leaseExpiresUtc = $leaseWindow.ExpiresUtc $script:deadlineUtc = $leaseWindow.WorkerDeadlineUtc if ($script:deadlineUtc -le $leaseWindow.NowUtc.AddMinutes(1)) { - throw 'Lease has insufficient remaining time after the reserved 90-second cleanup margin.' + throw 'Authorization has insufficient remaining time after the reserved 90-second cleanup margin.' } $script:lease = $lease $script:leaseHash = $leaseHash @@ -579,15 +680,15 @@ function Read-CapQuiescentWorkerProof { throw 'JournalCleanupUnproven: final owned cleanup arrays are not empty.' } Assert-CapDataRecord $boundWorkerManifest @{ - leaseId = [string]; jobManifestSha256 = [string]; moduleSha256 = [string] - workerSha256 = [string]; supervisorSha256 = [string]; leaseReceiptSha256 = [string] + authorityId = [string]; jobManifestSha256 = [string]; moduleSha256 = [string] + workerSha256 = [string]; supervisorSha256 = [string]; authoritySha256 = [string] } 'Proven worker manifest identity' - if ($boundWorkerManifest.leaseId -cne $journalState.Binding.leaseId -or + if ($boundWorkerManifest.authorityId -cne $journalState.Binding.leaseId -or $boundWorkerManifest.jobManifestSha256 -cne $journalState.Binding.jobSha256 -or $boundWorkerManifest.moduleSha256 -cne $journalState.Binding.moduleSha256 -or $boundWorkerManifest.workerSha256 -cne $journalState.Binding.workerSha256 -or $boundWorkerManifest.supervisorSha256 -cne $journalState.Binding.supervisorSha256 -or - $boundWorkerManifest.leaseReceiptSha256 -cne $leaseHash) { + $boundWorkerManifest.authoritySha256 -cne $authorityHash) { throw 'JournalIdentity: final manifest source/lease fields differ from the proven binding.' } if ($workerRun.Identity.Process.ExitCode -ne 0 -and $boundWorkerManifest.status -ceq 'COMPLETE') { @@ -617,7 +718,12 @@ function Get-CapAppSessionPrefix { param([Parameter(Mandatory)]$Record) if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } - $shortLease = $lease.leaseId.Replace('-', '').Substring(0, 8) + $boundAuthorityId = if (-not [string]::IsNullOrWhiteSpace([string]$authorityId)) { + [string]$authorityId + } else { + [string]$lease.leaseId + } + $shortLease = $boundAuthorityId.Replace('-', '').Substring(0, 8) if ($Record.Role -like '*-actions') { return "gc-cap-actions-$shortLease-$($Record.Phase)-" } return "gc-cap-$shortLease-$($Record.Phase)-" } @@ -792,8 +898,9 @@ function Assert-CapMatchedWorkerManifest { $path=Assert-CapSupervisorPath $restart.transcriptPath if ((Get-FileHash $path -Algorithm SHA256).Hash -ne $restart.transcriptSha256) {throw 'Matched restart transcript bytes changed.'} $rows=@(Read-CapRestartTranscript -Path $path) - if ($rows.Count -lt $restart.evidence.rows.Count -or - (ConvertTo-Json -InputObject @($rows[0..($restart.evidence.rows.Count-1)]) -Depth 40 -Compress) -cne + $connectionRows=@($rows | Where-Object connection -EQ $restart.evidence.connection) + if ($connectionRows.Count -lt $restart.evidence.rows.Count -or + (ConvertTo-Json -InputObject @($connectionRows[0..($restart.evidence.rows.Count-1)]) -Depth 40 -Compress) -cne (ConvertTo-Json -InputObject @($restart.evidence.rows) -Depth 40 -Compress)) {throw 'Matched restart evidence differs from its real recorded frame prefix.'} $null=Assert-CapJobOutputProof -Root $root -Proof $restart.daemonCleanup } @@ -806,6 +913,41 @@ function Assert-CapWorkerManifest { throw 'Worker exited without exact quiescent manifest/terminal binding.' } $manifest = $boundWorkerManifest + if ($transitionRehearsal) { + $expected=@(foreach ($phase in @('before','after')) { + foreach ($scene in Get-CapMatchedSceneNames) {"${phase}:$scene"} + }) + $actual=@($manifest.checkpoints | ForEach-Object {"$($_.record.phase):$($_.record.scene)"}) + if ($manifest.status -cne 'COMPLETE' -or + $manifest.executionMode -cne 'LIVE_TRANSITION_REHEARSAL' -or + $manifest.evidenceClassification -cne 'LIVE_TRANSITION_REHEARSAL_NOT_CAPTURE_EVIDENCE' -or + $manifest.captures -ne 0 -or $manifest.images.Count -ne 0 -or + $manifest.checkpointCount -ne 36 -or $manifest.checkpoints.Count -ne 36 -or + @($actual | Sort-Object -Unique).Count -ne 36 -or + (($actual | Sort-Object) -join ',') -cne (($expected | Sort-Object) -join ',') -or + $manifest.zmxSeeds.Count -ne 22 -or $manifest.realRestarts.Count -ne 2 -or + $manifest.phaseTransitions.Count -ne 1 -or + $manifest.failures.Count -ne 0 -or $manifest.cleanupFailures.Count -ne 0 -or + $manifest.ownedProcessSurvivors.Count -ne 0 -or $manifest.pendingLaunchesRemaining.Count -ne 0) { + throw 'Live transition rehearsal manifest is incomplete or could be mistaken for capture evidence.' + } + foreach ($checkpoint in $manifest.checkpoints) { + Assert-CapDataRecord $checkpoint.reference @{name=[string];length=[long];sha256=[string]} ` + 'Live transition checkpoint reference' + $checkpointPath=Assert-CapSupervisorPath (Join-Path (Join-Path $outputDirectory 'checkpoints') $checkpoint.reference.name) + if ($checkpoint.record.evidenceClassification -cne 'LIVE_TRANSITION_REHEARSAL_NOT_CAPTURE_EVIDENCE' -or + $checkpoint.record.captureMode -cne 'NO_IMAGE_CHECKPOINT' -or + $checkpoint.record.path -or $checkpoint.record.sha256 -or + (Get-Item -LiteralPath $checkpointPath).Length -ne $checkpoint.reference.length -or + (Get-FileHash -LiteralPath $checkpointPath -Algorithm SHA256).Hash -ne $checkpoint.reference.sha256) { + throw 'Live transition rehearsal checkpoint is mutable, image-shaped, or misclassified.' + } + } + if (@(Get-ChildItem -LiteralPath $root -Recurse -File -Filter '*.png' -ErrorAction Stop).Count -ne 0) { + throw 'Live transition rehearsal retained a PNG.' + } + return + } if ($rehearsal) { if ($manifest.status -cne 'COMPLETE' -or $manifest.executionMode -cne $ExecutionMode -or $manifest.guiLaunches.Count -ne 0 -or $manifest.images.Count -ne 0 -or @@ -996,7 +1138,7 @@ try { $outputDirectory = Assert-CapSupervisorPath $requestedOutputDirectory if (Test-Path -LiteralPath $outputDirectory) { throw "Refusing to replace a prior capture output: $outputDirectory" } $null = New-Item -ItemType Directory -Path $outputDirectory - $releaseReceiptPath = Join-Path $outputDirectory 'lease-release.json' + $releaseReceiptPath = Join-Path $outputDirectory $(if ($transitionRehearsal) {'rehearsal-release.json'} else {'lease-release.json'}) $safeOutputInitialized = $true $moduleHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $modulePath).Hash $workerHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $workerPath).Hash @@ -1008,9 +1150,10 @@ try { Invoke-CapTimeoutProbe return } - if (-not $LeaseReceiptPath -or -not $LeaseReceiptSha256 -or + if (($transitionRehearsal -and (-not $RehearsalAuthorizationPath -or -not $RehearsalAuthorizationSha256)) -or + (-not $transitionRehearsal -and (-not $LeaseReceiptPath -or -not $LeaseReceiptSha256)) -or (-not $rehearsal -and (-not $PreflightReceiptPath -or -not $PreflightReceiptSha256))) { - throw 'No coordinator lease or accepted preflight receipt was supplied; no desktop slot is authorized.' + throw 'No exact desktop authorization and accepted preflight receipt were supplied.' } $JobPath = Assert-CapSupervisorPath $JobPath $preflightPath = if ($rehearsal) { $null } else { Assert-CapSupervisorPath $PreflightReceiptPath } @@ -1042,12 +1185,21 @@ try { throw 'Harmless rehearsal target executable hash mismatch.' } $sourceRoot = $PSScriptRoot + } elseif ($transitionRehearsal) { + if ($job.executionMode -cne $ExecutionMode -or -not $job.desktopAuthorized -or + $job.evidenceClassification -cne 'LIVE_TRANSITION_REHEARSAL_NOT_CAPTURE_EVIDENCE') { + throw 'Expected an explicitly authorized live transition rehearsal job.' + } + $preflightHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $preflightPath).Hash + $preflight = Read-CapBoundJson -Root $root -Reference ([pscustomobject]@{ path = $preflightPath; sha256 = $preflightHash }) } else { $preflightHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $preflightPath).Hash $preflight = Read-CapBoundJson -Root $root -Reference ([pscustomobject]@{ path = $preflightPath; sha256 = $preflightHash }) } $null = Assert-CapLease - if (-not $rehearsal -or $null -ne $job.PSObject.Properties['admission']) { + if ($transitionRehearsal) { + $jobIdentity = Assert-CapTransitionJobIdentity + } elseif (-not $rehearsal -or $null -ne $job.PSObject.Properties['admission']) { $jobIdentity = Assert-CapJobIdentity -Root $root -Job $job -Lease $lease -JobSha256 $jobHash -OfflineTestData:$rehearsal } if ($rehearsal -and $null -ne $job.PSObject.Properties['sceneAdapter'] -and @@ -1089,8 +1241,12 @@ try { } } if (@(Get-CapPendingLaunches).Count -ne 0) { throw 'Unreturned capture worker launches remain before run authorization.' } + if ($transitionRehearsal -and + @(Get-ChildItem -LiteralPath $root -Recurse -File -Filter '*.png' -ErrorAction Stop).Count -ne 0) { + throw 'Live transition rehearsal refuses a root that already retains PNG files.' + } - $shortLease = $lease.leaseId.Replace('-', '').Substring(0, 8) + $shortLease = $authorityId.Replace('-', '').Substring(0, 8) if (-not $rehearsal) { if ($root.Length -gt 5) { $pathFailure = [IO.PathTooLongException]::new( @@ -1107,7 +1263,7 @@ try { $processJournalPath = Join-Path $runDirectory 'process-journal' $manifestPath = Join-Path $outputDirectory 'manifest.json' $workerLogPath = Join-Path $outputDirectory 'worker.log' - $releaseReceiptPath = Join-Path $outputDirectory 'lease-release.json' + $releaseReceiptPath = Join-Path $outputDirectory $(if ($transitionRehearsal) {'rehearsal-release.json'} else {'lease-release.json'}) foreach ($path in @($runDirectory)) { if (Test-Path -LiteralPath $path) { throw "Refusing to reuse prior capture run path: $path" } } @@ -1121,9 +1277,14 @@ try { schemaVersion = 1 executionMode = $ExecutionMode ownedRoot = $root - leaseId = $lease.leaseId - leaseReceiptPath = $LeaseReceiptPath - leaseReceiptSha256 = $leaseHash + authorityId = $authorityId + authorityPath = $authorityPath + authoritySha256 = $authorityHash + leaseId = if ($transitionRehearsal) { $null } else { $lease.leaseId } + leaseReceiptPath = if ($transitionRehearsal) { $null } else { $LeaseReceiptPath } + leaseReceiptSha256 = if ($transitionRehearsal) { $null } else { $leaseHash } + rehearsalAuthorizationPath = if ($transitionRehearsal) { $RehearsalAuthorizationPath } else { $null } + rehearsalAuthorizationSha256 = if ($transitionRehearsal) { $authorityHash } else { $null } jobPath = $JobPath jobSha256 = $jobHash modulePath = $modulePath @@ -1151,8 +1312,8 @@ try { } Write-CapJsonAtomic -Path $authorizationPath -Value $auth $authorizationHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $authorizationPath).Hash - $releaseReceiptPath = Join-Path $outputDirectory 'lease-release.json' - Write-CapSupervisorLog "LEASE_ACCEPTED lease=$($lease.leaseId) head=$($job.head) remaining=$([Math]::Round(($leaseExpiresUtc - [DateTime]::UtcNow).TotalSeconds, 1))s reserve=90s" + $releaseReceiptPath = Join-Path $outputDirectory $(if ($transitionRehearsal) {'rehearsal-release.json'} else {'lease-release.json'}) + Write-CapSupervisorLog "AUTHORIZATION_ACCEPTED id=$authorityId mode=$ExecutionMode head=$($job.head) remaining=$([Math]::Round(($leaseExpiresUtc - [DateTime]::UtcNow).TotalSeconds, 1))s reserve=90s" Write-CapSupervisorLog "STARTING_WORKER path=$workerPath sha256=$workerHash authorization=$authorizationHash" if (@(Get-CapTraceFailures).Count -gt 0) { throw 'Capture tracing failed before worker launch.' } @@ -1167,7 +1328,7 @@ try { ) -WorkingDirectory $runDirectory -Role 'b-capture-worker' -HoldStdin [CapProcessJob]::Assign($workerJob, $workerRun.Identity.Process.Handle) $journalBinding = New-CapJournalBinding -RunId ([IO.Path]::GetFileName($runDirectory)) ` - -LeaseId $lease.leaseId -JobSha256 $jobHash -ModuleSha256 $moduleHash ` + -LeaseId $authorityId -JobSha256 $jobHash -ModuleSha256 $moduleHash ` -WorkerSha256 $workerHash -SupervisorSha256 $supervisorHash -Identity $workerRun.Identity $journalState = New-CapJournalState -Root $root -Directory $processJournalPath -Binding $journalBinding ` -DeadlineUtc $deadlineUtc -ObservationPath (Join-Path $diagnosticsDirectory 'journal-read-observations.jsonl') @@ -1335,9 +1496,17 @@ try { workerStarted = $null -ne $workerRun workerLaunchAttempted = $workerLaunchAttempted pendingLaunchesRemaining = @($pendingLaunchesRemaining) - guiOperations = if ($rehearsal) { 'NotExecuted: no-desktop real-launch rehearsal' } else { 'See worker manifest' } - leaseId = if ($lease) { $lease.leaseId } else { $null } - leaseReceiptSha256 = $leaseHash + guiOperations = if ($rehearsal) { 'NotExecuted: no-desktop real-launch rehearsal' } + elseif ($transitionRehearsal) { 'Executed real UIA transitions; no images authorized' } + else { 'See worker manifest' } + evidenceClassification = if ($transitionRehearsal) { + 'LIVE_TRANSITION_REHEARSAL_NOT_CAPTURE_EVIDENCE' + } else { $null } + authorityId = $authorityId + authoritySha256 = $authorityHash + leaseId = if ($transitionRehearsal) { $null } elseif ($lease) { $lease.leaseId } else { $null } + leaseReceiptSha256 = if ($transitionRehearsal) { $null } else { $leaseHash } + rehearsalAuthorizationSha256 = if ($transitionRehearsal) { $authorityHash } else { $null } jobSha256 = $jobHash head = if ($job) { $job.head } else { $null } moduleSha256 = $moduleHash @@ -1383,4 +1552,5 @@ if ($cleanupFailures.Count -or $releaseFailure -or -not $releaseWritten) { throw "Capture supervisor failed. Primary=$(Get-CapSupervisorFailureText $primaryFailure); cleanup=$($cleanupFailures -join '; '); release=$(Get-CapSupervisorFailureText $releaseFailure); releaseWritten=$releaseWritten" } if ($primaryFailure) { throw $primaryFailure } -Write-Output ('{0:o} CAPTURE_COMPLETE output={1} release={2}' -f [DateTime]::UtcNow, $outputDirectory, $releaseReceiptPath) +Write-Output ('{0:o} {1}_COMPLETE output={2} release={3}' -f [DateTime]::UtcNow, + $(if ($transitionRehearsal) {'LIVE_TRANSITION_REHEARSAL'} else {'CAPTURE'}), $outputDirectory, $releaseReceiptPath) diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index 193df350..90d1569e 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -11,6 +11,7 @@ $ErrorActionPreference = 'Stop' $root = [IO.Path]::GetFullPath($OwnedRoot).TrimEnd('\') $modulePath = Join-Path $PSScriptRoot 'UiCaptureDriver.psm1' $rehearsal = $false +$transitionRehearsal = $false $rehearsalLaunches = [Collections.Generic.List[object]]::new() $rehearsalActions = [Collections.Generic.List[object]]::new() $peerScript = Join-Path $root 'inputs\offline-peer-source.ps1' @@ -31,6 +32,8 @@ $cleanupFailures = [Collections.Generic.List[string]]::new() $cleanupReceipts = [Collections.Generic.List[object]]::new() $zmxSeedReceipts = [Collections.Generic.List[object]]::new() $images = [Collections.Generic.List[object]]::new() +$checkpoints = [Collections.Generic.List[object]]::new() +$phaseTransitions = [Collections.Generic.List[object]]::new() $runs = [Collections.Generic.List[object]]::new() $seedRuns = [Collections.Generic.List[object]]::new() $applicationRuns = [Collections.Generic.List[object]]::new() @@ -64,6 +67,30 @@ function Write-CapWorkerLog { if ($workerLog) { [IO.File]::AppendAllText($workerLog, $line + [Environment]::NewLine, [Text.UTF8Encoding]::new($false)) } } +function Get-CapWorkerAuthorityId { + if ($auth -and $auth.PSObject.Properties['authorityId'] -and + -not [string]::IsNullOrWhiteSpace([string]$auth.authorityId)) { + return [string]$auth.authorityId + } + if ($auth -and $auth.PSObject.Properties['leaseId'] -and + -not [string]::IsNullOrWhiteSpace([string]$auth.leaseId)) { + return [string]$auth.leaseId + } + throw 'Worker authority identity is missing.' +} + +function ConvertTo-CapWorkerImagePath { + param([Parameter(Mandatory)][string]$Path) + $value=$Path.Trim() + if ($value.StartsWith('\\?\UNC\',[StringComparison]::OrdinalIgnoreCase)) { + $value='\\'+$value.Substring(8) + } elseif ($value.StartsWith('\\?\',[StringComparison]::OrdinalIgnoreCase) -or + $value.StartsWith('\??\',[StringComparison]::OrdinalIgnoreCase)) { + $value=$value.Substring(4) + } + return [IO.Path]::GetFullPath($value) +} + function Test-CapMatchedAdapter { return $job.PSObject.Properties['sceneAdapter'] -and $job.sceneAdapter -cin @('S_MATCHED_V12_V1','S_MATCHED_V12_DPI_V1') @@ -142,6 +169,21 @@ function Write-CapWorkerJournalSnapshot { param([ValidateSet('Bootstrap','Progress','Terminal')][string]$Kind, [string]$Outcome = 'CAPTURING') if ($null -eq $journalState) { throw 'Worker journal was not initialized before publication.' } + $progressManifest=if ($transitionRehearsal) { + [pscustomobject][ordered]@{ + schemaVersion=1L + executionMode='LIVE_TRANSITION_REHEARSAL' + evidenceClassification='LIVE_TRANSITION_REHEARSAL_NOT_CAPTURE_EVIDENCE' + status=$Outcome + checkpointCount=[long]$checkpoints.Count + seedCount=[long]$zmxSeedReceipts.Count + restartCount=[long]$realRestarts.Count + phaseTransitionCount=[long]$phaseTransitions.Count + captures=0L + } + } else { + [pscustomobject](New-CapManifest -Status $Outcome) + } $null = Publish-CapJournalSnapshot -State $journalState -Kind $Kind -Value ([pscustomobject]@{ entries = @($journalEntries.ToArray()) outcome = $Outcome @@ -149,7 +191,7 @@ function Write-CapWorkerJournalSnapshot { ownedProcessSurvivors = @($ownedProcessSurvivors) pendingLaunchesRemaining = @($pendingLaunchesRemaining) cleanupFailures = @($cleanupFailures.ToArray()) - progressManifest = [pscustomobject](New-CapManifest -Status $Outcome) + progressManifest = $progressManifest }) } @@ -216,17 +258,33 @@ function Assert-CapAcceptedInputs { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters Assert-CapSceneContract -Job $job - if ($job.authority.centralCaptureLeaseGrantedByThisArtifact -ne $false -or - $lease.status -ne 'GRANTED' -or $lease.lane -cne $job.lane -or - $lease.authorizedAction -ne 'central-cap-capture' -or - $lease.centralPreflightAccepted -ne $true) { - throw 'A separate coordinator-granted central-cap lease and preflight acceptance are required.' - } - if ($lease.leaseId -ne $auth.leaseId -or $lease.head -ne $job.head -or $lease.jobManifestSha256 -ne $jobHash -or - $lease.moduleSha256 -ne $moduleHash -or $lease.workerSha256 -ne $workerHash -or - $lease.supervisorSha256 -ne $auth.supervisorSha256 -or - $lease.preflightReceiptSha256 -ne $auth.preflightReceiptSha256) { - throw 'Lease receipt hashes do not bind the exact reviewed B job and capture controls.' + if ($transitionRehearsal) { + if (-not $job.authority.liveTransitionRehearsalApproved -or + $job.authority.captureEvidenceAuthorized -or $job.authority.pngAuthorized -or + $lease.status -cne 'AUTHORIZED' -or $lease.authorizedAction -cne 'live-transition-rehearsal' -or + $lease.evidenceClassification -cne 'LIVE_TRANSITION_REHEARSAL_NOT_CAPTURE_EVIDENCE') { + throw 'Live transition rehearsal lacks explicit non-capture authority.' + } + if ($lease.authorizationId -ne (Get-CapWorkerAuthorityId) -or $lease.head -ne $job.head -or + $lease.productSourceHead -ne $job.productSourceHead -or + $lease.jobManifestSha256 -ne $jobHash -or $lease.moduleSha256 -ne $moduleHash -or + $lease.workerSha256 -ne $workerHash -or $lease.supervisorSha256 -ne $auth.supervisorSha256 -or + $lease.preflightReceiptSha256 -ne $auth.preflightReceiptSha256) { + throw 'Rehearsal authorization hashes do not bind the exact job and controls.' + } + } else { + if ($job.authority.centralCaptureLeaseGrantedByThisArtifact -ne $false -or + $lease.status -ne 'GRANTED' -or $lease.lane -cne $job.lane -or + $lease.authorizedAction -ne 'central-cap-capture' -or + $lease.centralPreflightAccepted -ne $true) { + throw 'A separate coordinator-granted central-cap lease and preflight acceptance are required.' + } + if ($lease.leaseId -ne (Get-CapWorkerAuthorityId) -or $lease.head -ne $job.head -or $lease.jobManifestSha256 -ne $jobHash -or + $lease.moduleSha256 -ne $moduleHash -or $lease.workerSha256 -ne $workerHash -or + $lease.supervisorSha256 -ne $auth.supervisorSha256 -or + $lease.preflightReceiptSha256 -ne $auth.preflightReceiptSha256) { + throw 'Lease receipt hashes do not bind the exact reviewed B job and capture controls.' + } } $mappingProof = Assert-CapFixtureMapping -Root $root -FixturePath $fixturePath ` -SourcePath (Join-Path $root 'inputs\fixture-source.json') -MappingPath (Join-Path $root 'reports\fixture-mapping.json') ` @@ -651,6 +709,87 @@ function Measure-CapSceneZoom { return Measure-CapZoom -Element $element -NaturalWidth $NaturalWidth -Dpi $dpi } +function Assert-CapNoPngArtifacts { + $pngs=@(Get-ChildItem -LiteralPath $root -Recurse -File -Filter '*.png' -ErrorAction Stop) + if ($pngs.Count -ne 0) { + throw "Live transition rehearsal refuses PNG creation or retention: $($pngs[0].FullName)" + } +} + +function Save-CapTransitionCheckpoint { + param( + [Parameter(Mandatory)]$AppRun, + [Parameter(Mandatory)][string]$SceneName, + [AllowNull()]$ZoomEvidence, + [Parameter(Mandatory)][string]$Phase, + [Parameter(Mandatory)]$Metrics + ) + Assert-CapNoPngArtifacts + $index=$checkpoints.Count+1 + $actions=if ($AppRun.PSObject.Properties['SActions']) {@($AppRun.SActions)} else {@()} + $record=[pscustomobject][ordered]@{ + schemaVersion=1L + evidenceClassification='LIVE_TRANSITION_REHEARSAL_NOT_CAPTURE_EVIDENCE' + captureMode='NO_IMAGE_CHECKPOINT' + checkpointIndex=[long]$index + phase=$Phase + scene=$SceneName + path=$null + sha256=$null + observedUtc=[DateTime]::UtcNow.ToString('o') + workerDeadlineUtc=$leaseDeadlineUtc.ToString('o',[Globalization.CultureInfo]::InvariantCulture) + remainingMilliseconds=[long][Math]::Floor(($leaseDeadlineUtc-[DateTime]::UtcNow).TotalMilliseconds) + outerBounds=@($Metrics.OuterBounds) + clientBounds=@($Metrics.ClientBounds) + dpi=[long]$Metrics.Dpi + style=[long]$Metrics.Style + exStyle=[long]$Metrics.ExStyle + hasMenu=[bool]$Metrics.HasMenu + zoom=$ZoomEvidence + actions=$actions + observedState=$AppRun.SObservation + sourceHead=if ($Phase -ceq 'before') {$job.binaries.before.source} else {$job.binaries.after.source} + binaryBuiltAtSource=if ($Phase -ceq 'before') {$job.binaries.before.binaryBuiltAtSource} else {$job.binaries.after.binaryBuiltAtSource} + binarySha256=if ($Phase -ceq 'before') {$job.binaries.before.sha256} else {$job.binaries.after.sha256} + fixtureSha256=$fixtureHash + fixtureSourceSha256=$fixtureSourceHash + fixtureMappingSha256=$fixtureMappingHash + } + $directory=Join-Path $outputDirectory 'checkpoints' + $null=New-Item -ItemType Directory -Path $directory -Force + $reference=Write-CapImmutableJson -Root $root -Path (Join-Path $directory ('{0:D2}-{1}-{2}.json' -f $index,$Phase,$SceneName)) -Value $record + $checkpoints.Add([pscustomobject]@{reference=$reference;record=$record}) + if ($AppRun.PSObject.Properties['SActions']) {$AppRun.SActions.Clear()} + Assert-CapNoPngArtifacts + Write-CapWorkerJournalSnapshot -Kind 'Progress' + Write-CapWorkerLog "CHECKPOINT index=$index phase=$Phase scene=$SceneName classification=LIVE_TRANSITION_REHEARSAL_NOT_CAPTURE_EVIDENCE" +} + +function Save-CapPhaseTransition { + Assert-CapNoPngArtifacts + if ($checkpoints.Count -ne 18 -or $zmxSeedReceipts.Count -ne 11 -or $realRestarts.Count -ne 1) { + throw 'Before-to-after transition requires 18 checkpoints, 11 seeds, and one real Restart.' + } + if (@($applicationRuns | Where-Object {-not $_.CleanupComplete}).Count -ne 0 -or + @($seedRuns | Where-Object {-not $_.CleanupComplete}).Count -ne 0) { + throw 'Before-to-after transition requires complete app and seed cleanup.' + } + $record=[pscustomobject][ordered]@{ + schemaVersion=1L + evidenceClassification='LIVE_TRANSITION_REHEARSAL_NOT_CAPTURE_EVIDENCE' + from='before' + to='after' + checkpointCount=18L + seedCount=11L + restartCount=1L + cleanup='BEFORE_PHASE_RELEASED_ZERO_SURVIVORS' + observedUtc=[DateTime]::UtcNow.ToString('o') + } + $reference=Write-CapImmutableJson -Root $root -Path (Join-Path $outputDirectory 'before-to-after-transition.json') -Value $record + $phaseTransitions.Add([pscustomobject]@{reference=$reference;record=$record}) + Write-CapWorkerJournalSnapshot -Kind 'Progress' +} + function Save-CapSceneImage { param( [Parameter(Mandatory)]$AppRun, @@ -681,6 +820,11 @@ function Save-CapSceneImage { if ($ZoomEvidence -and [Math]::Abs([double]$ZoomEvidence.ZoomPercent - 100.0) -gt 1.0) { throw "Zoom evidence changed outside the 100% tolerance for $Phase/$SceneName." } + if ($transitionRehearsal) { + Save-CapTransitionCheckpoint -AppRun $AppRun -SceneName $SceneName -ZoomEvidence $ZoomEvidence ` + -Phase $Phase -Metrics $metrics + return + } $imagePath = Join-Path $outputDirectory "$Phase-$SceneName.png" if (Test-Path -LiteralPath $imagePath) { throw "Refusing to replace prior capture image: $imagePath" } $capture = Capture-CapWindow -Window $AppRun.Window -ProcessIdentity $AppRun.Run.Identity -Path $imagePath @@ -768,9 +912,16 @@ function New-CapManifest { schemaVersion = 1 status = $Status executionMode = if ($mockSceneRun) { 'OFFLINE_MOCK_ONLY' } - elseif ($rehearsal) { 'NO_DESKTOP_REAL_LAUNCH_REHEARSAL' } else { 'LIVE_CAPTURE' } - leaseId = $auth.leaseId - leaseReceiptSha256 = $leaseHash + elseif ($rehearsal) { 'NO_DESKTOP_REAL_LAUNCH_REHEARSAL' } + elseif ($transitionRehearsal) { 'LIVE_TRANSITION_REHEARSAL' } else { 'LIVE_CAPTURE' } + evidenceClassification = if ($transitionRehearsal) { + 'LIVE_TRANSITION_REHEARSAL_NOT_CAPTURE_EVIDENCE' + } elseif (Test-CapMatchedDpiAdapter) { 'C8R_RDP_DPI192_QUALIFIED' } else { $null } + authorityId = Get-CapWorkerAuthorityId + authoritySha256 = if ($auth.PSObject.Properties['authoritySha256']) {$auth.authoritySha256} else {$leaseHash} + leaseId = if ($transitionRehearsal) { $null } else { $auth.leaseId } + leaseReceiptSha256 = if ($transitionRehearsal) { $null } else { $leaseHash } + rehearsalAuthorizationSha256 = if ($transitionRehearsal) { $authorityHash } else { $null } jobManifestSha256 = $jobHash moduleSha256 = $moduleHash workerSha256 = $workerHash @@ -784,20 +935,27 @@ function New-CapManifest { guiLaunches = @($applicationRuns | Where-Object { -not $rehearsal } | ForEach-Object { $_.Run.Identity.Pid }) rehearsalLaunches = @($rehearsalLaunches) rehearsalActions = @($rehearsalActions) - guiOperations = if ($rehearsal) { 'NotExecuted: UIA, HWND, PrintWindow, native input and desktop capture' } else { 'See images' } + guiOperations = if ($rehearsal) { 'NotExecuted: UIA, HWND, PrintWindow, native input and desktop capture' } + elseif ($transitionRehearsal) { 'Real UIA transitions executed; image APIs prohibited' } else { 'See images' } fixtureSha256 = $fixtureHash fixtureSourceSha256 = $fixtureSourceHash fixtureMappingSha256 = $fixtureMappingHash zmxSeedExitCodes = @($zmxSeedReceipts | ForEach-Object exitCode) zmxSeeds = @($zmxSeedReceipts) images = @($images) + captures = if ($transitionRehearsal) { 0L } else { [long]$images.Count } + checkpoints = @($checkpoints) + checkpointCount = [long]$checkpoints.Count + phaseTransitions = @($phaseTransitions) cleanup = @($cleanupReceipts) cleanupFailures = @($cleanupFailures) pendingLaunchesRemaining = @($pendingLaunchesRemaining) failures = @($cleanupFailures) ownedProcessSurvivors = @($ownedProcessSurvivors) ownedProcesses = @($ownedProcessReceipts) - privacyRequirement = 'Every image requires an individual privacy check before reviewer handoff.' + privacyRequirement = if ($transitionRehearsal) { + 'No images exist; this rehearsal is not capture evidence and must not enter capture reports or ledgers.' + } else { 'Every image requires an individual privacy check before reviewer handoff.' } } if ($job.PSObject.Properties['sceneAdapter'] -and $job.sceneAdapter -ceq 'D_QUICK_CHAT_V1') { $result.sceneAdapter=$job.sceneAdapter;$result.lane='D';$result.requiredSceneNames=@(Get-CapDSceneNames) @@ -810,7 +968,7 @@ function New-CapManifest { $result.sceneAdapter=$job.sceneAdapter;$result.lane='S';$result.requiredSceneNames=@(Get-CapMatchedSceneNames) $result.g11Qualification='ASSESSABLE_SOURCE_CAPABILITY_NOT_RUNTIME_VALIDATED' $result.backendTurns=0;$result.realRestarts=@($realRestarts) - if (Test-CapMatchedDpiAdapter) { + if ((Test-CapMatchedDpiAdapter) -and -not $transitionRehearsal) { $result.evidenceClassification='C8R_RDP_DPI192_QUALIFIED' $result.geometrySchemaVersion=3L $result.logicalClientWH=@($job.captureContract.logicalClientWH) @@ -1669,7 +1827,7 @@ function Get-CapMatchedAttach { $tree=@(Get-CapTreeForApp -AppRun $AppRun) $session="graphcode-$NodeId" $found=@(foreach ($candidate in $tree) { - if ($candidate.ExpectedImagePath -ine (ConvertTo-CapImagePath (Join-Path $root 'runtime\zmx.exe'))) {continue} + if ($candidate.ExpectedImagePath -ine (ConvertTo-CapWorkerImagePath (Join-Path $root 'runtime\zmx.exe'))) {continue} $verified=Assert-CapProcessIdentity -Identity $candidate if ($verified.State -ceq 'Exited') {continue} $arguments=@([CapNative]::CommandArguments($verified.Metadata.CommandLine)) @@ -1696,33 +1854,36 @@ function Get-CapMatchedRestartEvidence { }) if ($restartRows.Count -eq 0) {return $null} if ($restartRows.Count -ne 1) {throw 'Restart evidence: duplicate real restart requests'} + $restartConnection=[long]$restartRows[0].connection + $sameConnectionRows=@($rows | Where-Object connection -EQ $restartConnection) + if ($sameConnectionRows.Count -eq 0) {throw 'Restart evidence: restart connection transcript is empty'} $daemonSnapshot=[CapNative]::Snapshot($Context.daemonProcess.Handle,$Context.daemonProcess.Id) if ($daemonSnapshot.IsExited -or $daemonSnapshot.CreationFileTimeUtc -ne $Context.daemonTarget.creationFileTimeUtc -or -not [CapProcessJob]::Contains($Context.daemonRun.Job,$Context.daemonProcess.Handle)) {throw 'Restart evidence: held daemon generation changed'} $after=Get-CapMatchedAttach -AppRun $AppRun -NodeId $Context.nodeId if ($null -eq $after -or -not [CapNative]::IsExited($Context.oldAttach.Process.Handle,$Context.oldAttach.Pid)) {return $null} $members=@(Get-CapJobMembers -Run $Context.daemonRun) - $backend=@($members | Where-Object ExpectedImagePath -IEQ (ConvertTo-CapImagePath (Join-Path $root 'runtime\copilot.exe'))) - $zmx=@($members | Where-Object ExpectedImagePath -IEQ (ConvertTo-CapImagePath (Join-Path $root 'runtime\zmx.exe'))) + $backend=@($members | Where-Object ExpectedImagePath -IEQ (ConvertTo-CapWorkerImagePath (Join-Path $root 'runtime\copilot.exe'))) + $zmx=@($members | Where-Object ExpectedImagePath -IEQ (ConvertTo-CapWorkerImagePath (Join-Path $root 'runtime\zmx.exe'))) if ($backend.Count -ne 1 -or $zmx.Count -lt 1) {return $null} if ($backend[0].CreationFileTimeUtc -le $Context.oldBackend.CreationFileTimeUtc -or -not [CapNative]::IsExited($Context.oldBackend.Process.Handle,$Context.oldBackend.Pid)) {return $null} $panes=@(Read-CapMatchedPanes -AppRun $AppRun -ExpectedCount 1) $evidence=[pscustomobject]@{ - projectPath=$Context.projectPath;nodeId=$Context.nodeId;connection=[long]$restartRows[0].connection - counter=[long]$Context.counter;rows=$rows + projectPath=$Context.projectPath;nodeId=$Context.nodeId;connection=$restartConnection + counter=[long]$Context.counter;rows=$sameConnectionRows daemon=[pscustomobject]@{pid=[long]$daemonSnapshot.ProcessId;creationFileTimeUtc=[long]$daemonSnapshot.CreationFileTimeUtc held=$true;jobOwned=$true;imageSha256=(Get-FileHash (Join-Path $root 'runtime\graphcoded.exe') -Algorithm SHA256).Hash} beforeAttach=[pscustomobject]@{pid=[long]$Context.oldAttach.Pid;creationFileTimeUtc=[long]$Context.oldAttach.CreationFileTimeUtc session="graphcode-$($Context.nodeId)";retired=$true} afterAttach=[pscustomobject]@{pid=[long]$after.Pid;creationFileTimeUtc=[long]$after.CreationFileTimeUtc session="graphcode-$($Context.nodeId)";held=$true;jobOwned=[CapProcessJob]::Contains($AppRun.OwnedJob.Job,$after.Process.Handle) - daemonSessionReady=$true;uiaPaneOwned=($panes.Count -eq 1);observedAfterOrdinal=[long]$rows[-1].ordinal + daemonSessionReady=$true;uiaPaneOwned=($panes.Count -eq 1);observedAfterOrdinal=[long]$sameConnectionRows[-1].ordinal panes=$panes;backendPid=[long]$backend[0].Pid;backendCreationFileTimeUtc=[long]$backend[0].CreationFileTimeUtc oldBackendPid=[long]$Context.oldBackend.Pid;oldBackendCreationFileTimeUtc=[long]$Context.oldBackend.CreationFileTimeUtc oldBackendExited=$true;appJobId=$AppRun.OwnedJob.JobId} } - $resumeReplies=@($rows | Where-Object {$_.direction -ceq 'daemon' -and $_.frame.kind -ceq 'response'}) + $resumeReplies=@($sameConnectionRows | Where-Object {$_.direction -ceq 'daemon' -and $_.frame.kind -ceq 'response'}) if ($resumeReplies.Count -lt 1) {return $null} try {$null=Assert-CapRestartEvidence -Evidence $evidence -ExpectedDaemonSha256 $job.binaries.daemon.sha256} catch { @@ -1790,6 +1951,7 @@ function Initialize-CapMatchedRestartStore { $wirePath=$project.Replace('\','/') $graph=$Fixture.graphs[0] | ConvertTo-Json -Depth 30 | ConvertFrom-Json -DateKind String $graph.project.path=$wirePath;$graph.nodes=@($node);$graph.edges=@() + $graph.nodes[0].state=[pscustomobject]@{idle=[pscustomobject]@{}} $projects=Join-Path $Roots.Support 'projects' $null=New-Item -ItemType Directory -Path $projects $key='v1-'+[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($wirePath))).ToLowerInvariant() @@ -1841,7 +2003,7 @@ function Invoke-CapMatchedRestartCapture { for ($i=0;$i -lt 100;$i++) { $old=Get-CapMatchedAttach -AppRun $app -NodeId $node.id $members=@(Get-CapJobMembers -Run $daemon) - $oldBackend=@($members | Where-Object ExpectedImagePath -IEQ (ConvertTo-CapImagePath (Join-Path $root 'runtime\copilot.exe'))) + $oldBackend=@($members | Where-Object ExpectedImagePath -IEQ (ConvertTo-CapWorkerImagePath (Join-Path $root 'runtime\copilot.exe'))) if ($old -and $oldBackend.Count -eq 1) {break} Assert-CapWorkerDeadline 'initial daemon-owned attach';Start-Sleep -Milliseconds 100 } @@ -2023,10 +2185,12 @@ try { throw 'Supervisor run authorization hash mismatch.' } $auth = Get-Content -LiteralPath $AuthorizationPath -Raw | ConvertFrom-Json -DateKind String - if ($auth.ownedRoot -ine $root -or $auth.executionMode -notin @('LIVE_CAPTURE', 'NO_DESKTOP_REAL_LAUNCH_REHEARSAL')) { + if ($auth.ownedRoot -ine $root -or + $auth.executionMode -notin @('LIVE_CAPTURE', 'NO_DESKTOP_REAL_LAUNCH_REHEARSAL', 'LIVE_TRANSITION_REHEARSAL')) { throw 'Worker root or execution mode differs from supervisor authorization.' } $rehearsal = $auth.executionMode -ceq 'NO_DESKTOP_REAL_LAUNCH_REHEARSAL' + $transitionRehearsal = $auth.executionMode -ceq 'LIVE_TRANSITION_REHEARSAL' # Hash the exact source bytes before importing or compiling the native declarations. $moduleHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $modulePath).Hash $workerHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $PSCommandPath).Hash @@ -2045,7 +2209,7 @@ try { Set-CapTracePath -Path (Join-Path $safeWorkerOutput 'worker-calls.jsonl') Write-CapTrace -Command 'Invoke-UiCaptureWorker' -Parameters $PSBoundParameters $jobPath = Assert-CapOwnedPath $auth.jobPath - $leasePath = [IO.Path]::GetFullPath($auth.leaseReceiptPath) + $leasePath = [IO.Path]::GetFullPath($auth.authorityPath) $runDirectory = Assert-CapOwnedPath $auth.runDirectory $outputDirectory = Assert-CapOwnedPath $auth.outputDirectory $journalPath = Assert-CapOwnedPath $auth.processJournalPath @@ -2061,9 +2225,10 @@ try { $preflightHash = if ($rehearsal) { $null } else { (Get-FileHash -Algorithm SHA256 -LiteralPath $auth.preflightReceiptPath).Hash } $leaseHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $leasePath).Hash + $authorityHash = $leaseHash if ($moduleHash -ne $auth.moduleSha256 -or $workerHash -ne $auth.workerSha256 -or $supervisorHash -ne $auth.supervisorSha256 -or $preflightHash -ne $auth.preflightReceiptSha256 -or - $leaseHash -ne $auth.leaseReceiptSha256) { + $leaseHash -ne $auth.authoritySha256) { throw 'A reviewed capture control or authorization input changed after supervisor launch.' } if ($AuthorizationPath -ine $auth.authorizationPath) { @@ -2071,13 +2236,15 @@ try { } $job = Read-CapBoundJson -Root $root -Reference ([pscustomobject]@{ path = $jobPath; sha256 = $jobHash }) $lease = Read-CapBoundJson -Root $root -Reference ([pscustomobject]@{ path = $leasePath; sha256 = $leaseHash }) - if (-not $rehearsal -and $lease.fixtureMappingSha256 -ne $auth.fixtureMappingSha256) { + if (-not $rehearsal -and -not $transitionRehearsal -and + $lease.fixtureMappingSha256 -ne $auth.fixtureMappingSha256) { throw 'Worker lease does not bind its exact owned-root fixture mapping.' } - if ($lease.leaseId -ne $auth.leaseId -or $lease.jobManifestSha256 -ne $jobHash -or + $authorityId = if ($transitionRehearsal) {$lease.authorizationId} else {$lease.leaseId} + if ($authorityId -ne (Get-CapWorkerAuthorityId) -or $lease.jobManifestSha256 -ne $jobHash -or $lease.moduleSha256 -ne $moduleHash -or $lease.workerSha256 -ne $workerHash -or $lease.supervisorSha256 -ne $supervisorHash) { - throw 'Worker lease no longer binds the authorized job and controls.' + throw 'Worker authority no longer binds the authorized job and controls.' } $leaseWindow = Get-CapLeaseWindow -GrantedUtc $lease.grantedUtc -ExpiresUtc $lease.expiresUtc ` -MaximumMinutes ([double]$lease.maxMinutes) -NowUtc ([DateTime]::UtcNow) @@ -2101,12 +2268,12 @@ try { throw 'Worker journal/final manifest paths differ from the numbered protocol authorization.' } $journalBinding = New-CapJournalBinding -RunId ([IO.Path]::GetFileName($runDirectory)) ` - -LeaseId $auth.leaseId -JobSha256 $jobHash -ModuleSha256 $moduleHash ` + -LeaseId (Get-CapWorkerAuthorityId) -JobSha256 $jobHash -ModuleSha256 $moduleHash ` -WorkerSha256 $workerHash -SupervisorSha256 $supervisorHash -Identity $selfIdentity $journalState = New-CapJournalState -Root $root -Directory $journalPath ` -Binding $journalBinding -DeadlineUtc $leaseExpiresUtc Write-CapWorkerJournalSnapshot -Kind 'Bootstrap' - if (-not $rehearsal -or $null -ne $job.PSObject.Properties['admission']) { + if (-not $transitionRehearsal -and (-not $rehearsal -or $null -ne $job.PSObject.Properties['admission'])) { $identity = Assert-CapJobIdentity -Root $root -Job $job -Lease $lease -JobSha256 $jobHash -OfflineTestData:$rehearsal if (($identity | ConvertTo-Json -Depth 15 -Compress) -cne ($auth.jobIdentity | ConvertTo-Json -Depth 15 -Compress)) { throw 'Worker recomputed job identity differs from supervisor authorization.' @@ -2130,6 +2297,24 @@ try { -OriginalRoot $job.fixture.originalRoot -SceneAdapter $job.sceneAdapter -ExpectedPeerSha256 $job.fixture.offlinePeerReference.sha256 $fixtureHash=$proof.FixtureSha256 } + } elseif ($transitionRehearsal) { + if ($lease.status -cne 'AUTHORIZED' -or + $lease.authorizedAction -cne 'live-transition-rehearsal' -or + $lease.evidenceClassification -cne 'LIVE_TRANSITION_REHEARSAL_NOT_CAPTURE_EVIDENCE' -or + -not $lease.desktopAuthorized -or $lease.captureEvidenceAuthorized -or $lease.pngAuthorized -or + $job.executionMode -cne $auth.executionMode -or -not $job.desktopAuthorized -or + $job.evidenceClassification -cne 'LIVE_TRANSITION_REHEARSAL_NOT_CAPTURE_EVIDENCE') { + throw 'Live transition rehearsal authority could be mistaken for capture authorization.' + } + Assert-CapAcceptedInputs + $zmxHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $root 'runtime\zmx.exe')).Hash + $beforeHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $root 'inputs\graphcode-before.exe')).Hash + $afterHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $root 'inputs\graphcode-after.exe')).Hash + if ($zmxHash -ne $job.binaries.offlineZmx.sha256 -or + $beforeHash -ne $job.binaries.before.sha256 -or $afterHash -ne $job.binaries.after.sha256) { + throw 'Live transition rehearsal executable hashes differ from the frozen job.' + } + $fixtureHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $fixturePath).Hash } else { Assert-CapAcceptedInputs $zmxHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $root 'runtime\zmx.exe')).Hash @@ -2146,7 +2331,8 @@ try { } New-Item -ItemType Directory -Path (Split-Path -Parent $manifestPath) -Force | Out-Null [IO.File]::WriteAllText($workerLog, '', [Text.UTF8Encoding]::new($false)) - Write-CapWorkerLog "WORKER_STARTED lease=$($auth.leaseId) head=$($job.head) job=$jobHash deadline=$($leaseDeadlineUtc.ToString('o', [Globalization.CultureInfo]::InvariantCulture))" + if ($transitionRehearsal) { Assert-CapNoPngArtifacts } + Write-CapWorkerLog "WORKER_STARTED authority=$(Get-CapWorkerAuthorityId) mode=$($auth.executionMode) head=$($job.head) job=$jobHash deadline=$($leaseDeadlineUtc.ToString('o', [Globalization.CultureInfo]::InvariantCulture))" if ($rehearsal) { Invoke-CapRealSeedRehearsal Invoke-CapRealLaunchRehearsal @@ -2162,13 +2348,20 @@ try { } foreach ($phase in @('before', 'after')) { Assert-CapWorkerDeadline "starting phase $phase" + if ($transitionRehearsal -and $phase -ceq 'after') { Save-CapPhaseTransition } if (Test-CapMatchedAdapter) {Invoke-CapMatchedScenes -Phase $phase} elseif ($job.sceneAdapter -ceq 'D_QUICK_CHAT_V1') {Invoke-CapDScenes -Phase $phase} else {Invoke-CapNormalScenes -Phase $phase;Invoke-CapActionsScene -Phase $phase} } - $expectedImages=if (Test-CapMatchedAdapter) {36} elseif ($job.sceneAdapter -ceq 'D_QUICK_CHAT_V1') {20} else {10} + $expectedImages=if ($transitionRehearsal) {0} elseif (Test-CapMatchedAdapter) {36} elseif ($job.sceneAdapter -ceq 'D_QUICK_CHAT_V1') {20} else {10} $expectedSeeds=if (Test-CapMatchedAdapter) {22} elseif ($job.sceneAdapter -ceq 'D_QUICK_CHAT_V1') {4} else {2} if ($images.Count -ne $expectedImages) { throw "Expected $expectedImages images across before/after; captured $($images.Count)." } + if ($transitionRehearsal) { + Assert-CapNoPngArtifacts + if ($checkpoints.Count -ne 36 -or $phaseTransitions.Count -ne 1 -or $realRestarts.Count -ne 2) { + throw 'Live transition rehearsal requires 36 checkpoints, one before-to-after transition, and two real Restarts.' + } + } if ($zmxSeedReceipts.Count -ne $expectedSeeds -or @($zmxSeedReceipts | Where-Object { $_.exitCode -ne 0 -or -not $_.daemonReady -or $_.cmdCount -lt 1 -or $_.cleanupStatus -ne 'CLEAN_ZERO_SURVIVORS_FILES_CLOSED' -or @@ -2246,7 +2439,10 @@ try { if ($manifestPath -and $job -and $auth -and $journalState) { $finalStatus = if (-not $primaryFailure -and $cleanupFailures.Count -eq 0 -and (($rehearsal -and $rehearsalLaunches.Count -eq 1 -and $images.Count -eq 0) -or - (-not $rehearsal -and $images.Count -eq $(if (Test-CapMatchedAdapter) {36} else {10})))) { + ($transitionRehearsal -and $images.Count -eq 0 -and $checkpoints.Count -eq 36 -and + $zmxSeedReceipts.Count -eq 22 -and $realRestarts.Count -eq 2 -and $phaseTransitions.Count -eq 1) -or + (-not $rehearsal -and -not $transitionRehearsal -and + $images.Count -eq $(if (Test-CapMatchedAdapter) {36} else {10})))) { 'COMPLETE' } else { 'FAILED' } try { @@ -2268,7 +2464,7 @@ try { if ($journalFinalLine) { if (-not $primaryFailure -and $cleanupFailures.Count -eq 0) { - try { Write-CapWorkerLog "WORKER_COMPLETE images=$($images.Count) lease=$($auth.leaseId)" } + try { Write-CapWorkerLog "WORKER_COMPLETE images=$($images.Count) checkpoints=$($checkpoints.Count) authority=$(Get-CapWorkerAuthorityId)" } catch { $cleanupFailures.Add("Completion logging failed: $($_.Exception.Message)") } } foreach ($run in $runs) { diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 5a185b6f..26fa8aa4 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -22,6 +22,7 @@ param( [switch]$AdvisoryOlderContractOnly, [switch]$DAdapterContractOnly, [switch]$MatchedScenarioContractOnly, + [switch]$LiveTransitionRehearsalContractOnly, [switch]$MatchedDpiContractOnly, [string]$Schema3JobPath = '', [switch]$SeedPathContractOnly, @@ -399,6 +400,7 @@ function Assert-CapRejects { if ($InvalidJobContract) { $manifest | Add-Member -NotePropertyMembers @{ leaseId = $context.Binding.leaseId; leaseReceiptSha256 = ('B' * 64) + authorityId = $context.Binding.leaseId; authoritySha256 = ('B' * 64) jobManifestSha256 = $context.Binding.jobSha256 moduleSha256 = $context.Binding.moduleSha256 workerSha256 = $context.Binding.workerSha256 @@ -646,6 +648,7 @@ function Assert-CapRejects { $journalProof = Read-CapJournalFinalProof $fixture.Context.State $fixture.Context.Root $fixture.Record $boundWorkerManifest = $journalProof.Manifest $rehearsal = $true + $transitionRehearsal = $false $ExecutionMode = 'NO_DESKTOP_REAL_LAUNCH_REHEARSAL' $ast = [Management.Automation.Language.Parser]::ParseInput( [IO.File]::ReadAllText($supervisorPath), [ref]$null, [ref]$null) @@ -2975,6 +2978,10 @@ function Invoke-CapMatchedContracts { event=$responseGraph.rows[3].frame.event} $responseGraph.rows[5].frame.requestID=$responseGraph.rows[5].frame.requestID.ToUpperInvariant() $null=Assert-CapRestartEvidence -Evidence $responseGraph -ExpectedDaemonSha256 ('e'*64) + $wrappedGraph=New-CapRestartTestEvidence + $wrappedGraph.rows[0].frame.event.graphChanged=[pscustomobject]@{_0=$wrappedGraph.rows[0].frame.event.graphChanged} + $wrappedGraph.rows[3].frame.event.nodesChanged.nodes[0].state=[pscustomobject]@{idle=[pscustomobject]@{}} + $null=Assert-CapRestartEvidence -Evidence $wrappedGraph -ExpectedDaemonSha256 ('E'*64) $cases=@('ack-only','wrong-owner','connection','generation','duplicate','unrelated-counter','error','timeout', 'resume-error','no-resume','old-attach','unowned-attach','no-daemon-session','no-pane','stop','early-resume','counter-type', 'old-backend','untyped-ownership') @@ -3003,7 +3010,7 @@ function Invoke-CapMatchedContracts { } $null=Assert-CapRejects {Assert-CapRestartEvidence -Evidence $e -ExpectedDaemonSha256 ('E'*64)} 'Restart evidence' } - return @{positive=2;negative=$cases.Count;mutations=$cases;evidence='SIMULATED_TRANSCRIPT_NOT_DAEMON_EXECUTION'} + return @{positive=3;negative=$cases.Count;mutations=$cases;evidence='SIMULATED_TRANSCRIPT_NOT_DAEMON_EXECUTION'} } Invoke-CapTest 'real restart scene invokes existing UIA route and refuses offline peer authority' { return @{route=(Invoke-CapMatchedRestartRouteContract);pending=(Invoke-CapMatchedPendingContract)} @@ -3012,7 +3019,10 @@ function Invoke-CapMatchedContracts { Invoke-CapMatchedProxyContract } Invoke-CapTest 'matched manifest seals scene inventory and refuses premature restart success' { - Invoke-CapMatchedManifestContract + return @{ + capture = Invoke-CapMatchedManifestContract + liveTransitionRehearsal = Invoke-CapLiveTransitionRehearsalManifestContract + } } } @@ -4142,13 +4152,79 @@ function Invoke-CapMatchedPersistenceContract { $recent.Count -eq 1 -and $recent[0].lastOpenedAt -eq $graph.project.lastOpenedAt -and $recent[0].path -ceq $graph.project.path -and $recent[0].name -ceq $graph.project.name -and $graph.nodes.Count -eq 1 -and $graph.nodes[0].id -ceq '11111111-1111-4111-8111-000000000001' -and - $graph.edges.Count -eq 0) 'Actual restart persistence lost required ProjectRef fields or isolated node ownership.' + $graph.nodes[0].state -is [pscustomobject] -and + $null -ne $graph.nodes[0].state.PSObject.Properties['idle'] -and + $graph.edges.Count -eq 0) 'Actual restart persistence is not a real Swift LoopGraph with the isolated Restart node.' return @{graphPath=$store.graphPath;recentPath=$store.recentPath;projects=1;nodes=1 lastOpenedAt=$graph.project.lastOpenedAt;dateEncoding='SWIFT_REFERENCE_SECONDS' evidence='ACTUAL_WORKER_PERSISTENCE_FILE_IO_NOT_DAEMON_EXECUTION'} } } +function Invoke-CapLiveTransitionRehearsalManifestContract { + $workerSource=[IO.File]::ReadAllText($workerPath) + $supervisorSource=[IO.File]::ReadAllText($supervisorPath) + foreach ($source in @($workerSource,$supervisorSource)) { + Assert-CapTest ($source.Contains('LIVE_TRANSITION_REHEARSAL')) 'Live transition rehearsal execution mode is missing.' + Assert-CapTest ($source.Contains('LIVE_TRANSITION_REHEARSAL_NOT_CAPTURE_EVIDENCE')) 'Live transition evidence refusal classification is missing.' + } + Assert-CapTest ($workerSource.Contains('Save-CapTransitionCheckpoint')) 'Worker has no typed transition checkpoint writer.' + Assert-CapTest ($workerSource.Contains('Assert-CapNoPngArtifacts')) 'Worker does not refuse PNG creation or retention.' + Assert-CapTest ($workerSource.Contains('function ConvertTo-CapWorkerImagePath') -and + -not $workerSource.Contains('ConvertTo-CapImagePath (')) 'Restart path normalization still depends on a module-private helper.' + Assert-CapTest ($workerSource.Contains('$sameConnectionRows=@($rows | Where-Object connection -EQ $restartConnection)') -and + $supervisorSource.Contains('$connectionRows=@($rows | Where-Object connection -EQ $restart.evidence.connection)')) ` + 'Restart evidence does not isolate and rebind the exact Restart connection.' + $moduleSource=[IO.File]::ReadAllText($modulePath) + Assert-CapTest ($moduleSource.Contains('function Get-CapJobMemberProcess') -and + $moduleSource.Contains('after bounded recheck') -and + $moduleSource.Contains('for ($attempt=0;$attempt -lt 8;$attempt++)')) ` + 'Job census does not bound the listed-but-unavailable member race.' + Assert-CapTest ($moduleSource.Contains('($process.parentPid -le 0 -and -not $fileOutput)')) ` + 'Final journal still treats contained-job members as dependent on advisory parent metadata.' + Assert-CapTest ($workerSource.Contains('checkpointCount') -and + $workerSource.Contains('captures = if ($transitionRehearsal) { 0L }')) 'Worker manifest does not seal checkpoint and zero-capture counts.' + Assert-CapTest ($workerSource.Contains('$progressManifest=if ($transitionRehearsal)') -and + $workerSource.Contains('phaseTransitionCount=[long]$phaseTransitions.Count') -and + $workerSource.Contains('$transitionRehearsal -and $images.Count -eq 0 -and $checkpoints.Count -eq 36')) ` + 'Transition journal progress or final completion still depends on the full image manifest.' + Assert-CapTest ($supervisorSource.Contains('RehearsalAuthorizationPath') -and + $supervisorSource.Contains('RehearsalAuthorizationSha256')) 'Supervisor still requires a capture lease for live transition rehearsal.' + Assert-CapTest ($supervisorSource.Contains('Assert-CapDataRecord $checkpoint.reference @{name=[string];length=[long];sha256=[string]}') -and + $supervisorSource.Contains("Join-Path (Join-Path `$outputDirectory 'checkpoints') `$checkpoint.reference.name")) ` + 'Supervisor does not bind immutable checkpoint names, lengths, and hashes to the authorized directory.' + Assert-CapTest ($supervisorSource.Contains('function Invoke-CapTransitionGit') -and + -not $supervisorSource.Contains('Invoke-CapAdmissionGit $repository')) 'Live transition admission depends on a module-private Git helper.' + $job=New-CapMatchedDpiTestJob + $manifest=[pscustomobject]@{ + sceneAdapter=$job.sceneAdapter;lane='S';status='COMPLETE' + executionMode='LIVE_TRANSITION_REHEARSAL' + evidenceClassification='LIVE_TRANSITION_REHEARSAL_NOT_CAPTURE_EVIDENCE' + images=@();realRestarts=@();g11Qualification='ASSESSABLE_SOURCE_CAPABILITY_NOT_RUNTIME_VALIDATED' + backendTurns=0 + } + $reportRefusal=Assert-CapRejects {Assert-CapMatchedManifest -Manifest $manifest -Job $job} 'Matched manifest' + return @{ + classification='LIVE_TRANSITION_REHEARSAL_NOT_CAPTURE_EVIDENCE' + checkpoints=36 + seeds=22 + restarts=2 + captures=0 + captureReportRefusal=$reportRefusal + evidence='SOURCE_CONTRACT_ONLY_NO_DESKTOP' + } +} + +function Invoke-CapLiveTransitionRehearsalContracts { + Invoke-CapTest 'matched v12 fixture binds exact node type wiring reason and outcome fields' { + $fixture=Get-Content (Join-Path $driverDirectory 'fixtures\matched-v12.json') -Raw | ConvertFrom-Json -DateKind String + return @{persistence=(Invoke-CapMatchedPersistenceContract);nodes=$fixture.graphs[0].nodes.Count} + } + Invoke-CapTest 'matched manifest seals scene inventory and refuses premature restart success' { + Invoke-CapLiveTransitionRehearsalManifestContract + } +} + function Invoke-CapMatchedPendingContract { & { $caseRoot=Join-Path $scratch 'matched-pending-transcript' @@ -4659,7 +4735,18 @@ function Invoke-CapReceiptReadControl { if ($null -ne $PublishValue) { $temporary = "$Path.publish" [IO.File]::WriteAllText($temporary, (ConvertTo-Json $PublishValue -Compress), [Text.UTF8Encoding]::new($false)) - [IO.File]::Move($temporary, $Path, $true) + $publishDeadline=[DateTime]::UtcNow.AddSeconds(2) + while ($true) { + try {[IO.File]::Move($temporary,$Path,$true);break} + catch [UnauthorizedAccessException] { + if ([DateTime]::UtcNow -ge $publishDeadline) {throw} + Start-Sleep -Milliseconds 25 + } + catch [IO.IOException] { + if ([DateTime]::UtcNow -ge $publishDeadline) {throw} + Start-Sleep -Milliseconds 25 + } + } } if (-not $pending.AsyncWaitHandle.WaitOne(5000)) { throw 'RECEIPT_HARNESS_MECHANICAL: production reader exceeded its bounded deadline.' @@ -6870,6 +6957,19 @@ if ($MatchedScenarioContractOnly) { exit 0 } +if ($LiveTransitionRehearsalContractOnly) { + Invoke-CapLiveTransitionRehearsalContracts + $passed=@($results | Where-Object passed -EQ $true).Count + $failed=@($results | Where-Object passed -EQ $false).Count + $report=[pscustomobject][ordered]@{suite='UiCaptureLiveTransitionRehearsal';executed=$results.Count + passed=$passed;failed=$failed;skipped=0;gui='NotExecuted';images=0;cases=@($results) + } + $report | ConvertTo-Json -Depth 25 | Set-Content -LiteralPath (Join-Path $scratch 'live-transition-rehearsal-report.json') -Encoding utf8NoBOM + $report | Select-Object suite,executed,passed,failed | ConvertTo-Json -Compress + if ($results.Count -ne 2 -or $passed -ne 2 -or $failed -ne 0) {exit 1} + exit 0 +} + if ($MatchedDpiContractOnly) { Invoke-CapMatchedDpiContracts $passed=@($results | Where-Object passed -EQ $true).Count @@ -7606,7 +7706,8 @@ try { . ([scriptblock]::Create($function.Extent.Text)) $preflight = [ordered]@{ suite = 'UiCaptureDriver'; executed = $results.Count; passed = $results.Count; failed = 0 - mode = $mode; gui = 'NotExecuted'; images = 0; backendTurns = 0; fixture = $target + mode = $mode; gui = 'NotExecuted'; images = 0; backendTurns = 0 + fixture = [pscustomobject]@{ compilerExitCode = 0L } controls = [pscustomobject]@{ moduleSha256 = $sourceHashesBefore[$modulePath] workerSha256 = $sourceHashesBefore[$workerPath]; supervisorSha256 = $sourceHashesBefore[$supervisorPath] } cases = @($results) diff --git a/Tools/windows/UI-CAPTURE-SCENARIOS.md b/Tools/windows/UI-CAPTURE-SCENARIOS.md index f111e69a..6d8f99f8 100644 --- a/Tools/windows/UI-CAPTURE-SCENARIOS.md +++ b/Tools/windows/UI-CAPTURE-SCENARIOS.md @@ -46,6 +46,28 @@ 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. +## Live transition rehearsal + +`LIVE_TRANSITION_REHEARSAL` is a no-lease, non-evidence mode for exercising the +complete real matched transition sequence before one separately authorized +capture. It requires a hash-bound rehearsal authorization with +`authorizedAction=live-transition-rehearsal`, +`captureEvidenceAuthorized=false`, and `pngAuthorized=false`. It still consumes +the exact 185-case preflight and uses the real UIA, pinned zmx, daemon, process +identity, deadline, numbered-journal, and zero-survivor release paths. + +The worker replaces each image operation with an immutable typed checkpoint. A +successful matched run requires exactly **36 checkpoints**, **22 cmd-only +seeds**, **two real Restarts**, one proven `before` to `after` cleanup +transition, `images=[]`, `captures=0`, and no `.png` anywhere below the fresh +owned root. Every checkpoint and final manifest is classified +`LIVE_TRANSITION_REHEARSAL_NOT_CAPTURE_EVIDENCE`. + +This mode is refused by the matched capture-manifest gate and must not enter a +capture report, privacy review, parity ledger, or evidence publication. It can +identify tooling and observer defects only; passing it does not authorize +capture or establish parity. + Every scene action and its associated visibility probe now waits for at most **10 seconds** and must stop at least **30 seconds before the worker deadline**, whichever comes first. The calculation holds one additional second ahead of @@ -337,6 +359,8 @@ observed UI state, not a `selected` property invented on the daemon's node. The adapter requests eighteen scenes for each before/after phase: **36 original images**, **22 cmd-only seed lifecycles**, and **two real-daemon Restart scenes**. +The live transition rehearsal executes the same order and transitions but emits +36 non-image checkpoints instead of those images. Every static workspace uses its own profile, zmx state, layout and contained app job. Card scenes share no live backend. Actual tab and pane ownership, selected state, bounds and focus are read before capture; the terminal text/glyphs are diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index 3abdb7ee..3cd06da3 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -780,6 +780,19 @@ function Wait-CapJobTarget { return $record } +function Get-CapJobMemberProcess { + param($Run,[int]$MemberPid) + for ($attempt=0;$attempt -lt 8;$attempt++) { + try { return [Diagnostics.Process]::GetProcessById($MemberPid) } + catch [ArgumentException] { + if ($MemberPid -notin [CapProcessJob]::Members($Run.Job)) {return $null} + if ($attempt -eq 7) {throw 'Job member identity unavailable while still listed after bounded recheck.'} + Start-Sleep -Milliseconds 25 + } + } + throw 'Unreachable job-member process retry state.' +} + function Get-CapJobMembers { [CmdletBinding()] param($Run) @@ -787,11 +800,8 @@ function Get-CapJobMembers { foreach ($memberPid in [CapProcessJob]::Members($Run.Job)) { if ($memberPid -eq $Run.Identity.Pid) { continue } $process = $null - try { $process = [Diagnostics.Process]::GetProcessById($memberPid) } - catch [ArgumentException] { - if ($memberPid -in [CapProcessJob]::Members($Run.Job)) { throw 'Job member identity unavailable while still listed.' } - continue - } + $process = Get-CapJobMemberProcess -Run $Run -MemberPid $memberPid + if ($null -eq $process) {continue} $retained = $false try { $access = Read-CapProcessAccess -Process $process -Role 'job-member' ` @@ -3764,6 +3774,7 @@ function Assert-CapRestartEvidence { $after.backendCreationFileTimeUtc -isnot [long] -or $after.backendCreationFileTimeUtc -le $after.oldBackendCreationFileTimeUtc) {& $fail 'offline backend generation was not replaced'} $restart=$null;$restartResponse=$null;$advance=$null;$resume=$null;$resumeResponse=$null;$baseline=$false + $counterAfter=$null $ordinal=0L;$elapsed=0L foreach ($row in $Evidence.rows) { if ($row.ordinal -isnot [long] -or $row.ordinal -le $ordinal -or $row.elapsedMs -isnot [long] -or @@ -3807,8 +3818,15 @@ function Assert-CapRestartEvidence { if ($row.direction -cne 'daemon' -or -not $frame.PSObject.Properties['event']) {continue} if ($frame.event.PSObject.Properties['errorOccurred']) {& $fail 'daemon error is not confirmation'} $graph=$null;$path=$null - if ($frame.event.PSObject.Properties['graphChanged']) {$graph=$frame.event.graphChanged;$path=$graph.project.path} - elseif ($frame.event.PSObject.Properties['nodesChanged']) {$graph=$frame.event.nodesChanged;$path=$graph.projectPath} + if ($frame.event.PSObject.Properties['graphChanged']) { + $graph=$frame.event.graphChanged + if ($graph.PSObject.Properties['_0']) {$graph=$graph._0} + $path=$graph.project.path + } elseif ($frame.event.PSObject.Properties['nodesChanged']) { + $graph=$frame.event.nodesChanged + if ($graph.PSObject.Properties['_0']) {$graph=$graph._0} + $path=$graph.projectPath + } if ($null -eq $graph -or $path -cne $Evidence.projectPath) {continue} $nodes=@($graph.nodes | Where-Object id -CEQ $Evidence.nodeId) if ($nodes.Count -gt 1) {& $fail 'ambiguous owner'} @@ -3817,9 +3835,15 @@ function Assert-CapRestartEvidence { if ($counter -isnot [long] -or $counter -lt 0) {& $fail 'malformed owner counter'} if (-not $restart -and $counter -eq $Evidence.counter) {$baseline=$true} if ($restart -and $counter -gt $Evidence.counter) { + $state=$nodes[0].state + $stateProperties=@() + if ($state -is [pscustomobject]) {$stateProperties=@($state.PSObject.Properties)} + $stateName=if ($state -is [string]) {$state} elseif ($stateProperties.Count -eq 1) { + [string]$stateProperties[0].Name + } else {$null} if ($row.elapsedMs-$restart.elapsedMs -gt 30000 -or - $nodes[0].state -cnotin @('idle','running','awaitingInput','blocked','waiting')) {& $fail 'counter confirmation timed out or loop is not unresolved'} - if (-not $advance) {$advance=$row} + $stateName -cnotin @('idle','running','awaitingInput','blocked','waiting')) {& $fail 'counter confirmation timed out or loop is not unresolved'} + if (-not $advance) {$advance=$row;$counterAfter=[long]$counter} } } if (-not $restart -or -not $restartResponse -or -not $advance -or -not $resume -or -not $resumeResponse -or @@ -3827,9 +3851,7 @@ function Assert-CapRestartEvidence { & $fail 'complete correlated restart, counter advance, checked resume and subsequent attach required' } return [pscustomobject]@{restartRequestId=$restart.frame.requestID;resumeRequestId=$resume.frame.requestID - connection=$Evidence.connection;counterBefore=$Evidence.counter;counterAfter=($advance.frame.event.PSObject.Properties | - Where-Object {$_.Name -cin @('graphChanged','nodesChanged')} | ForEach-Object {$_.Value.nodes | - Where-Object id -CEQ $Evidence.nodeId | ForEach-Object sessionRestarts}) + connection=$Evidence.connection;counterBefore=$Evidence.counter;counterAfter=$counterAfter status='CONFIRMED_DAEMON_ROUNDTRIP_AND_OWNED_REATTACH';backend='OFFLINE_FIXTURE_NOT_AUTHENTICATED_AGENT'} } @@ -5499,7 +5521,8 @@ function Read-CapJournalFinalProof { ($fileOutput -and ($process.stdoutEof -or $process.stderrEof))) { throw (New-CapJournalFailure 'JournalCleanupUnproven' 'Kernel exit or owning-stream EOF is unproved.') } - if ($process.pid -le 0 -or $process.creationFileTimeUtc -le 0 -or $process.parentPid -le 0 -or + if ($process.pid -le 0 -or $process.creationFileTimeUtc -le 0 -or + ($process.parentPid -le 0 -and -not $fileOutput) -or $process.sessionId -lt 0 -or $process.streamOwnerPid -le 0 -or $process.streamOwnerCreationFileTimeUtc -le 0 -or -not [IO.Path]::IsPathFullyQualified($process.expectedImagePath)) { throw (New-CapJournalFailure 'JournalIdentity' 'Invalid cleanup generation identity.')