Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 70 additions & 17 deletions Tools/windows/Invoke-MatchedActionSupplement.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ param(
[Parameter(Mandatory)][string]$Repository,
[Parameter(Mandatory)][string]$OwnedRoot,
[Parameter(Mandatory)][string]$ActionPlanPath,
[Parameter(Mandatory)][ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$ActionPlanSha256
[Parameter(Mandatory)][ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$ActionPlanSha256,
[switch]$DryRun
)
$ErrorActionPreference='Stop'
$root=[IO.Path]::GetFullPath($OwnedRoot).TrimEnd('\')
Expand Down Expand Up @@ -44,11 +45,18 @@ foreach ($binding in @(
}
}
$granted=[DateTimeOffset]::UtcNow.AddSeconds(-1)
$actionRunDirectory=Join-Path $root ($(if ($DryRun) {'dry-run-runs\'} else {'action-runs\'}) +
[guid]::NewGuid().ToString('N'))
$authorizationPath=Join-Path $root $(if ($DryRun) {'dry-run-binding.json'} else {'action-authorization.json'})
$authorization=[ordered]@{
schemaVersion=1L
authorizationId=[guid]::NewGuid().ToString()
status='AUTHORIZED'
authorizedAction='matched-action-supplement'
status=$(if ($DryRun) {'DRY_RUN_NO_AUTHORITY'} else {'AUTHORIZED'})
authorizedAction=$(if ($DryRun) {'no-product-import-dry-run'} else {'matched-action-supplement'})
ownedRoot=$root
authorizationPath=$authorizationPath
runDirectory=$actionRunDirectory
processJournalPath=(Join-Path $actionRunDirectory 'process-journal')
group=$plan.group
sourceHead=$head
sourceTree=$tree
Expand All @@ -64,32 +72,34 @@ $authorization=[ordered]@{
[long]$plan.authorizationBounds.requestedMaxMinutes).ToString('o')
maxMinutes=[long]$plan.authorizationBounds.requestedMaxMinutes
}
if ($plan.group -ceq 'FinalBoundedActions') {$authorization.pngAuthorized=$true}
$authorizationPath=Join-Path $root 'action-authorization.json'
if (-not $DryRun -and $plan.group -ceq 'FinalBoundedActions') {$authorization.pngAuthorized=$true}
if (Test-Path $authorizationPath) {throw 'Action supplement authorization already consumed.'}
[IO.File]::WriteAllText($authorizationPath,($authorization|ConvertTo-Json -Depth 20),
[Text.UTF8Encoding]::new($false))
$output=Join-Path $root 'action-output'
$authorizationSha256=(Get-FileHash -LiteralPath $authorizationPath -Algorithm SHA256).Hash
$output=Join-Path $root $(if ($DryRun) {'dry-run-output'} else {'action-output'})
if (Test-Path $output) {throw 'Action supplement output already exists.'}
$null=New-Item -ItemType Directory -Path $output

Import-Module $modulePath -Force -WarningAction SilentlyContinue
. $workerPath -OwnedRoot $root -DefinitionsOnly
$authorizationHandle=Open-CapActionAuthorization -Path $authorizationPath -Sha256 $authorizationSha256 `
-Expected ([pscustomobject]$authorization) -DryRun:$DryRun
$selfProcess=$null
try {
$job=Get-Content $plan.jobPath -Raw | ConvertFrom-Json -DateKind String
$matchedCapturePlan=Get-CapMatchedCapturePlan -Job $job
if (-not $DryRun) {$matchedCapturePlan=Get-CapMatchedCapturePlan -Job $job}
$fixturePath=Join-Path $root 'fixtures\fixture.json'
$peerScript=Join-Path $root 'inputs\offline-peer-source.ps1'
$runDirectory=Join-Path $root ('action-runs\' + [guid]::NewGuid().ToString('N'))
$runDirectory=$actionRunDirectory
$null=New-Item -ItemType Directory -Path $runDirectory
$journalPath=Join-Path $runDirectory 'process-journal'
$null=New-Item -ItemType Directory -Path $journalPath
$outputDirectory=$output
$workerLog=Join-Path $output 'action-worker.log'
[IO.File]::WriteAllText($workerLog,'',[Text.UTF8Encoding]::new($false))
Set-CapTracePath -Path (Join-Path $output 'action-calls.jsonl')
$leaseWindow=Get-CapLeaseWindow -GrantedUtc $authorization.grantedUtc `
-ExpiresUtc $authorization.expiresUtc -MaximumMinutes ([double]$authorization.maxMinutes) `
-NowUtc ([DateTime]::UtcNow)
$leaseWindow=$authorizationHandle.Window
$leaseDeadlineUtc=$leaseWindow.WorkerDeadlineUtc
$focusedCapture=$true
$actionSupplementMode=$true
Expand All @@ -114,10 +124,43 @@ Write-CapWorkerJournalSnapshot -Kind 'Bootstrap'
$startedUtc=[DateTime]::UtcNow
$primaryFailure=$null
$independent=@()
$dryRunLauncher=$null
$dryRunProof=$null
try {
$matchedFixture=Get-Content $fixturePath -Raw | ConvertFrom-Json -DateKind String
Initialize-CapMatchedWorktrees -Fixture $matchedFixture
if ($plan.group -ceq 'TerminalActions') {
Assert-CapWorkerDeadline 'action entry'
if ($DryRun) {
# Exercise the real custody gate with a harmless child, never a product/provider image.
$dryRunLauncher=Start-CapJobLauncher -Root $root -OutputDirectory $outputDirectory `
-FilePath (Join-Path $PSHOME 'pwsh.exe') `
-ArgumentList @('-NoProfile','-NonInteractive','-Command','exit 0') `
-WorkingDirectory $root -Role 'import-dry-run-launcher' `
-Environment @{TEMP=$runDirectory;TMP=$runDirectory}
$runs.Add($dryRunLauncher)
Write-CapProcessJournal -Identity $dryRunLauncher.Identity -Phase 'dry-run'
$releaseSequence=$journalState.Sequence
$target=Release-CapJobLauncher -Run $dryRunLauncher -JournalState $journalState
$dryRunProof=[ordered]@{
authorizationPath=$authorizationPath
authorizationSha256=$authorizationSha256
journalDirectory=$journalState.Directory
journalBinding=$journalState.Binding
releaseSequence=$releaseSequence
target=$target
productLaunches=0L
providerLaunches=0L
windowIdentityValidation='REQUIRES_PRODUCT_WINDOW_NOT_EXECUTED'
geometry=$null
preconditionsEvaluated=$true
}
if (-not (Test-CapMatchedDpiAdapter)) {throw 'Action dry run requires the production matched DPI contract.'}
$dryRunProof.geometry=Get-CapMatchedWindowGeometry -Phase 'after'
} else {
$matchedFixture=Get-Content $fixturePath -Raw | ConvertFrom-Json -DateKind String
Initialize-CapMatchedWorktrees -Fixture $matchedFixture
}
if ($DryRun) {
Assert-CapWorkerDeadline 'dry-run preconditions evaluated'
} elseif ($plan.group -ceq 'TerminalActions') {
$bindings=@{}
foreach ($property in $plan.imageBindings.PSObject.Properties) {
$bindings[$property.Name]=[string]$property.Value
Expand Down Expand Up @@ -160,6 +203,10 @@ try {
} catch {
$primaryFailure=$_
} finally {
if ($dryRunLauncher) {
try {$null=Close-CapMatchedJob -Run $dryRunLauncher -Phase 'dry-run'}
catch {$cleanupFailures.Add("Dry-run launcher cleanup failed: $($_.Exception.Message)")}
}
foreach ($appRun in @($applicationRuns | Sort-Object {$_.Run.StartedUtc} -Descending)) {
if (-not $appRun.CleanupComplete) {
try {Close-CapGraphCodeApp -AppRun $appRun}
Expand Down Expand Up @@ -199,7 +246,9 @@ $manifest=[ordered]@{
sourceHead=$head
sourceTree=$tree
actionPlanSha256=$ActionPlanSha256.ToUpperInvariant()
authorizationSha256=(Get-FileHash $authorizationPath -Algorithm SHA256).Hash
authorizationSha256=$authorizationHandle.Sha256
executionMode=$(if ($DryRun) {'DRY_RUN_NO_AUTHORITY'} else {'MATCHED_ACTION_SUPPLEMENT'})
dryRun=$dryRunProof
startedUtc=$startedUtc.ToString('o')
finishedUtc=[DateTime]::UtcNow.ToString('o')
imageBindings=$plan.imageBindings
Expand All @@ -217,7 +266,7 @@ $manifest=[ordered]@{
cleanup=@($cleanupReceipts)
cleanupFailures=@($cleanupFailures)
independentSurvivors=@($independent)
failures=$(if ($primaryFailure) {@($primaryFailure.Exception.Message)} else {@()})
failures=@(if ($primaryFailure) {$primaryFailure.Exception.Message})
pngCount=[long]@(Get-ChildItem $root -Recurse -File -Filter '*.png').Count
}
$manifestPath=Join-Path $output 'action-manifest.json'
Expand Down Expand Up @@ -245,7 +294,7 @@ $releasePath=Join-Path $output 'action-release.json'
if ($manifest.status -cne 'COMPLETE' -or $release.status -cne 'RELEASED_ZERO_SURVIVORS') {
throw "Action supplement failed: $($manifest.failures -join '; ') cleanup=$($cleanupFailures -join '; ') survivors=$($independent.Count)"
}
if ($manifest.pngCount -ne [long]$plan.maximumResultPngs) {
if ($manifest.pngCount -ne $(if ($DryRun) {0L} else {[long]$plan.maximumResultPngs})) {
throw "Action supplement result image count differs: expected=$($plan.maximumResultPngs) actual=$($manifest.pngCount)"
}
[ordered]@{
Expand All @@ -259,3 +308,7 @@ if ($manifest.pngCount -ne [long]$plan.maximumResultPngs) {
cleanupState=$release.cleanupState
pngCount=$manifest.pngCount
} | ConvertTo-Json -Depth 10
} finally {
$authorizationHandle.Pin.Dispose()
if ($selfProcess) {$selfProcess.Dispose()}
}
83 changes: 64 additions & 19 deletions Tools/windows/Invoke-UiCaptureWorker.ps1
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
param(
[Parameter(Mandatory)][string]$OwnedRoot,
[Parameter()][string]$AuthorizationPath,
[Parameter()][string]$AuthorizationSha256,
[Parameter()][Alias('AuthorizationPath')][string]$WorkerEntryAuthorizationPath,
[Parameter()][Alias('AuthorizationSha256')][string]$WorkerEntryAuthorizationSha256,
[switch]$AwaitSupervisorGate,
[Parameter()][switch]$DefinitionsOnly
)
Expand Down Expand Up @@ -84,6 +84,58 @@ function Get-CapWorkerAuthorityId {
throw 'Worker authority identity is missing.'
}

function Open-CapActionAuthorization {
param(
[AllowNull()][AllowEmptyString()][string]$Path,
[Parameter(Mandatory)][string]$Sha256,
[Parameter(Mandatory)]$Expected,
[switch]$DryRun
)
if ([string]::IsNullOrWhiteSpace($Path)) { throw 'Action authorization path is null or empty.' }
$fullPath = Assert-CapArtifactPath -Root $root -Path $Path
if ($fullPath -ine $Expected.authorizationPath) { throw 'Action authorization path differs from this run.' }
# Deny replacement/writes until manifest and release publication have finished.
$pin = [IO.File]::Open($fullPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
try {
$record = Read-CapBoundJson -Root $root -Reference ([pscustomobject]@{path=$fullPath;sha256=$Sha256})
foreach ($name in @('authorizationId','ownedRoot','authorizationPath','runDirectory',
'processJournalPath','actionPlanSha256','jobSha256','moduleSha256','workerSha256',
'actionRunnerSha256','sourceHead','sourceTree','group','grantedUtc','expiresUtc','maxMinutes','pngAuthorized')) {
if (-not $record.PSObject.Properties[$name] -or
[string]$record.$name -cne [string]$Expected.$name) {
throw "Action authorization binding differs: $name"
}
}
$expectedStatus = if ($DryRun) {'DRY_RUN_NO_AUTHORITY'} else {'AUTHORIZED'}
$expectedAction = if ($DryRun) {'no-product-import-dry-run'} else {'matched-action-supplement'}
if ($record.status -cne $expectedStatus -or $record.authorizedAction -cne $expectedAction -or
($DryRun -and $record.pngAuthorized -ne $false)) {
throw 'Action authorization authority differs.'
}
$window = Get-CapLeaseWindow -GrantedUtc $record.grantedUtc -ExpiresUtc $record.expiresUtc `
-MaximumMinutes ([double]$record.maxMinutes) -NowUtc ([DateTime]::UtcNow)
if ([DateTime]::UtcNow -ge $window.WorkerDeadlineUtc) { throw 'Action authorization worker deadline expired.' }
return [pscustomobject]@{Record=$record;Pin=$pin;Sha256=$Sha256;Window=$window}
} catch {
$pin.Dispose()
throw
}
}

function Get-CapMatchedWindowGeometry {
param([Parameter(Mandatory)][string]$Phase)
$area=Get-CapPrimaryPhysicalWorkArea
$contract = $job.captureContract
$identity = $contract.windowIdentities.$Phase
return Get-CapDpiGeometry -LogicalClientWH @($contract.logicalClientWH) `
-ExpectedDpi ([uint32]$contract.expectedWindowDpi) `
-WindowStyle ([long]$identity.windowStyleForAdjustment) `
-WindowExStyle ([long]$identity.windowExStyleForAdjustment) `
-HasMenu ([bool]$identity.hasMenu) `
-WorkArea @($area.Left,$area.Top,$area.Right,$area.Bottom) `
-ExpectedAdjustedOuterWH @($identity.expectedAdjustedOuterWH)
}

function ConvertTo-CapWorkerImagePath {
param([Parameter(Mandatory)][string]$Path)
$value=$Path.Trim()
Expand Down Expand Up @@ -1345,18 +1397,11 @@ function Start-CapGraphCodeApp {
$appRun | Add-Member -NotePropertyName Placement -NotePropertyValue $placement
$appRun | Add-Member -NotePropertyName Roots -NotePropertyValue $Roots
} elseif (Test-CapMatchedDpiAdapter) {
$area=Get-CapPrimaryPhysicalWorkArea
$beforeMetrics=Get-CapWindowMetrics -Window $window -ProcessIdentity $run.Identity
$contract=$job.captureContract
$identity=$contract.windowIdentities.$Phase
$null=Assert-CapDpiWindowIdentity -Metrics $beforeMetrics -Identity $identity -Initial
$geometry=Get-CapDpiGeometry -LogicalClientWH @($contract.logicalClientWH) `
-ExpectedDpi ([uint32]$contract.expectedWindowDpi) `
-WindowStyle ([long]$identity.windowStyleForAdjustment) `
-WindowExStyle ([long]$identity.windowExStyleForAdjustment) `
-HasMenu ([bool]$identity.hasMenu) `
-WorkArea @($area.Left,$area.Top,$area.Right,$area.Bottom) `
-ExpectedAdjustedOuterWH @($identity.expectedAdjustedOuterWH)
$geometry=Get-CapMatchedWindowGeometry -Phase $Phase
Set-CapWindowPosition -Window $window -ProcessIdentity $run.Identity `
-X $geometry.OuterBounds[0] -Y $geometry.OuterBounds[1] `
-Width $geometry.OuterWH[0] -Height $geometry.OuterWH[1]
Expand Down Expand Up @@ -3067,21 +3112,21 @@ function Invoke-CapRealSeedRehearsal {
if ($DefinitionsOnly) { return }

try {
if (-not $AuthorizationPath -or -not $AuthorizationSha256) {
if (-not $WorkerEntryAuthorizationPath -or -not $WorkerEntryAuthorizationSha256) {
throw 'Live capture requires the supervisor-bound authorization path and hash.'
}
$authorizationFullPath = [IO.Path]::GetFullPath($AuthorizationPath)
$authorizationFullPath = [IO.Path]::GetFullPath($WorkerEntryAuthorizationPath)
if (-not $authorizationFullPath.StartsWith($root + '\', [StringComparison]::OrdinalIgnoreCase)) {
throw 'Worker authorization path escaped its explicit owned root.'
}
if (-not (Test-Path -LiteralPath $AuthorizationPath -PathType Leaf)) {
throw "Supervisor run authorization is missing: $AuthorizationPath"
if (-not (Test-Path -LiteralPath $WorkerEntryAuthorizationPath -PathType Leaf)) {
throw "Supervisor run authorization is missing: $WorkerEntryAuthorizationPath"
}
$actualAuthorizationHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $AuthorizationPath).Hash
if ($actualAuthorizationHash -ne $AuthorizationSha256) {
$actualAuthorizationHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $WorkerEntryAuthorizationPath).Hash
if ($actualAuthorizationHash -ne $WorkerEntryAuthorizationSha256) {
throw 'Supervisor run authorization hash mismatch.'
}
$auth = Get-Content -LiteralPath $AuthorizationPath -Raw | ConvertFrom-Json -DateKind String
$auth = Get-Content -LiteralPath $WorkerEntryAuthorizationPath -Raw | ConvertFrom-Json -DateKind String
if ($auth.ownedRoot -ine $root -or
$auth.executionMode -notin @('LIVE_CAPTURE','FOCUSED_LIVE_CAPTURE',
'NO_DESKTOP_REAL_LAUNCH_REHEARSAL','LIVE_TRANSITION_REHEARSAL')) {
Expand All @@ -3101,7 +3146,7 @@ try {
[CapProcessJob]::ClearStandardHandleInheritance()
if (-not $AwaitSupervisorGate) { throw 'Worker requires the assigned supervisor job gate.' }
$gate = [Console]::In.ReadLineAsync()
if (-not $gate.Wait(30000) -or $gate.Result -cne $AuthorizationSha256) {
if (-not $gate.Wait(30000) -or $gate.Result -cne $WorkerEntryAuthorizationSha256) {
throw 'Supervisor job gate was not released.'
}
$safeWorkerOutput = Assert-CapArtifactPath -Root $root -Path $auth.outputDirectory
Expand Down Expand Up @@ -3130,7 +3175,7 @@ try {
$leaseHash -ne $auth.authoritySha256) {
throw 'A reviewed capture control or authorization input changed after supervisor launch.'
}
if ($AuthorizationPath -ine $auth.authorizationPath) {
if ($WorkerEntryAuthorizationPath -ine $auth.authorizationPath) {
throw 'Worker command-line authorization does not match the supervisor-bound record.'
}
$job = Read-CapBoundJson -Root $root -Reference ([pscustomobject]@{ path = $jobPath; sha256 = $jobHash })
Expand Down
Loading
Loading