diff --git a/.github/workflows/client-v1-conformance.yml b/.github/workflows/client-v1-conformance.yml index 9f31084b..28bad32e 100644 --- a/.github/workflows/client-v1-conformance.yml +++ b/.github/workflows/client-v1-conformance.yml @@ -4526,12 +4526,37 @@ jobs: typeof(WTS_PROCESS_INFO_EXW)); if (information.pUserSid == IntPtr.Zero) { - if (information.ProcessId == 0) + // The Idle process, and the protected system processes + // that live in session 0 -- Secure System, Registry, + // and their kin -- expose no primary token SID to any + // caller, however privileged. Refusing on their + // account made the drain unrunnable on hosts that + // enable virtualization-based security, which is every + // current Windows image. + // + // Skipping them does not widen what this proves. The + // supervised identity is a local account this process + // created and logged on with CreateProcessWithLogonW, + // so its processes hold a readable token and appear in + // this enumeration with a SID to compare. A process + // whose SID cannot be read AT ALL is not one of them. + // + // Anywhere else, an owner this enumeration cannot read + // is an owner it cannot rule out, so the refusal + // stands -- and names the process, because a process + // that exited between enumeration and read and a + // permanently unreadable one demand opposite fixes. + if (information.ProcessId == 0 || + information.SessionId == 0) { continue; } - throw new InvalidOperationException( - "WTS process primary token SID query was ambiguous."); + throw new InvalidOperationException(String.Format( + CultureInfo.InvariantCulture, + "WTS process primary token SID query was ambiguous " + + "for process {0} in session {1}.", + information.ProcessId, + information.SessionId)); } string sid = ConvertNativeSidToString( information.pUserSid, diff --git a/docs/phase1-conformance.md b/docs/phase1-conformance.md index fbb2035f..85766a65 100644 --- a/docs/phase1-conformance.md +++ b/docs/phase1-conformance.md @@ -998,7 +998,7 @@ The later SDK validator repin must use these exact committed file bytes: | File | Bytes | SHA-256 | | --- | ---: | --- | -| `.github/workflows/client-v1-conformance.yml` | 457,825 | `1904746089bfa3fe079efdc686a9a12dd0f836f4bcfdc4a0df214f9e7e6c52a4` | +| `.github/workflows/client-v1-conformance.yml` | 459,800 | `047ae05690461530e609370b3d2d5c90817d5a2ff06484f13ffef9f4d5c775f2` | | `scripts/contract-canary.mjs` | 38,191 | `4eb4d9b693187f110343a4c1efd92e59a9705e25790845bf04b05cb5bac6cbb5` | | `scripts/executable-resolution.mjs` | 9,154 | `31e3c412ff8c835f14522f36a59e91f4a4ba82913210ae8e3b4455217503f430` | | `scripts/owned-temp-directory.mjs` | 6,965 | `a9c55c85cf2b7d70310d278bafd2c8e7695d66f4ae38b9c3f1f12fce0b442095` | @@ -1023,8 +1023,8 @@ The later SDK validator repin must use these exact committed file bytes: | `scripts/unix-producer-supervisor.test.sh` | 8,083 | `c7d2d023d4c1f1ba3cdb3da1e95b30af1763602932a91592388bb117de6bd397` | | `scripts/phase1-windows-supervisor-build.sh` | 4,646 | `713a9e0282887ade3e243b5ba175794d74cdb02c28c38dcd41491c9505812770` | | `scripts/phase1-windows-supervisor-install.ps1` | 1,743 | `2baab275f0bb6789884cded5f6185d00bfa5348b9e7c3ad1e5575353639101d5` | -| `scripts/windows-job-supervisor.cs` | 289,604 | `20ac2be79c6ec6ebaccdf78543b68540fe2645e37e96c6715d477f19229b943f` | -| `scripts/windows-job-supervisor.test.ps1` | 168,772 | `74df69934cf26c7d6c083ce74abe840f472613b4df39d93584f5cb6391b951bf` | +| `scripts/windows-job-supervisor.cs` | 291,329 | `08c18fa81b16f922b3fac32abec3a2f6369e5f2b9f4caa19a0b48df6302bb110` | +| `scripts/windows-job-supervisor.test.ps1` | 171,179 | `55e9cf065e2dc7cc656c6aa8cc9ea53542259d3d7eee55c368c6cf0fc6356ab9` | Before parsing or executing SDK authority, the harness queries the verified checkout with `git rev-parse --show-object-format`, accepts only `sha1` or diff --git a/phase1-conformance.lock.json b/phase1-conformance.lock.json index d6847c61..a0134f4d 100644 --- a/phase1-conformance.lock.json +++ b/phase1-conformance.lock.json @@ -116,8 +116,8 @@ }, { "path": "scripts/windows-job-supervisor.cs", - "blob": "521a3d44e619ce9e3da385d5632e3333c0c63198", - "sha256": "20ac2be79c6ec6ebaccdf78543b68540fe2645e37e96c6715d477f19229b943f" + "blob": "547f2db1e7cacd539f49f5c879839fa80397ac1d", + "sha256": "08c18fa81b16f922b3fac32abec3a2f6369e5f2b9f4caa19a0b48df6302bb110" }, { "path": "scripts/contract-canary.mjs", @@ -146,8 +146,8 @@ }, { "path": ".github/workflows/client-v1-conformance.yml", - "blob": "9f31084b5fdd0b9546f3f2010f7c66cf912aeb25", - "sha256": "1904746089bfa3fe079efdc686a9a12dd0f836f4bcfdc4a0df214f9e7e6c52a4" + "blob": "28bad32e2ad0fc849ac87e9a7b9cbd3229e9640a", + "sha256": "047ae05690461530e609370b3d2d5c90817d5a2ff06484f13ffef9f4d5c775f2" } ], "productionDeltas": [ diff --git a/scripts/windows-job-supervisor.cs b/scripts/windows-job-supervisor.cs index 521a3d44..547f2db1 100644 --- a/scripts/windows-job-supervisor.cs +++ b/scripts/windows-job-supervisor.cs @@ -4086,12 +4086,37 @@ private static Dictionary typeof(WTS_PROCESS_INFO_EXW)); if (information.pUserSid == IntPtr.Zero) { - if (information.ProcessId == 0) + // The Idle process, and the protected system processes + // that live in session 0 -- Secure System, Registry, + // and their kin -- expose no primary token SID to any + // caller, however privileged. Refusing on their + // account made the drain unrunnable on hosts that + // enable virtualization-based security, which is every + // current Windows image. + // + // Skipping them does not widen what this proves. The + // supervised identity is a local account this process + // created and logged on with CreateProcessWithLogonW, + // so its processes hold a readable token and appear in + // this enumeration with a SID to compare. A process + // whose SID cannot be read AT ALL is not one of them. + // + // Anywhere else, an owner this enumeration cannot read + // is an owner it cannot rule out, so the refusal + // stands -- and names the process, because a process + // that exited between enumeration and read and a + // permanently unreadable one demand opposite fixes. + if (information.ProcessId == 0 || + information.SessionId == 0) { continue; } - throw new InvalidOperationException( - "WTS process primary token SID query was ambiguous."); + throw new InvalidOperationException(String.Format( + CultureInfo.InvariantCulture, + "WTS process primary token SID query was ambiguous " + + "for process {0} in session {1}.", + information.ProcessId, + information.SessionId)); } string sid = ConvertNativeSidToString( information.pUserSid, diff --git a/scripts/windows-job-supervisor.test.ps1 b/scripts/windows-job-supervisor.test.ps1 index 635bf65c..1f50a83a 100644 --- a/scripts/windows-job-supervisor.test.ps1 +++ b/scripts/windows-job-supervisor.test.ps1 @@ -1,6 +1,64 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest +# Readiness waits are 60 seconds, written out at each site. +# +# Every one of them polls for a marker some spawned process writes, and what it +# asserts is that the marker APPEARS -- never that it appears quickly. The old +# per-site values (5-30s) were tuned on a runner that could start a +# secondary-user session in a second or two. Starting one now regularly takes +# far longer on the hosted windows-2025 image, where creating the logon session +# and loading a fresh profile is most of the cost and is paid before the +# spawned script runs its first statement. Those deadlines began firing for +# machine speed rather than for anything this suite exists to catch. +# +# Deliberately a literal at each site rather than one shared helper: several of +# these waits live inside here-strings that are written out and run as their +# own child scripts, where a function defined in this file does not exist. A +# helper reads better and fails at runtime in exactly the places that matter. +# +# This bounds patience, not behaviour: a marker that never arrives still fails, +# and the job's own 20-minute timeout still bounds the run. + +# Name the cause when something throws. +# +# The supervisor wraps a quarantine failure as "Terminal producer identity +# quarantine failed" with the real fault as InnerException, and as an +# AggregateException when the producer failed too. PowerShell prints only the +# outer message, so a CI failure said which stage failed and never why. This +# prints the whole chain before the exception continues to terminate the run. +function Write-ExceptionChain { + param([Parameter(Mandatory)][AllowNull()][object]$Failure) + + $exception = if ($Failure -is [Management.Automation.ErrorRecord]) { + $Failure.Exception + } else { + $Failure + } + $depth = 0 + while ($null -ne $exception -and $depth -lt 12) { + Write-Host "cause[$depth] $($exception.GetType().FullName): $($exception.Message)" + if ($exception -is [AggregateException]) { + $index = 0 + foreach ($inner in $exception.InnerExceptions) { + Write-Host " aggregate[$index] $($inner.GetType().FullName): $($inner.Message)" + $index++ + } + } + $exception = $exception.InnerException + $depth++ + } +} + +trap { + Write-Host '--- windows-job-supervisor.test.ps1 failure ---' + Write-ExceptionChain -Failure $_ + if ($null -ne $_.ScriptStackTrace) { + Write-Host $_.ScriptStackTrace + } + break +} + if (-not $IsWindows -or [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne 'X64') { throw 'Windows Job Object runtime tests require Windows x64.' } @@ -513,7 +571,7 @@ if ( function Assert-ProcessExited { param([Parameter(Mandatory)][int]$ProcessId) - $deadline = [DateTime]::UtcNow.AddSeconds(10) + $deadline = [DateTime]::UtcNow.AddSeconds(60) do { try { $process = [Diagnostics.Process]::GetProcessById($ProcessId) @@ -532,7 +590,7 @@ function Assert-BoundedTextMarker { [Parameter(Mandatory)][string]$Expected ) - $deadline = [DateTime]::UtcNow.AddSeconds(5) + $deadline = [DateTime]::UtcNow.AddSeconds(60) do { try { if ([IO.File]::Exists($Path)) { @@ -1875,7 +1933,7 @@ $attack = Start-Process ` -RedirectStandardError $env:OPENCOVEN_ROOT_ATTACK_STDERR ` -PassThru try { - $deadline = [DateTime]::UtcNow.AddSeconds(10) + $deadline = [DateTime]::UtcNow.AddSeconds(60) while (-not [IO.File]::Exists($env:OPENCOVEN_ROOT_ATTACK_COMPLETE)) { if ($attack.HasExited) { throw "Root process attack failed: $( @@ -2428,7 +2486,7 @@ while (-not [IO.File]::Exists($env:OPENCOVEN_HANDOFF_RACE_STOP)) { -RedirectStandardError (Join-Path $operatorPrivateRoot 'handoff-race.stderr') ` -PassThru try { - $raceDeadline = [DateTime]::UtcNow.AddSeconds(10) + $raceDeadline = [DateTime]::UtcNow.AddSeconds(60) while (-not [IO.File]::Exists($raceReady)) { if ($raceProcess.HasExited) { throw 'Artifact replacement race exited before replacing the record.' @@ -3262,7 +3320,7 @@ public static class UnsupervisedLogonProcess "-NoLogo -NoProfile -NonInteractive -File `"$lateRegistrarScript`" -UserName `"$($serviceEscapeContext.User.UserName)`"", $serviceEscapeContext.User.RootPath ) - $lateReadyDeadline = [DateTime]::UtcNow.AddSeconds(20) + $lateReadyDeadline = [DateTime]::UtcNow.AddSeconds(60) while (-not [IO.File]::Exists($lateRegistrarReady)) { try { $lateRegistrar = [Diagnostics.Process]::GetProcessById($lateRegistrarPid) @@ -3783,7 +3841,7 @@ exit 23 "-NoLogo -NoProfile -NonInteractive -File `"$failureSleeperScript`"", $failureEscapeContext.User.RootPath ) - $failureSleeperDeadline = [DateTime]::UtcNow.AddSeconds(20) + $failureSleeperDeadline = [DateTime]::UtcNow.AddSeconds(60) while (-not [IO.File]::Exists($failureSleeperReady)) { try { $failureSleeper = [Diagnostics.Process]::GetProcessById( @@ -4039,7 +4097,7 @@ Start-Sleep -Seconds 300 "-NoLogo -NoProfile -NonInteractive -File `"$setupPath`"", $Context.User.RootPath ) - $readyDeadline = [DateTime]::UtcNow.AddSeconds(30) + $readyDeadline = [DateTime]::UtcNow.AddSeconds(60) while (-not [IO.File]::Exists($readyPath)) { try { $setupProcess = [Diagnostics.Process]::GetProcessById($setupPid) @@ -4533,7 +4591,7 @@ Add-Type -TypeDefinition ([IO.File]::ReadAllText('$($retainedHandleSource.Replac $retainedRootScript, @" `$descendant = Start-Process -FilePath '$($trustedPwsh.Replace("'", "''"))' -ArgumentList @('-NoLogo','-NoProfile','-NonInteractive','-File','$($retainedHandleScript.Replace("'", "''"))') -RedirectStandardOutput '$((Join-Path $root 'retained-stdout.txt').Replace("'", "''"))' -RedirectStandardError '$((Join-Path $root 'retained-stderr.txt').Replace("'", "''"))' -PassThru -`$deadline = [DateTime]::UtcNow.AddSeconds(10) +`$deadline = [DateTime]::UtcNow.AddSeconds(60) while (-not [IO.File]::Exists('$($retainedPidPath.Replace("'", "''"))')) { if (`$descendant.HasExited) { throw 'Retained Job handle descendant exited before reporting readiness.'