diff --git a/Tools/windows/Invoke-MatchedActionSupplement.ps1 b/Tools/windows/Invoke-MatchedActionSupplement.ps1 index 4acbdeac..65f62e9b 100644 --- a/Tools/windows/Invoke-MatchedActionSupplement.ps1 +++ b/Tools/windows/Invoke-MatchedActionSupplement.ps1 @@ -141,6 +141,25 @@ try { } }) if ($DryRun) { + $helperAvailability=@(foreach ($name in @( + 'Install-CapQualifiedActionTopology','Start-CapMatchedActionSeeds','Close-CapMatchedActionSeeds' + )) { + $command=Get-Command $name -CommandType Function -ErrorAction Stop + [pscustomobject]@{name=$command.Name;available=$true} + }) + $topologyPreparation=@(if ($plan.PSObject.Properties['qualifiedAuthority'] -and $plan.qualifiedAuthority) { + foreach ($scene in @($boundWindows | Where-Object actionPhase -CNE 'wire-completion' | + ForEach-Object {$_.sceneNames})) { + $qualified=@($plan.qualifiedAuthority.topologies | Where-Object scene -CEQ $scene) + if ($qualified.Count -ne 1) {throw "Final bounded action topology missing or duplicated: $scene"} + Assert-CapWorkerDeadline "dry-run topology $scene" + $roots=Initialize-CapPhaseRoot -PhaseRoot (Join-Path $runDirectory "phases\after\$scene") + $installed=Install-CapQualifiedActionTopology -Roots $roots -Topology $qualified[0] + $seedPlan=Start-CapMatchedActionSeeds -Roots $roots -Phase "after-$scene" ` + -Topology $qualified[0] -PrepareOnly + [pscustomobject]@{scene=$scene;installed=$installed;seedPlan=$seedPlan} + } + }) # 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') ` @@ -162,6 +181,8 @@ try { providerLaunches=0L windowIdentityValidation='SOURCE_PHASE_BINDINGS_VALIDATED_BEFORE_LAUNCH_RUNTIME_WINDOW_NOT_EXECUTED' windowBindings=$windowPreflight + helperAvailability=$helperAvailability + topologyPreparation=$topologyPreparation toolingSourceHead=$head captureSourceHead=$job.head geometry=$null diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index ea6a2ea9..42eb7672 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -611,6 +611,140 @@ function Start-CapOfflinePeer { return $run } +function Install-CapQualifiedActionTopology { + param([Parameter(Mandatory)]$Roots,[Parameter(Mandatory)]$Topology) + if ((Get-FileHash -LiteralPath $Topology.preparedLayoutPath -Algorithm SHA256).Hash -cne + [string]$Topology.preparedLayoutSha256) { + throw "Prepared qualified layout changed: $($Topology.scene)" + } + $layoutPath=Join-Path $Roots.Root "$($Topology.loop).json" + Copy-Item -LiteralPath $Topology.preparedLayoutPath -Destination $layoutPath + $sessionRoot=Join-Path $Roots.Root 'shell-sessions' + $null=New-Item -ItemType Directory -Path $sessionRoot -Force + foreach ($session in @($Topology.sessions | Where-Object {-not $_.agent})) { + $source=Join-Path $Topology.shellSessionsPath $session.name + if ((Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash -cne + [string]$session.preparedSha256) { + throw "Prepared shell-session identity changed: $($session.name)" + } + Copy-Item -LiteralPath $source -Destination (Join-Path $sessionRoot $session.name) + } + return [pscustomobject]@{ + layoutPath=$layoutPath + layoutSha256=(Get-FileHash -LiteralPath $layoutPath -Algorithm SHA256).Hash + sourceLayoutSha256=$Topology.sourceLayoutSha256 + sessionCount=[long]$Topology.sessions.Count + shellSessionCount=[long]@($Topology.sessions | Where-Object {-not $_.agent}).Count + } +} + +function Start-CapMatchedActionSeeds { + param([Parameter(Mandatory)]$Roots,[Parameter(Mandatory)][string]$Phase, + [Parameter(Mandatory)]$Topology,[switch]$PrepareOnly) + $sessions=@($Topology.sessions | ForEach-Object {[string]$_.name}) + if ($sessions.Count -lt 1 -or @($sessions | Sort-Object -Unique).Count -ne $sessions.Count) { + throw 'Qualified action topology has missing or duplicate sessions.' + } + $manifestPath=Join-Path $Roots.Peer 'action-seed-sessions.json' + $manifest=[pscustomobject]@{schemaVersion=1L;sessions=$sessions} + $manifestReference=Write-CapImmutableJson -Root $root -Path $manifestPath -Value $manifest + $zmxPath=Join-Path $root 'runtime\zmx.exe' + $zmxRoot=Assert-CapOwnedPath $Roots.Zmx + $working=Assert-CapOwnedPath (Join-Path $Roots.Peer 'action-seed-cwd') + $null=New-Item -ItemType Directory -Path $working -Force + $seedArguments=@( + '-NoProfile','-NonInteractive','-File',$actionSeedHelperPath, + '-ZmxPath',$zmxPath,'-SessionManifestPath',$manifestPath + ) + if ($PrepareOnly) { + return [pscustomobject]@{ + status='PREPARED_NO_LAUNCH';phase=$Phase + sessionManifestPath=$manifestPath;sessionManifest=$manifestReference + filePath=(Join-Path $PSHOME 'pwsh.exe');argumentList=$seedArguments + workingDirectory=$working;zmxDirectory=$zmxRoot + } + } + $run=Start-CapJobLauncher -Root $root -OutputDirectory $working ` + -FilePath (Join-Path $PSHOME 'pwsh.exe') -ArgumentList $seedArguments ` + -WorkingDirectory $working -Role 'matched-action-seed-launcher' -Environment @{ + ZMX_DIR=$zmxRoot + TEMP=$Roots.Temp + TMP=$Roots.Temp + USERPROFILE=$Roots.Profile + } + $run | Add-Member -NotePropertyMembers @{ + Phase=$Phase + SessionNames=$sessions + SessionManifestSha256=$manifestReference.sha256 + ActionSeedGroup=$true + Descendants=@() + SeedReceipt=$null + } + $runs.Add($run) + $actionSeedRuns.Add($run) + Write-CapProcessJournal -Identity $run.Identity -Phase $Phase + $target=Release-CapJobLauncher -Run $run -JournalState $journalState + $deadline=[DateTime]::UtcNow.AddSeconds(30) + if ($deadline -gt $leaseDeadlineUtc) {$deadline=$leaseDeadlineUtc} + $exit=Wait-CapJobTarget -Run $run -DeadlineUtc $deadline + if ($exit.exitCode -ne 0) {throw "Qualified action seed helper failed for $Phase."} + $cmdPath=Join-Path ([Environment]::SystemDirectory) 'cmd.exe' + $zmxPath=ConvertTo-CapWorkerImagePath $zmxPath + $readyDeadline=[DateTime]::UtcNow.AddSeconds(15) + if ($readyDeadline -gt $leaseDeadlineUtc.AddSeconds(-90)) {$readyDeadline=$leaseDeadlineUtc.AddSeconds(-90)} + $tree=@();$daemons=@();$cmdPeers=@() + do { + $tree=@(Get-CapJobMembers -Run $run) + foreach ($identity in $tree) { + if ($identity.ExpectedImagePath -ieq $zmxPath) {$identity.Role='zmx-daemon'} + elseif ($identity.ExpectedImagePath -ieq $cmdPath) {$identity.Role='offline-cmd-peer'} + } + $daemons=@($tree | Where-Object Role -eq 'zmx-daemon') + $cmdPeers=@($tree | Where-Object Role -eq 'offline-cmd-peer') + if ($daemons.Count -ge 1 -and $cmdPeers.Count -eq $sessions.Count) {break} + Assert-CapWorkerDeadline "qualified action seed readiness $Phase" + Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $readyDeadline) + if ($daemons.Count -lt 1 -or $cmdPeers.Count -ne $sessions.Count) { + throw "Qualified action seeds differ: sessions=$($sessions.Count) daemon=$($daemons.Count) cmd=$($cmdPeers.Count)." + } + foreach ($identity in $tree) { + Write-CapProcessJournal -Identity $identity -Phase $Phase + if (-not @($descendantIdentities | Where-Object { + $_.Pid -eq $identity.Pid -and $_.CreationFileTimeUtc -eq $identity.CreationFileTimeUtc + }).Count) {$descendantIdentities.Add($identity)} + } + $run.Descendants=@($tree) + $receipt=[pscustomobject]@{ + phase=$Phase + sessions=$sessions + sessionCount=[long]$sessions.Count + launcherPid=[long]$run.Identity.Pid + launcherCreationFileTimeUtc=[long]$run.Identity.CreationFileTimeUtc + targetPid=[long]$target.pid + targetCreationFileTimeUtc=[long]$target.creationFileTimeUtc + jobId=$run.JobId + daemonCount=[long]$daemons.Count + cmdCount=[long]$cmdPeers.Count + sessionManifestSha256=$manifestReference.sha256 + cleanupStatus='PENDING' + outputProof=$null + } + $run.SeedReceipt=$receipt + $zmxSeedReceipts.Add($receipt) + return $run +} + +function Close-CapMatchedActionSeeds { + param([Parameter(Mandatory)]$SeedRun) + if ($SeedRun.CleanupComplete) {return $SeedRun.OutputProof} + $proof=Close-CapJobLauncher -Run $SeedRun + $SeedRun.SeedReceipt.cleanupStatus='CLOSED' + $SeedRun.SeedReceipt.outputProof=$proof + $cleanupReceipts.Add($proof) + return $proof +} + function Start-CapOfflineSeed { param( [Parameter(Mandatory)]$Roots, @@ -637,130 +771,6 @@ function Start-CapOfflineSeed { $arguments[6] -cne 'echo Offline card fixture - no backend')) { throw 'The B job does not contain the exact seven-argument offline cmd-only zmx seed.' } - - function Install-CapQualifiedActionTopology { - param([Parameter(Mandatory)]$Roots,[Parameter(Mandatory)]$Topology) - if ((Get-FileHash -LiteralPath $Topology.preparedLayoutPath -Algorithm SHA256).Hash -cne - [string]$Topology.preparedLayoutSha256) { - throw "Prepared qualified layout changed: $($Topology.scene)" - } - $layoutPath=Join-Path $Roots.Root "$($Topology.loop).json" - Copy-Item -LiteralPath $Topology.preparedLayoutPath -Destination $layoutPath - $sessionRoot=Join-Path $Roots.Root 'shell-sessions' - $null=New-Item -ItemType Directory -Path $sessionRoot -Force - foreach ($session in @($Topology.sessions | Where-Object {-not $_.agent})) { - $source=Join-Path $Topology.shellSessionsPath $session.name - if ((Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash -cne - [string]$session.preparedSha256) { - throw "Prepared shell-session identity changed: $($session.name)" - } - Copy-Item -LiteralPath $source -Destination (Join-Path $sessionRoot $session.name) - } - return [pscustomobject]@{ - layoutPath=$layoutPath - layoutSha256=(Get-FileHash -LiteralPath $layoutPath -Algorithm SHA256).Hash - sourceLayoutSha256=$Topology.sourceLayoutSha256 - sessionCount=[long]$Topology.sessions.Count - shellSessionCount=[long]@($Topology.sessions | Where-Object {-not $_.agent}).Count - } - } - - function Start-CapMatchedActionSeeds { - param([Parameter(Mandatory)]$Roots,[Parameter(Mandatory)][string]$Phase, - [Parameter(Mandatory)]$Topology) - $sessions=@($Topology.sessions | ForEach-Object {[string]$_.name}) - if ($sessions.Count -lt 1 -or @($sessions | Sort-Object -Unique).Count -ne $sessions.Count) { - throw 'Qualified action topology has missing or duplicate sessions.' - } - $manifestPath=Join-Path $Roots.Peer 'action-seed-sessions.json' - $manifest=[pscustomobject]@{schemaVersion=1L;sessions=$sessions} - $manifestReference=Write-CapImmutableJson -Root $root -Path $manifestPath -Value $manifest - $zmxPath=Join-Path $root 'runtime\zmx.exe' - $zmxRoot=Assert-CapOwnedPath $Roots.Zmx - $working=Assert-CapOwnedPath (Join-Path $Roots.Peer 'action-seed-cwd') - $null=New-Item -ItemType Directory -Path $working -Force - $run=Start-CapJobLauncher -Root $root -OutputDirectory $working ` - -FilePath (Join-Path $PSHOME 'pwsh.exe') -ArgumentList @( - '-NoProfile','-NonInteractive','-File',$actionSeedHelperPath, - '-ZmxPath',$zmxPath,'-SessionManifestPath',$manifestPath - ) -WorkingDirectory $working -Role 'matched-action-seed-launcher' -Environment @{ - ZMX_DIR=$zmxRoot - TEMP=$Roots.Temp - TMP=$Roots.Temp - USERPROFILE=$Roots.Profile - } - $run | Add-Member -NotePropertyMembers @{ - Phase=$Phase - SessionNames=$sessions - SessionManifestSha256=$manifestReference.sha256 - ActionSeedGroup=$true - Descendants=@() - SeedReceipt=$null - } - $runs.Add($run) - $actionSeedRuns.Add($run) - Write-CapProcessJournal -Identity $run.Identity -Phase $Phase - $target=Release-CapJobLauncher -Run $run -JournalState $journalState - $deadline=[DateTime]::UtcNow.AddSeconds(30) - if ($deadline -gt $leaseDeadlineUtc) {$deadline=$leaseDeadlineUtc} - $exit=Wait-CapJobTarget -Run $run -DeadlineUtc $deadline - if ($exit.exitCode -ne 0) {throw "Qualified action seed helper failed for $Phase."} - $cmdPath=Join-Path ([Environment]::SystemDirectory) 'cmd.exe' - $zmxPath=ConvertTo-CapWorkerImagePath $zmxPath - $readyDeadline=[DateTime]::UtcNow.AddSeconds(15) - if ($readyDeadline -gt $leaseDeadlineUtc.AddSeconds(-90)) {$readyDeadline=$leaseDeadlineUtc.AddSeconds(-90)} - $tree=@();$daemons=@();$cmdPeers=@() - do { - $tree=@(Get-CapJobMembers -Run $run) - foreach ($identity in $tree) { - if ($identity.ExpectedImagePath -ieq $zmxPath) {$identity.Role='zmx-daemon'} - elseif ($identity.ExpectedImagePath -ieq $cmdPath) {$identity.Role='offline-cmd-peer'} - } - $daemons=@($tree | Where-Object Role -eq 'zmx-daemon') - $cmdPeers=@($tree | Where-Object Role -eq 'offline-cmd-peer') - if ($daemons.Count -ge 1 -and $cmdPeers.Count -eq $sessions.Count) {break} - Assert-CapWorkerDeadline "qualified action seed readiness $Phase" - Start-Sleep -Milliseconds 100 - } while ([DateTime]::UtcNow -lt $readyDeadline) - if ($daemons.Count -lt 1 -or $cmdPeers.Count -ne $sessions.Count) { - throw "Qualified action seeds differ: sessions=$($sessions.Count) daemon=$($daemons.Count) cmd=$($cmdPeers.Count)." - } - foreach ($identity in $tree) { - Write-CapProcessJournal -Identity $identity -Phase $Phase - if (-not @($descendantIdentities | Where-Object { - $_.Pid -eq $identity.Pid -and $_.CreationFileTimeUtc -eq $identity.CreationFileTimeUtc - }).Count) {$descendantIdentities.Add($identity)} - } - $run.Descendants=@($tree) - $receipt=[pscustomobject]@{ - phase=$Phase - sessions=$sessions - sessionCount=[long]$sessions.Count - launcherPid=[long]$run.Identity.Pid - launcherCreationFileTimeUtc=[long]$run.Identity.CreationFileTimeUtc - targetPid=[long]$target.pid - targetCreationFileTimeUtc=[long]$target.creationFileTimeUtc - jobId=$run.JobId - daemonCount=[long]$daemons.Count - cmdCount=[long]$cmdPeers.Count - sessionManifestSha256=$manifestReference.sha256 - cleanupStatus='PENDING' - outputProof=$null - } - $run.SeedReceipt=$receipt - $zmxSeedReceipts.Add($receipt) - return $run - } - - function Close-CapMatchedActionSeeds { - param([Parameter(Mandatory)]$SeedRun) - if ($SeedRun.CleanupComplete) {return $SeedRun.OutputProof} - $proof=Close-CapJobLauncher -Run $SeedRun - $SeedRun.SeedReceipt.cleanupStatus='CLOSED' - $SeedRun.SeedReceipt.outputProof=$proof - $cleanupReceipts.Add($proof) - return $proof - } $seedReceiptRoot = if ($isS) { $Roots.Root } else { Join-Path $runDirectory "phases\$Phase\normal" } $seedReceiptPath = Assert-CapOwnedPath (Join-Path $seedReceiptRoot 'zmx-seed.json') $null = New-Item -ItemType Directory -Path (Split-Path -Parent $seedReceiptPath) -Force diff --git a/Tools/windows/Tests/ActionTopologyImport.Tests.ps1 b/Tools/windows/Tests/ActionTopologyImport.Tests.ps1 new file mode 100644 index 00000000..72cfde90 --- /dev/null +++ b/Tools/windows/Tests/ActionTopologyImport.Tests.ps1 @@ -0,0 +1,149 @@ +param( + [string]$ScratchRoot = (Join-Path ([IO.Path]::GetTempPath()) ('at' + [guid]::NewGuid().ToString('N').Substring(0,8))) +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$driverDirectory = Split-Path -Parent $PSScriptRoot +$workerPath = Join-Path $driverDirectory 'Invoke-UiCaptureWorker.ps1' +$caseRoot = [IO.Path]::GetFullPath($ScratchRoot) +if (Test-Path -LiteralPath $caseRoot) {throw "Refusing to replace topology evidence: $caseRoot"} +$null = New-Item -ItemType Directory -Path $caseRoot +. $workerPath -OwnedRoot $caseRoot -DefinitionsOnly +Import-Module $modulePath -Force -WarningAction SilentlyContinue +Set-CapTracePath -Path (Join-Path $caseRoot 'calls.jsonl') +$results = [Collections.Generic.List[object]]::new() + +function Assert-TopologyCase { + param([bool]$Condition,[string]$Message) + if (-not $Condition) {throw $Message} +} + +function Reject-TopologyCase { + param([string]$Name,[scriptblock]$Action,[string]$Message) + $failure=$null + try {& $Action | Out-Null} catch {$failure=$_} + Assert-TopologyCase ($null -ne $failure -and $failure.Exception.Message.Contains($Message)) ` + "Expected $Name refusal '$Message'; actual=$failure" + $results.Add(@{name=$Name;passed=$true;refusal=$failure.Exception.Message}) +} + +$parseErrors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($workerPath,[ref]$null,[ref]$parseErrors) +Assert-TopologyCase ($parseErrors.Count -eq 0) 'Worker must parse.' +foreach ($name in @('Install-CapQualifiedActionTopology','Start-CapMatchedActionSeeds','Close-CapMatchedActionSeeds')) { + $command=Get-Command $name -CommandType Function -ErrorAction SilentlyContinue + Assert-TopologyCase ($null -ne $command) "Production DefinitionsOnly import did not expose $name." + $results.Add(@{name="production import exposes $name";passed=$true}) + $definitions=@($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + },$true)) + Assert-TopologyCase ($definitions.Count -eq 1 -and $definitions[0].Parent.Parent -eq $ast) ` + "$name must have exactly one top-level definition, not an offline-seed-local definition." + $results.Add(@{name="lexical top-level $name";passed=$true}) +} + +$inputRoot=Join-Path $caseRoot 'inputs' +$sessionRoot=Join-Path $inputRoot 'shell-sessions' +$null=New-Item -ItemType Directory -Path $sessionRoot +$layoutPath=Join-Path $inputRoot 'layout.json' +[IO.File]::WriteAllText($layoutPath,'{"schemaVersion":2}',[Text.UTF8Encoding]::new($false)) +$sessionName='graphcode-11111111-1111-4111-8111-000000000001' +$loop='11111111-1111-4111-8111-000000000001' +$sessionPath=Join-Path $sessionRoot $sessionName +[IO.File]::WriteAllText($sessionPath,$loop,[Text.UTF8Encoding]::new($false)) +$topology=[pscustomobject]@{ + scene='s-tabs-two-agent-selected';loop=$loop + sourceLayoutSha256=('A'*64);preparedLayoutPath=$layoutPath + preparedLayoutSha256=(Get-FileHash $layoutPath).Hash + shellSessionsPath=$sessionRoot + sessions=@( + [pscustomobject]@{name=$sessionName;agent=$false;preparedSha256=(Get-FileHash $sessionPath).Hash}, + [pscustomobject]@{name='graphcode-22222222-2222-4222-8222-000000000002';agent=$true;preparedSha256=$null} + ) +} +$job=[pscustomobject]@{sceneAdapter='S_MATCHED_V12_DPI_V1'} +$roots=Initialize-CapPhaseRoot -PhaseRoot (Join-Path $caseRoot 'phase') +$installed=Install-CapQualifiedActionTopology -Roots $roots -Topology $topology +Assert-TopologyCase ($installed.layoutSha256 -ceq $topology.preparedLayoutSha256 -and + $installed.sourceLayoutSha256 -ceq $topology.sourceLayoutSha256 -and + $installed.sessionCount -eq 2 -and $installed.shellSessionCount -eq 1 -and + [IO.File]::ReadAllText((Join-Path $roots.Root "shell-sessions\$sessionName")) -ceq $loop -and + @(Get-ChildItem (Join-Path $roots.Root 'shell-sessions') -File).Count -eq 1) ` + 'Qualified install did not preserve exact layout bytes and non-agent ownership.' +$results.Add(@{name='qualified topology installs exact bytes and shell ownership';passed=$true}) +$originalLayoutHash=$topology.preparedLayoutSha256 +$topology.preparedLayoutSha256='0'*64 +Reject-TopologyCase 'changed layout hash' {Install-CapQualifiedActionTopology $roots $topology} 'Prepared qualified layout changed' +$topology.preparedLayoutSha256=$originalLayoutHash +$originalSessionHash=$topology.sessions[0].preparedSha256 +$topology.sessions[0].preparedSha256='0'*64 +Reject-TopologyCase 'changed session hash' {Install-CapQualifiedActionTopology $roots $topology} 'Prepared shell-session identity changed' +$topology.sessions[0].preparedSha256=$originalSessionHash +$topology.preparedLayoutPath=Join-Path $inputRoot 'missing.json' +Reject-TopologyCase 'missing layout' {Install-CapQualifiedActionTopology $roots $topology} 'missing.json' +$topology.preparedLayoutPath=$layoutPath + +# Fail before any launcher can be created, even if prepare-only routing regresses. +$script:launcherCalls=0 +function Start-CapJobLauncher { + param($Root,$OutputDirectory,$FilePath,$ArgumentList,$WorkingDirectory,$Role,$Environment) + $script:launcherCalls++ + throw 'TOPOLOGY_TEST_LAUNCH_INTERCEPTED' +} +$actionSeedHelperPath=Join-Path $driverDirectory 'Start-MatchedActionSessions.ps1' +$seedPlan=Start-CapMatchedActionSeeds -Roots $roots -Phase 'after-test' -Topology $topology -PrepareOnly +$seedManifest=Get-Content $seedPlan.sessionManifestPath -Raw | ConvertFrom-Json -DateKind String +Assert-TopologyCase ($seedPlan.status -ceq 'PREPARED_NO_LAUNCH' -and $script:launcherCalls -eq 0 -and + $seedPlan.phase -ceq 'after-test' -and $seedManifest.schemaVersion -eq 1 -and + ($seedManifest.sessions -join '|') -ceq ($topology.sessions.name -join '|') -and + $seedPlan.sessionManifest.sha256 -ceq (Get-FileHash $seedPlan.sessionManifestPath).Hash -and + $seedPlan.filePath -ieq (Join-Path $PSHOME 'pwsh.exe') -and + ($seedPlan.argumentList -join '|') -ceq ( + @('-NoProfile','-NonInteractive','-File',$actionSeedHelperPath, + '-ZmxPath',(Join-Path $caseRoot 'runtime\zmx.exe'), + '-SessionManifestPath',$seedPlan.sessionManifestPath) -join '|') -and + $runs.Count -eq 0 -and $actionSeedRuns.Count -eq 0 -and $zmxSeedReceipts.Count -eq 0) ` + 'Prepare-only seed path launched, fabricated readiness, or changed the actual seed invocation.' +$results.Add(@{name='prepare-only plans exact seed manifest and invocation without launch or receipts';passed=$true}) +$defaultRoots=Initialize-CapPhaseRoot -PhaseRoot (Join-Path $caseRoot 'default') +Reject-TopologyCase 'default seed still reaches real launcher' { + Start-CapMatchedActionSeeds -Roots $defaultRoots -Phase 'after-test' -Topology $topology +} 'TOPOLOGY_TEST_LAUNCH_INTERCEPTED' +Assert-TopologyCase ($script:launcherCalls -eq 1) 'Default seed must reach launcher exactly once.' +foreach ($fault in @('empty','duplicate')) { + $bad=$topology | ConvertTo-Json -Depth 20 | ConvertFrom-Json -DateKind String + if ($fault -ceq 'empty') {$bad.sessions=@()} + else {$bad.sessions=@($topology.sessions[0],$topology.sessions[0])} + Reject-TopologyCase "$fault sessions" { + Start-CapMatchedActionSeeds -Roots $roots -Phase 'after-test' -Topology $bad -PrepareOnly + } 'missing or duplicate sessions' +} +Assert-TopologyCase ($script:launcherCalls -eq 1) 'Invalid seed inventory reached launcher.' + +$script:closeCalls=0 +function Close-CapJobLauncher { + param($Run) + $script:closeCalls++ + return [pscustomobject]@{kind='TEST_ONLY_NO_PROCESS';jobActive0=$true} +} +$seedRun=[pscustomobject]@{CleanupComplete=$false;OutputProof=$null + SeedReceipt=[pscustomobject]@{cleanupStatus='PENDING';outputProof=$null}} +$proof=Close-CapMatchedActionSeeds -SeedRun $seedRun +Assert-TopologyCase ($script:closeCalls -eq 1 -and $seedRun.SeedReceipt.cleanupStatus -ceq 'CLOSED' -and + $seedRun.SeedReceipt.outputProof -eq $proof -and $cleanupReceipts.Count -eq 1) ` + 'Imported cleanup helper did not preserve lifecycle receipt.' +$results.Add(@{name='imported seed cleanup preserves existing lifecycle receipt';passed=$true}) +$seedRun.CleanupComplete=$true +$seedRun.OutputProof=$proof +$again=Close-CapMatchedActionSeeds -SeedRun $seedRun +Assert-TopologyCase ($script:closeCalls -eq 1 -and $again -eq $proof -and $cleanupReceipts.Count -eq 1) ` + 'Completed seed cleanup must not close or publish twice.' +$results.Add(@{name='completed seed cleanup remains idempotent';passed=$true}) + +$report=[ordered]@{suite='ActionTopologyImport';executed=$results.Count;passed=$results.Count;failed=0;skipped=0 + productLaunches=0;providerLaunches=0;cases=@($results)} +[IO.File]::WriteAllText((Join-Path $caseRoot 'report.json'),($report|ConvertTo-Json -Depth 30), + [Text.UTF8Encoding]::new($false)) +[pscustomobject]$report | Select-Object suite,executed,passed,failed,skipped | ConvertTo-Json -Compress +if ($results.Count -ne 16) {throw "Unexpected action topology contract count: $($results.Count)"} diff --git a/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 b/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 index 1ee2ea2f..f3df7a8d 100644 --- a/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 +++ b/Tools/windows/Tests/ActionWorkerImport.Tests.ps1 @@ -152,6 +152,26 @@ foreach ($scenario in @('accepted','setup-refusal')) { fixture=@{canonicalSourceSha256=$fixtureHashValue;mappingSha256=$fixtureHashValue} imageBindings=@{};maximumResultPngs=1L } + $topologies=@(foreach ($scene in @($windowBindings | Where-Object actionPhase -CNE 'wire-completion' | + ForEach-Object {$_.sceneNames})) { + $inputDirectory=Join-Path $owned "inputs\$scene" + $sessionDirectory=Join-Path $inputDirectory 'shell-sessions' + $null=New-Item -ItemType Directory -Path $sessionDirectory -Force + $layoutFile=Join-Path $inputDirectory 'layout.json' + $layoutDigest=Write-ImportJson $layoutFile @{schemaVersion=2L;scene=$scene} + $sessionName='graphcode-11111111-1111-4111-8111-000000000001' + $sessionFile=Join-Path $sessionDirectory $sessionName + [IO.File]::WriteAllText($sessionFile,'11111111-1111-4111-8111-000000000001', + [Text.UTF8Encoding]::new($false)) + [pscustomobject]@{ + scene=$scene;loop='11111111-1111-4111-8111-000000000001' + sourceLayoutSha256=$layoutDigest;preparedLayoutPath=$layoutFile;preparedLayoutSha256=$layoutDigest + shellSessionsPath=$sessionDirectory + sessions=@([pscustomobject]@{name=$sessionName;agent=$false + preparedSha256=(Get-FileHash $sessionFile).Hash}) + } + }) + $actionPlan.qualifiedAuthority=@{topologies=$topologies} $planFile=Join-Path $owned 'action-plan.json' $planDigest=Write-ImportJson $planFile $actionPlan $entryOutput=@(& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File ` @@ -208,6 +228,19 @@ foreach ($scenario in @('accepted','setup-refusal')) { $manifest.dryRun.releaseSequence -ge 2 -and $release.journalPublicationCount -ge 3 -and $release.manifestSha256 -ceq (Get-FileHash $manifestFile).Hash -and -not (Test-Path (Join-Path $owned 'action-authorization.json'))) 'Dry-run custody/no-authority/cleanup evidence differs.' + Assert-ImportCase ($manifest.dryRun.helperAvailability.Count -eq 3 -and + @($manifest.dryRun.helperAvailability | Where-Object available -NE $true).Count -eq 0 -and + $manifest.dryRun.topologyPreparation.Count -eq 5) 'Production dry run omitted imported helpers or qualified topology preparation.' + foreach ($prepared in $manifest.dryRun.topologyPreparation) { + $source=@($topologies | Where-Object scene -CEQ $prepared.scene) + $seedManifest=Get-Content $prepared.seedPlan.sessionManifestPath -Raw | ConvertFrom-Json -DateKind String + Assert-ImportCase ($source.Count -eq 1 -and $prepared.seedPlan.status -ceq 'PREPARED_NO_LAUNCH' -and + $prepared.installed.layoutSha256 -ceq $source[0].preparedLayoutSha256 -and + (Get-FileHash $prepared.installed.layoutPath).Hash -ceq $source[0].preparedLayoutSha256 -and + $prepared.seedPlan.sessionManifest.sha256 -ceq (Get-FileHash $prepared.seedPlan.sessionManifestPath).Hash -and + ($seedManifest.sessions -join '|') -ceq ($source[0].sessions.name -join '|')) ` + 'Production dry run changed qualified topology or seed manifest bytes.' + } $binding=Get-Content (Join-Path $owned 'dry-run-binding.json') -Raw | ConvertFrom-Json -DateKind String Assert-ImportCase ($binding.status -ceq 'DRY_RUN_NO_AUTHORITY' -and $binding.pngAuthorized -eq $false -and $binding.authorizationPath -ceq $manifest.dryRun.authorizationPath -and diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index b9367978..ab43cb10 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -6074,6 +6074,18 @@ if ($actionBindingSummary.executed -ne 57 -or $actionBindingSummary.passed -ne 5 $actionBindingSummary.failed -ne 0 -or $actionBindingSummary.skipped -ne 0) { throw "RED: action window binding requires all 57 contracts without skips" } +$actionTopologyOutput=@(& $pwsh -NoProfile -File (Join-Path $PSScriptRoot "ActionTopologyImport.Tests.ps1")) +$actionTopologyOutput | ForEach-Object {Write-Host $_} +if ($LASTEXITCODE -ne 0) {throw "RED: action topology import contract child failed"} +$actionTopologySummaries=@($actionTopologyOutput | Where-Object { + $_ -is [string] -and $_.StartsWith('{"suite":"ActionTopologyImport",') + }) +if ($actionTopologySummaries.Count -ne 1) {throw "RED: action topology structured count missing or duplicated"} +$actionTopologySummary=ConvertFrom-Json $actionTopologySummaries[0] +if ($actionTopologySummary.executed -ne 16 -or $actionTopologySummary.passed -ne 16 -or + $actionTopologySummary.failed -ne 0 -or $actionTopologySummary.skipped -ne 0) { + throw "RED: action topology import requires all 16 contracts without skips" +} $agentCommandOutput = @(& $pwsh -NoProfile -File ( Join-Path $PSScriptRoot "Invoke-AgentCommand.Tests.ps1")) $agentCommandSummary = "Invoke-AgentCommand.Tests.ps1: PASS; executed=6; cases=argument-stream-marker,zero-arguments,existing-console,existing-marker,parse-failure,child-nonzero" diff --git a/Tools/windows/UI-CAPTURE-SCENARIOS.md b/Tools/windows/UI-CAPTURE-SCENARIOS.md index 52876322..995887eb 100644 --- a/Tools/windows/UI-CAPTURE-SCENARIOS.md +++ b/Tools/windows/UI-CAPTURE-SCENARIOS.md @@ -67,8 +67,9 @@ pwsh -NoProfile -File Tools\windows\Invoke-MatchedActionSupplement.ps1 ` ``` This still requires an unchanged clean, hash-bound action plan. It creates only -`dry-run-binding.json` (`DRY_RUN_NO_AUTHORITY`), `dry-run-runs`, and -`dry-run-output`; it never creates `action-authorization.json` or grants PNG, +`dry-run-binding.json` (`DRY_RUN_NO_AUTHORITY`), `dry-run-runs`, +`dry-run-output`, and private empty zmx state directories for qualified seed +preparation; it never creates `action-authorization.json` or grants PNG, product, or provider authority. It uses a contained harmless `pwsh` child to exercise the actual launcher release with the action-owned numbered journal. Before any launcher is created, the entry resolves every explicit @@ -93,6 +94,15 @@ product behavior are explicitly not evaluated. success and setup refusal, including the primary exception, terminal numbered journal and, when a harmless launcher was started, positive held-job-empty/ launcher-EOF cleanup. A binding/geometry refusal now occurs before any launcher. +A dry run also requires all three top-level qualified topology/seed lifecycle +helpers to be available through the production `DefinitionsOnly` import. When +the plan carries qualified topologies, it installs each bound terminal scene's +exact prepared layout and shell-session bytes into its isolated phase root and +calls the shared seed helper with `-PrepareOnly`. This publishes the immutable +session manifest and records the actual seed invocation, but returns +`PREPARED_NO_LAUNCH` before creating a seed launcher, readiness proof, or provider +receipt. The default helper path retains the existing launch, custody, readiness, +journal, and cleanup behavior; prepare-only output is not provider evidence. A failed precondition is a bounded tooling/setup blocker, never permission to retry a product action. No prior W1 setup exception is reconstructed by this dry run. @@ -112,6 +122,15 @@ drift with modeled metrics. Its accepted startup intercepts the launcher without starting a process; it is not native-window evidence. ValidationRunner requires all 57 with zero skips, alongside the 18 import and unchanged 182/185 baseline. +`Tests\ActionTopologyImport.Tests.ps1` adds **16 separately counted contracts** +for production import, lexical top-level scope, exact topology copies, layout/ +session hash refusals, missing layouts, prepare-only manifests and invocation, +default launcher reachability (intercepted without a process), invalid session +inventories, and cleanup/idempotence with modeled launcher output. The 18 import +contracts also exercise all five qualified topology/seed preparation paths +through the unmodified production entry dry run. ValidationRunner requires all +16 with zero skips without replacing the existing suites or baseline cases. + ## Live transition rehearsal `LIVE_TRANSITION_REHEARSAL` is a no-lease, non-evidence mode for exercising the