From 05fac9050621ce13b694fbf7c9074572077bb629 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 26 Oct 2026 14:31:12 -0700 Subject: [PATCH 01/11] Add no-lease live transition rehearsal Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Invoke-UiCapture.ps1 | 269 ++++++++++++++---- Tools/windows/Invoke-UiCaptureWorker.ps1 | 219 ++++++++++++-- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 71 ++++- Tools/windows/UI-CAPTURE-SCENARIOS.md | 24 ++ 4 files changed, 496 insertions(+), 87 deletions(-) diff --git a/Tools/windows/Invoke-UiCapture.ps1 b/Tools/windows/Invoke-UiCapture.ps1 index 04dfe9ff..f6ba5d55 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,52 @@ function Assert-CapGreenJob { return $jobIdentity.sourceRepository } +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-CapAdmissionGit $repository @('rev-parse','HEAD')) -cne $job.head -or + @(Invoke-CapAdmissionGit $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-CapAdmissionGit $repository @('rev-parse','--verify',"$commit`^{commit}")) -cne $commit) { + throw 'Live transition rehearsal names a missing source commit.' + } + } + $delta = @(Invoke-CapAdmissionGit $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 +474,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 +671,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 +709,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)-" } @@ -806,6 +903,38 @@ 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) { + 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-FileHash -LiteralPath (Assert-CapSupervisorPath $checkpoint.reference.path) -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 +1125,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 +1137,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 +1172,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 +1228,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 +1250,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 +1264,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 +1299,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 +1315,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 +1483,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 +1539,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..18846a69 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,18 @@ 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 Test-CapMatchedAdapter { return $job.PSObject.Properties['sceneAdapter'] -and $job.sceneAdapter -cin @('S_MATCHED_V12_V1','S_MATCHED_V12_DPI_V1') @@ -216,17 +231,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 +682,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 +793,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 +885,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 +908,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 +941,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) @@ -1790,6 +1921,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() @@ -2023,10 +2155,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 +2179,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 +2195,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 +2206,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 +2238,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 +2267,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 +2301,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 +2318,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 @@ -2268,7 +2431,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..78b9b531 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) @@ -3012,7 +3015,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 +4148,58 @@ 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('checkpointCount') -and + $workerSource.Contains('captures = if ($transitionRehearsal) { 0L }')) 'Worker manifest does not seal checkpoint and zero-capture counts.' + Assert-CapTest ($supervisorSource.Contains('RehearsalAuthorizationPath') -and + $supervisorSource.Contains('RehearsalAuthorizationSha256')) 'Supervisor still requires a capture lease for live transition rehearsal.' + $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' @@ -6870,6 +6921,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 +7670,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 From e1c830b451dc3046f9b88b6be0d63a9880065a8e Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 26 Oct 2026 14:43:59 -0700 Subject: [PATCH 02/11] Fix rehearsal Git admission Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Invoke-UiCapture.ps1 | 17 +++++++++++++---- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 2 ++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Tools/windows/Invoke-UiCapture.ps1 b/Tools/windows/Invoke-UiCapture.ps1 index f6ba5d55..b9b524dc 100644 --- a/Tools/windows/Invoke-UiCapture.ps1 +++ b/Tools/windows/Invoke-UiCapture.ps1 @@ -176,6 +176,15 @@ 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] @@ -189,16 +198,16 @@ function Assert-CapTransitionJobIdentity { throw 'Live transition rehearsal job is not explicitly authorized as non-capture evidence.' } $repository = [IO.Path]::GetFullPath($job.sourceRepository) - if ([string](Invoke-CapAdmissionGit $repository @('rev-parse','HEAD')) -cne $job.head -or - @(Invoke-CapAdmissionGit $repository @('status','--porcelain','--untracked-files=all')).Count -ne 0) { + 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-CapAdmissionGit $repository @('rev-parse','--verify',"$commit`^{commit}")) -cne $commit) { + if ([string](Invoke-CapTransitionGit $repository @('rev-parse','--verify',"$commit`^{commit}")) -cne $commit) { throw 'Live transition rehearsal names a missing source commit.' } } - $delta = @(Invoke-CapAdmissionGit $repository @( + $delta = @(Invoke-CapTransitionGit $repository @( 'diff','--name-only','--no-renames','--no-ext-diff','--no-textconv', $job.productSourceHead,$job.head,'--' )) diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 78b9b531..e8829c5d 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -4170,6 +4170,8 @@ function Invoke-CapLiveTransitionRehearsalManifestContract { $workerSource.Contains('captures = if ($transitionRehearsal) { 0L }')) 'Worker manifest does not seal checkpoint and zero-capture counts.' Assert-CapTest ($supervisorSource.Contains('RehearsalAuthorizationPath') -and $supervisorSource.Contains('RehearsalAuthorizationSha256')) 'Supervisor still requires a capture lease for live transition rehearsal.' + 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' From de1ecc6656e8f1759544e509dfd54f53c0c839ea Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 26 Oct 2026 15:03:03 -0700 Subject: [PATCH 03/11] Fix Restart process path matching Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Invoke-UiCaptureWorker.ps1 | 20 +++++++++++++++---- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 2 ++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index 18846a69..245959bc 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -79,6 +79,18 @@ function Get-CapWorkerAuthorityId { 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') @@ -1800,7 +1812,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)) @@ -1833,8 +1845,8 @@ function Get-CapMatchedRestartEvidence { $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} @@ -1973,7 +1985,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 } diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index e8829c5d..f15cd71e 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -4166,6 +4166,8 @@ function Invoke-CapLiveTransitionRehearsalManifestContract { } 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('checkpointCount') -and $workerSource.Contains('captures = if ($transitionRehearsal) { 0L }')) 'Worker manifest does not seal checkpoint and zero-capture counts.' Assert-CapTest ($supervisorSource.Contains('RehearsalAuthorizationPath') -and From 8f3e0fb33fa471011107a7388bef9d9ab954eaf4 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 26 Oct 2026 15:21:40 -0700 Subject: [PATCH 04/11] Bind Restart evidence to one connection Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Invoke-UiCapture.ps1 | 5 +++-- Tools/windows/Invoke-UiCaptureWorker.ps1 | 11 +++++++---- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 3 +++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Tools/windows/Invoke-UiCapture.ps1 b/Tools/windows/Invoke-UiCapture.ps1 index b9b524dc..4eff0339 100644 --- a/Tools/windows/Invoke-UiCapture.ps1 +++ b/Tools/windows/Invoke-UiCapture.ps1 @@ -898,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 } diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index 245959bc..8408a483 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -1839,6 +1839,9 @@ 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'} @@ -1852,20 +1855,20 @@ function Get-CapMatchedRestartEvidence { -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 { diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index f15cd71e..2da4b9ff 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -4168,6 +4168,9 @@ function Invoke-CapLiveTransitionRehearsalManifestContract { 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.' Assert-CapTest ($workerSource.Contains('checkpointCount') -and $workerSource.Contains('captures = if ($transitionRehearsal) { 0L }')) 'Worker manifest does not seal checkpoint and zero-capture counts.' Assert-CapTest ($supervisorSource.Contains('RehearsalAuthorizationPath') -and From 9a711896a139de972d875de38f7c59890399b444 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 26 Oct 2026 15:38:49 -0700 Subject: [PATCH 05/11] Retry transient job census gaps Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 5 +++++ Tools/windows/UiCaptureDriver.psm1 | 20 ++++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 2da4b9ff..32022050 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -4171,6 +4171,11 @@ function Invoke-CapLiveTransitionRehearsalManifestContract { 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 ($workerSource.Contains('checkpointCount') -and $workerSource.Contains('captures = if ($transitionRehearsal) { 0L }')) 'Worker manifest does not seal checkpoint and zero-capture counts.' Assert-CapTest ($supervisorSource.Contains('RehearsalAuthorizationPath') -and diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index 3abdb7ee..bb45c6b8 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' ` From dd263ceab41070daf30c0ae9d2d2958dc2d88157 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 26 Oct 2026 15:56:51 -0700 Subject: [PATCH 06/11] Accept wrapped Restart graph events Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 5 ++++- Tools/windows/UiCaptureDriver.psm1 | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 32022050..e84ef915 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -2978,6 +2978,9 @@ 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} + $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') @@ -3006,7 +3009,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)} diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index bb45c6b8..5e0f3615 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -3774,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 @@ -3817,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'} @@ -3829,7 +3837,7 @@ function Assert-CapRestartEvidence { if ($restart -and $counter -gt $Evidence.counter) { 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} + if (-not $advance) {$advance=$row;$counterAfter=[long]$counter} } } if (-not $restart -or -not $restartResponse -or -not $advance -or -not $resume -or -not $resumeResponse -or @@ -3837,9 +3845,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'} } From e808c13821838aac1b62cb5fc29b5fb900e28131 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 26 Oct 2026 16:18:24 -0700 Subject: [PATCH 07/11] Normalize Restart loop state evidence Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 1 + Tools/windows/UiCaptureDriver.psm1 | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index e84ef915..67f83567 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -2980,6 +2980,7 @@ function Invoke-CapMatchedContracts { $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', diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index 5e0f3615..3452f52a 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -3835,8 +3835,14 @@ 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'} + $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} } } From 5b0441bc66528cb03a9b8d713bfeb4301b97d8f3 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 26 Oct 2026 16:42:16 -0700 Subject: [PATCH 08/11] Bound rehearsal journal progress Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Invoke-UiCaptureWorker.ps1 | 22 +++++++++++++++++-- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 4 ++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index 8408a483..90d1569e 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -169,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 @@ -176,7 +191,7 @@ function Write-CapWorkerJournalSnapshot { ownedProcessSurvivors = @($ownedProcessSurvivors) pendingLaunchesRemaining = @($pendingLaunchesRemaining) cleanupFailures = @($cleanupFailures.ToArray()) - progressManifest = [pscustomobject](New-CapManifest -Status $Outcome) + progressManifest = $progressManifest }) } @@ -2424,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 { diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 67f83567..52b8e9b4 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -4182,6 +4182,10 @@ function Invoke-CapLiveTransitionRehearsalManifestContract { 'Job census does not bound the listed-but-unavailable member race.' 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('function Invoke-CapTransitionGit') -and From 51b5ed15b7d9669234270c4dcd3450980becc39c Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 26 Oct 2026 17:06:20 -0700 Subject: [PATCH 09/11] Trust contained job cleanup identity Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 2 ++ Tools/windows/UiCaptureDriver.psm1 | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 52b8e9b4..ab27c6b6 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -4180,6 +4180,8 @@ function Invoke-CapLiveTransitionRehearsalManifestContract { $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 diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index 3452f52a..3cd06da3 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -5521,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.') From bfa552d84b79075474a8c02f4752e4b9c0b666ce Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 26 Oct 2026 17:31:30 -0700 Subject: [PATCH 10/11] Validate immutable checkpoint references Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Invoke-UiCapture.ps1 | 7 +++++-- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Tools/windows/Invoke-UiCapture.ps1 b/Tools/windows/Invoke-UiCapture.ps1 index 4eff0339..eb21fc7c 100644 --- a/Tools/windows/Invoke-UiCapture.ps1 +++ b/Tools/windows/Invoke-UiCapture.ps1 @@ -932,11 +932,14 @@ function Assert-CapWorkerManifest { 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-FileHash -LiteralPath (Assert-CapSupervisorPath $checkpoint.reference.path) -Algorithm SHA256).Hash -ne - $checkpoint.reference.sha256) { + (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.' } } diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index ab27c6b6..697f87bd 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -4190,6 +4190,9 @@ function Invoke-CapLiveTransitionRehearsalManifestContract { '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 From c8bdd0b569373fccb82d6e85299b442346db2ffc Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 26 Oct 2026 18:47:41 -0700 Subject: [PATCH 11/11] Retry Windows receipt publication Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 697f87bd..26fa8aa4 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -4735,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.'