From 7b2f2e45a6eeeb7a912a43ef0af57c9066a0f86a Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:07:43 -0700 Subject: [PATCH 1/9] Remove x64 gate for telemetry tests --- .github/workflows/Build.Windows.Job.yml | 3 --- tests/scripts/README.md | 2 +- tests/scripts/run_telemetry_etw_smoke_test.ps1 | 2 ++ 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/Build.Windows.Job.yml b/.github/workflows/Build.Windows.Job.yml index 9c1b59d4c..9572c1091 100644 --- a/.github/workflows/Build.Windows.Job.yml +++ b/.github/workflows/Build.Windows.Job.yml @@ -77,17 +77,14 @@ jobs: --features "${{ matrix.features }}" - name: Build debug executor for isolated telemetry smoke tests - if: matrix.arch == 'x64' run: cargo build --locked -p wxc --features test-support - name: Run isolated telemetry consent smoke test - if: matrix.arch == 'x64' working-directory: ${{ github.workspace }} shell: pwsh run: tests\scripts\run_telemetry_consent_smoke_test.ps1 -BinDir src\target\debug - name: Run isolated telemetry ETW smoke test - if: matrix.arch == 'x64' working-directory: ${{ github.workspace }} shell: pwsh run: tests\scripts\run_telemetry_etw_smoke_test.ps1 -BinDir src\target\debug diff --git a/tests/scripts/README.md b/tests/scripts/README.md index 0b8b9edf1..f2244cd88 100644 --- a/tests/scripts/README.md +++ b/tests/scripts/README.md @@ -47,7 +47,7 @@ Linux / macOS (`.sh`): | `run_processcontainer_all_tests.ps1` | Process container (AppContainer / BaseContainer) primitives suite — tier probes, rw/ro/denied matrix, enumeration-only grants, UI mitigations, DACL restore, crash recovery, schema 0.8 networking. Dispatches to the per-area `run_processcontainer_*_test.ps1` scripts | `wxc-exec.exe`, `wxc-ui-probe.exe`, `plm.exe` and `winhttp-proxy-shim.exe` beside `wxc-exec.exe` | | `T3-Workloads.ps1` | Real workloads (pwsh, git, node, python, cmd) on top of the T3 primitives. A missing interpreter is reported as a skip, not a failure | `wxc-exec.exe`; `pwsh` / `git` / `node` / `python` each optional, gating their own cases | | `run_telemetry_consent_smoke_test.ps1` | Consent maintenance, presentation, policy, and exit-code smoke tests | Debug `wxc-exec.exe` built with `test-support` | -| `run_telemetry_etw_smoke_test.ps1` | Isolated consent flow plus public-provider ETW capture | Debug `wxc-exec.exe` built with `test-support`; ETW tooling | +| `run_telemetry_etw_smoke_test.ps1` | Isolated consent flow plus public-provider ETW capture | Debug `wxc-exec.exe` built with `test-support`; ETW tooling; Administrator, otherwise the test skips | | `run_on_repeat.ps1` | Stress test (loops core tests) | `wxc-exec.exe` | Each `run_processcontainer__test.ps1` also runs standalone against a diff --git a/tests/scripts/run_telemetry_etw_smoke_test.ps1 b/tests/scripts/run_telemetry_etw_smoke_test.ps1 index 4eb1b93d5..8cf067f9e 100644 --- a/tests/scripts/run_telemetry_etw_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_etw_smoke_test.ps1 @@ -193,6 +193,8 @@ try { $executionTimeoutSeconds = 60 $proc = Start-Process -FilePath $wxcExe -ArgumentList '--debug', $configFile -PassThru -NoNewWindow + # Caching the handle keeps ExitCode readable after the process exits. + $null = $proc.Handle if (-not $proc.WaitForExit($executionTimeoutSeconds * 1000)) { Stop-Process -Id $proc.Id -Force $proc.WaitForExit() From 5b632a574645e54570b8be967e2ab5975d0c5ec4 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:59:09 -0700 Subject: [PATCH 2/9] Add release-path telemetry consent test The consent store location and policy key both have a test-only override branch gated on `any(test, all(feature = "test-support", debug_assertions))`, so the shipped release shape -- real %LocalAppData% store and real HKLM policy key -- is never executed. `cargo test --release` does not close this: `cfg(test)` is on there, so the compiled function still contains the override. Add a test that drives a release wxc-exec.exe through the CLI and asserts the overrides are inert: it seeds a temp store saying granted and a registry key saying blocked, sets both override variables, and requires the binary to ignore them. It also covers real store creation and atomic replace, corruption and stale-prompt recovery, piped-EOF fail-closed, and the HKLM policy ceiling. Because it mutates real machine state it refuses to run without -AcceptRealMachineMutation, and refuses debug binaries outright. The temporary workflow runs it on ephemeral GitHub-hosted runners only and asserts RUNNER_ENVIRONMENT, since self-hosted runners also set GITHUB_ACTIONS. Refs #691 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/Temp.Telemetry.Release.Test.yml | 61 +++ tests/scripts/README.md | 1 + .../run_telemetry_consent_release_test.ps1 | 414 ++++++++++++++++++ 3 files changed, 476 insertions(+) create mode 100644 .github/workflows/Temp.Telemetry.Release.Test.yml create mode 100644 tests/scripts/run_telemetry_consent_release_test.ps1 diff --git a/.github/workflows/Temp.Telemetry.Release.Test.yml b/.github/workflows/Temp.Telemetry.Release.Test.yml new file mode 100644 index 000000000..32cb453fc --- /dev/null +++ b/.github/workflows/Temp.Telemetry.Release.Test.yml @@ -0,0 +1,61 @@ +# TEMPORARY. Validates tests/scripts/run_telemetry_consent_release_test.ps1 +# against GitHub-hosted runners before the release-path test is wired into +# Build.Windows.Job.yml. Delete once that lands. See issue #691. +# +# The test mutates the real per-user consent store and the real HKLM policy +# key, so it must only ever run on an ephemeral GitHub-hosted runner. Do not +# add self-hosted labels to this workflow. +name: TEMP - Telemetry release-path test + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + release-consent: + name: ${{ matrix.arch }} + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + runner: windows-2025 + target: x86_64-pc-windows-msvc + - arch: arm64 + runner: windows-11-arm + target: aarch64-pc-windows-msvc + runs-on: ${{ matrix.runner }} + defaults: + run: + working-directory: src + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Surface toolchain file at repo root + shell: bash + working-directory: ${{ github.workspace }} + run: cp src/rust-toolchain.toml rust-toolchain.toml + + - uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0 + with: + target: ${{ matrix.target }} + override: false + rustflags: '' + + - name: Point cargo at the MxcDependencies feed + uses: ./.github/actions/setup-cargo-feed + + # Consent is backend-independent and wxc defaults to no backend + # features, so the default release build is enough. + - name: Build release executor + run: cargo build --locked --release --target ${{ matrix.target }} -p wxc + + - name: Run release-path telemetry consent test + working-directory: ${{ github.workspace }} + shell: pwsh + run: | + tests\scripts\run_telemetry_consent_release_test.ps1 ` + -BinDir src\target\${{ matrix.target }}\release ` + -AcceptRealMachineMutation diff --git a/tests/scripts/README.md b/tests/scripts/README.md index f2244cd88..f801c9219 100644 --- a/tests/scripts/README.md +++ b/tests/scripts/README.md @@ -48,6 +48,7 @@ Linux / macOS (`.sh`): | `T3-Workloads.ps1` | Real workloads (pwsh, git, node, python, cmd) on top of the T3 primitives. A missing interpreter is reported as a skip, not a failure | `wxc-exec.exe`; `pwsh` / `git` / `node` / `python` each optional, gating their own cases | | `run_telemetry_consent_smoke_test.ps1` | Consent maintenance, presentation, policy, and exit-code smoke tests | Debug `wxc-exec.exe` built with `test-support` | | `run_telemetry_etw_smoke_test.ps1` | Isolated consent flow plus public-provider ETW capture | Debug `wxc-exec.exe` built with `test-support`; ETW tooling; Administrator, otherwise the test skips | +| `run_telemetry_consent_release_test.ps1` | Consent path in a **release** executor, where the debug store/policy overrides are compiled out. Mutates the real consent store and HKLM policy, so it requires `-AcceptRealMachineMutation` and an ephemeral machine | Release `wxc-exec.exe`; Administrator for the HKLM policy section, otherwise that section skips | | `run_on_repeat.ps1` | Stress test (loops core tests) | `wxc-exec.exe` | Each `run_processcontainer__test.ps1` also runs standalone against a diff --git a/tests/scripts/run_telemetry_consent_release_test.ps1 b/tests/scripts/run_telemetry_consent_release_test.ps1 new file mode 100644 index 000000000..56d509f76 --- /dev/null +++ b/tests/scripts/run_telemetry_consent_release_test.ps1 @@ -0,0 +1,414 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS + Exercises the telemetry consent path in a RELEASE wxc-exec, where the + debug-only store and policy overrides are compiled out. + +.DESCRIPTION + The debug smoke test redirects the consent store with + MXC_TEST_LOCALAPPDATA_OVERRIDE and the policy key with + MXC_TEST_POLICY_KEY_OVERRIDE. Both are gated on + `cfg(any(test, all(feature = "test-support", debug_assertions)))`, so a + shipped binary resolves the store through SHGetKnownFolderPath and reads + policy from HKLM. That release-only shape has no other coverage. + + Reaching it requires giving up isolation: this test writes the real + per-user consent store and the real HKLM policy key. Run it only on a + machine you are willing to mutate. Prior state is backed up and restored, + but a crash mid-run can leave the store or the policy key modified. + +.PARAMETER BinDir + Directory holding a release wxc-exec.exe. + +.PARAMETER AcceptRealMachineMutation + Required acknowledgement. There is deliberately no CI auto-detection: + self-hosted runners also set GITHUB_ACTIONS, and those machines persist. +#> + +[CmdletBinding()] +param( + [string]$BinDir, + [switch]$AcceptRealMachineMutation +) + +$ErrorActionPreference = 'Stop' + +if (-not $AcceptRealMachineMutation) { + throw 'This test mutates the real consent store and HKLM policy. Pass -AcceptRealMachineMutation on an ephemeral machine.' +} + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +if (-not $BinDir) { $BinDir = Join-Path $repoRoot 'src\target\release' } +elseif (-not [IO.Path]::IsPathRooted($BinDir)) { $BinDir = Join-Path $repoRoot $BinDir } +$BinDir = [IO.Path]::GetFullPath($BinDir) +$exe = Join-Path $BinDir 'wxc-exec.exe' +if (-not (Test-Path $exe)) { + throw "Release wxc-exec.exe not found at '$exe'. Run 'cargo build --release -p wxc'." +} +if ($exe -match '\\debug\\') { + throw 'Debug binaries are refused: this test exists to exercise the release cfg shape.' +} + +# GetFolderPath mirrors the SHGetKnownFolderPath call the executor makes; +# %LOCALAPPDATA% is a separate, spoofable source of truth. +$localAppData = [Environment]::GetFolderPath('LocalApplicationData') +if (-not $localAppData) { throw 'Could not resolve the LocalApplicationData known folder.' } +$mxcDir = Join-Path $localAppData 'mxc' +$consentFile = Join-Path $mxcDir 'telemetry-consent.json' +$policyKey = 'HKLM:\SOFTWARE\Policies\Mxc' + +$expectedBody = @' +Help improve MXC and other Microsoft product including Windows by sharing optional diagnostic data with Microsoft. + +If enabled, MXC sends diagnostic information about product usage, performance, and reliability. MXC does not send your commands, file paths, credentials, or other customer content. +'@ -replace "`r`n", "`n" + +function New-ConsentProcess { + param([string]$Arguments, [hashtable]$Environment) + + $start = New-Object Diagnostics.ProcessStartInfo + $start.FileName = $exe + $start.Arguments = $Arguments + $start.UseShellExecute = $false + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.CreateNoWindow = $true + if ($Environment) { + foreach ($name in $Environment.Keys) { $start.Environment[$name] = [string]$Environment[$name] } + } + $process = New-Object Diagnostics.Process + $process.StartInfo = $start + if (-not $process.Start()) { throw "Failed to start '$exe $Arguments'." } + return $process +} + +# Reads both pipes concurrently. Native stderr never reaches PowerShell's error +# stream this way, so the script behaves identically under 5.1 and 7. +function Invoke-Consent { + param([string]$Arguments, [hashtable]$Environment) + + $process = New-ConsentProcess -Arguments $Arguments -Environment $Environment + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.StandardInput.Close() + $process.WaitForExit() + return [pscustomobject]@{ + ExitCode = $process.ExitCode + StdOut = $stdout.Result + StdErr = $stderr.Result + } +} + +function Invoke-ConsentJson { + param([string]$Arguments, [hashtable]$Environment) + + $result = Invoke-Consent -Arguments $Arguments -Environment $Environment + if ($result.ExitCode -ne 0) { + throw "'$Arguments' exited $($result.ExitCode): $($result.StdErr)" + } + return ($result.StdOut | ConvertFrom-Json) +} + +function Invoke-ConsentRequest { + param([string]$Decision, [hashtable]$Environment) + + $process = New-ConsentProcess ` + -Arguments '--telemetry-consent request --telemetry-consent-locale en-US' ` + -Environment $Environment + $stderr = $process.StandardError.ReadToEndAsync() + $firstLine = $process.StandardOutput.ReadLine() + if (-not $firstLine) { + $process.WaitForExit() + throw "Consent request emitted no response: $($stderr.Result)" + } + $first = $firstLine | ConvertFrom-Json + if ($first.result -ne 'presentationRequired') { + $process.StandardInput.Close() + $process.WaitForExit() + return $first + } + if (-not $first.prompt -or -not $first.challenge) { + throw "Unexpected consent presentation: $firstLine" + } + if ($first.prompt.resourceVersion -ne 3 -or + $first.prompt.locale -ne 'en-US' -or + $first.prompt.title.text -ne 'Help improve Microsoft Products' -or + $first.prompt.body.text -ne $expectedBody -or + $first.prompt.affirmativeLabel.text -ne 'Yes' -or + $first.prompt.negativeLabel.text -ne 'No' -or + $first.prompt.learnMoreLabel.text -ne 'Privacy Statement' -or + $first.prompt.learnMoreUrl -ne 'https://go.microsoft.com/fwlink/?linkid=521839') { + throw 'The canonical consent resource drifted in the release build.' + } + $response = [pscustomobject]@{ + challenge = $first.challenge + resourceVersion = $first.prompt.resourceVersion + decision = $Decision + } + $process.StandardInput.WriteLine(($response | ConvertTo-Json -Compress)) + $finalRead = $process.StandardOutput.ReadLineAsync() + if (-not $finalRead.Wait(5000)) { + $process.Kill() + throw 'Consent process waited for stdin EOF instead of accepting the decision line.' + } + $process.StandardInput.Close() + $process.WaitForExit() + if ($process.ExitCode -ne 0) { throw "Consent request failed: $($stderr.Result)" } + return ($finalRead.Result | ConvertFrom-Json) +} + +function Assert-Status { + param( + [object]$Value, + [string]$Stored, + [string]$Effective, + [string]$Policy, + [bool]$NeedsPrompt, + [string]$Step + ) + if ($Value.storedState -ne $Stored -or + $Value.effectiveState -ne $Effective -or + $Value.policy -ne $Policy -or + $Value.needsPrompt -ne $NeedsPrompt) { + throw "$Step returned an unexpected response: $($Value | ConvertTo-Json -Compress)" + } +} + +function Write-ConsentRecord { + param([string]$Consent, [int]$PromptVersion = 3, [string]$Locale = 'en-US') + + $record = [pscustomobject]@{ + schemaVersion = 2 + consent = $Consent + source = 'release-path-test' + promptedMxcVersion = '0.0.0-release-test' + promptResourceVersion = $PromptVersion + promptLocale = $Locale + updatedAtEpoch = 0 + } + [IO.File]::WriteAllText( + $consentFile, + ($record | ConvertTo-Json -Compress), + (New-Object Text.UTF8Encoding $false)) +} + +function Remove-RealConsentFile { + if (Test-Path $consentFile) { Remove-Item $consentFile -Force } +} + +# --- Test sections --------------------------------------------------------- + +function Test-CliContract { + $invalid = Invoke-Consent -Arguments '--telemetry-consent invalid' + if ($invalid.ExitCode -ne 64) { + throw "Invalid consent action returned $($invalid.ExitCode); expected 64." + } + $conflict = Invoke-Consent -Arguments '--telemetry-consent status --config missing.json' + if ($conflict.ExitCode -ne 64) { + throw "Consent/config conflict returned $($conflict.ExitCode); expected 64." + } + $afterSeparator = Invoke-Consent -Arguments '--unknown-flag -- --telemetry-consent=request' + if ($afterSeparator.ExitCode -ne 2) { + throw "Consent-like text after '--' returned $($afterSeparator.ExitCode); expected 2." + } + Write-Host ' ok: release CLI exit-code contract' +} + +function Test-FreshStore { + Remove-RealConsentFile + $fresh = Invoke-ConsentJson -Arguments '--telemetry-consent status' + Assert-Status $fresh 'undetermined' 'undetermined' 'unrestricted' $true 'fresh status' + Write-Host ' ok: real store resolves and reports undetermined when absent' +} + +function Test-PipedEofFailsClosed { + Remove-RealConsentFile + $piped = Invoke-Consent -Arguments '--telemetry-consent request --telemetry-consent-locale en-US' + if ($piped.ExitCode -ne 1) { + throw "Piped request exited $($piped.ExitCode); expected 1: $($piped.StdErr)" + } + $responses = @($piped.StdOut -split '\r?\n' | Where-Object { $_ } | ForEach-Object { $_ | ConvertFrom-Json }) + if ($responses.Count -ne 2 -or + $responses[0].result -ne 'presentationRequired' -or + $responses[1].result -ne 'presentationUnavailable') { + throw "Piped request returned an unexpected response: $($piped.StdOut)" + } + if (Test-Path $consentFile) { throw 'Piped request EOF created a consent record.' } + Write-Host ' ok: presenter EOF fails closed without creating the real store' +} + +function Test-RealStoreLifecycle { + $denied = Invoke-ConsentRequest -Decision 'no' + if ($denied.result -ne 'denied') { throw 'Explicit No was not persisted as Denied.' } + if (-not (Test-Path $consentFile)) { + throw "Explicit No did not create the store at the known-folder path '$consentFile'." + } + $record = Get-Content $consentFile -Raw | ConvertFrom-Json + if ($record.schemaVersion -ne 2 -or + $record.consent -ne 'denied' -or + $record.promptResourceVersion -ne 3 -or + $record.promptLocale -ne 'en-US') { + throw "The persisted record is malformed: $(Get-Content $consentFile -Raw)" + } + Assert-Status (Invoke-ConsentJson -Arguments '--telemetry-consent status') ` + 'denied' 'denied' 'unrestricted' $false 'denied status' + Write-Host " ok: store written to the real known-folder path" +} + +# The property the cfg gating exists for. Only provable against release. +function Test-OverridesAreCompiledOut { + $probeDir = Join-Path ([IO.Path]::GetTempPath()) "mxc_release_probe_$([guid]::NewGuid().ToString('N'))" + $probeStore = Join-Path $probeDir 'mxc' + New-Item -ItemType Directory -Path $probeStore -Force | Out-Null + $probeSubkey = "Software\MxcReleaseProbe\$([guid]::NewGuid().ToString('N'))" + $probePath = "HKCU:\$probeSubkey" + try { + # A store override, if honored, would report granted instead of denied. + $record = [pscustomobject]@{ + schemaVersion = 2 + consent = 'granted' + source = 'release-path-test-probe' + promptedMxcVersion = '0.0.0-release-test' + promptResourceVersion = 3 + promptLocale = 'en-US' + updatedAtEpoch = 0 + } + [IO.File]::WriteAllText( + (Join-Path $probeStore 'telemetry-consent.json'), + ($record | ConvertTo-Json -Compress), + (New-Object Text.UTF8Encoding $false)) + + # A policy override, if honored, would report blocked instead of unrestricted. + New-Item -Path $probePath -Force | Out-Null + Set-ItemProperty -Path $probePath -Name AllowTelemetry -Value 0 -Type DWord + + $status = Invoke-ConsentJson -Arguments '--telemetry-consent status' -Environment @{ + MXC_TEST_LOCALAPPDATA_OVERRIDE = $probeDir + MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID = "$PID" + MXC_TEST_POLICY_KEY_OVERRIDE = $probeSubkey + MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID = "$PID" + } + if ($status.storedState -ne 'denied') { + throw "MXC_TEST_LOCALAPPDATA_OVERRIDE redirected a release binary (storedState '$($status.storedState)'). The debug override is not compiled out." + } + if ($status.policy -ne 'unrestricted') { + throw "MXC_TEST_POLICY_KEY_OVERRIDE redirected a release binary (policy '$($status.policy)'). The debug override is not compiled out." + } + Write-Host ' ok: debug store and policy overrides are compiled out' + } + finally { + Remove-Item -Recurse -Force $probeDir -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force $probePath -ErrorAction SilentlyContinue + } +} + +function Test-GrantAndCorruptionRecovery { + $granted = Invoke-ConsentRequest -Decision 'yes' + if ($granted.result -ne 'granted') { throw 'Explicit Yes was not persisted as Granted.' } + Assert-Status (Invoke-ConsentJson -Arguments '--telemetry-consent status') ` + 'granted' 'granted' 'unrestricted' $false 'granted status' + + [IO.File]::WriteAllText($consentFile, 'not json at all', (New-Object Text.UTF8Encoding $false)) + $corrupt = Invoke-ConsentJson -Arguments '--telemetry-consent status' + if ($corrupt.effectiveState -eq 'granted') { + throw 'A corrupted real store was treated as a grant.' + } + + Write-ConsentRecord -Consent 'granted' -PromptVersion 999 + $stale = Invoke-ConsentJson -Arguments '--telemetry-consent status' + if ($stale.storedState -ne 'granted' -or $stale.effectiveState -ne 'undetermined') { + throw "A grant for an unsupported prompt version authorized collection: $($stale | ConvertTo-Json -Compress)" + } + Write-Host ' ok: corrupt and stale-prompt records fail closed at the real path' +} + +function Test-PolicyCeiling { + Write-ConsentRecord -Consent 'granted' + Assert-Status (Invoke-ConsentJson -Arguments '--telemetry-consent status') ` + 'granted' 'granted' 'unrestricted' $false 'policy absent' + + New-Item -Path $policyKey -Force | Out-Null + Set-ItemProperty -Path $policyKey -Name AllowTelemetry -Value 0 -Type DWord + $blocked = Invoke-ConsentRequest -Decision 'yes' + if ($blocked.result -ne 'policyBlocked') { + throw "Blocked HKLM policy did not suppress presentation: $($blocked | ConvertTo-Json -Compress)" + } + Assert-Status $blocked 'granted' 'granted' 'blocked' $false 'blocked status' + + Set-ItemProperty -Path $policyKey -Name AllowTelemetry -Value 3 -Type DWord + Assert-Status (Invoke-ConsentJson -Arguments '--telemetry-consent status') ` + 'granted' 'granted' 'allowed' $false 'allowed status' + + Set-ItemProperty -Path $policyKey -Name AllowTelemetry -Value 0 -Type DWord + $withdrawn = Invoke-ConsentJson -Arguments '--telemetry-consent withdraw' + if ($withdrawn.result -ne 'withdrawn') { throw 'Withdrawal while blocked did not report withdrawn.' } + Assert-Status $withdrawn 'denied' 'denied' 'blocked' $false 'withdrawal while blocked' + $again = Invoke-ConsentJson -Arguments '--telemetry-consent withdraw' + if ($again.result -ne 'withdrawn') { throw 'Repeated withdrawal was not idempotent.' } + Write-Host ' ok: real HKLM policy ceiling gates presentation and never grants' +} + +# --- Run ------------------------------------------------------------------- + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = New-Object Security.Principal.WindowsPrincipal($identity) +$isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + +$mxcDirPreexisted = Test-Path $mxcDir +$consentBackup = if (Test-Path $consentFile) { [IO.File]::ReadAllBytes($consentFile) } else { $null } +$policyKeyPreexisted = Test-Path $policyKey +$policyValueBackup = $null +if ($policyKeyPreexisted) { + $policyValueBackup = (Get-ItemProperty -Path $policyKey -Name AllowTelemetry -ErrorAction SilentlyContinue).AllowTelemetry +} + +Write-Host "Release executor : $exe" +Write-Host "Consent store : $consentFile" +Write-Host "Policy key : $policyKey" + +try { + if (-not $mxcDirPreexisted) { New-Item -ItemType Directory -Path $mxcDir -Force | Out-Null } + + Test-CliContract + Test-FreshStore + Test-PipedEofFailsClosed + Test-RealStoreLifecycle + Test-OverridesAreCompiledOut + Test-GrantAndCorruptionRecovery + + if ($isAdmin) { + Test-PolicyCeiling + } + else { + Write-Host ' skipped: HKLM policy ceiling (requires Administrator)' -ForegroundColor Yellow + } + + Write-Host 'PASSED: release-path telemetry consent test' -ForegroundColor Green +} +finally { + if ($null -ne $consentBackup) { + New-Item -ItemType Directory -Path $mxcDir -Force | Out-Null + [IO.File]::WriteAllBytes($consentFile, $consentBackup) + } + elseif ($mxcDirPreexisted) { + Remove-RealConsentFile + } + else { + Remove-Item -Recurse -Force $mxcDir -ErrorAction SilentlyContinue + } + + if ($isAdmin) { + if (-not $policyKeyPreexisted) { + Remove-Item -Recurse -Force $policyKey -ErrorAction SilentlyContinue + } + elseif ($null -eq $policyValueBackup) { + Remove-ItemProperty -Path $policyKey -Name AllowTelemetry -ErrorAction SilentlyContinue + } + else { + Set-ItemProperty -Path $policyKey -Name AllowTelemetry -Value $policyValueBackup -Type DWord + } + } +} From f0f1afee49b85f6ddb782c0647534033e39ed13d Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:06:53 -0700 Subject: [PATCH 3/9] trigger workflow --- .github/workflows/Temp.Telemetry.Release.Test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/Temp.Telemetry.Release.Test.yml b/.github/workflows/Temp.Telemetry.Release.Test.yml index 32cb453fc..423ee49e2 100644 --- a/.github/workflows/Temp.Telemetry.Release.Test.yml +++ b/.github/workflows/Temp.Telemetry.Release.Test.yml @@ -8,7 +8,9 @@ name: TEMP - Telemetry release-path test on: - workflow_dispatch: + push: + branches: + - user/emichlin/telemetry-smoke-arm64 permissions: contents: read From 41f50cd2d7ca570941bf4c2a81cfe0951f369e13 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:37:28 -0700 Subject: [PATCH 4/9] add switch to require admin --- .github/workflows/Temp.Telemetry.Release.Test.yml | 3 ++- tests/scripts/README.md | 2 +- tests/scripts/run_telemetry_consent_release_test.ps1 | 11 ++++++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/Temp.Telemetry.Release.Test.yml b/.github/workflows/Temp.Telemetry.Release.Test.yml index 423ee49e2..426ed0bee 100644 --- a/.github/workflows/Temp.Telemetry.Release.Test.yml +++ b/.github/workflows/Temp.Telemetry.Release.Test.yml @@ -60,4 +60,5 @@ jobs: run: | tests\scripts\run_telemetry_consent_release_test.ps1 ` -BinDir src\target\${{ matrix.target }}\release ` - -AcceptRealMachineMutation + -AcceptRealMachineMutation ` + -RequirePolicyCeiling diff --git a/tests/scripts/README.md b/tests/scripts/README.md index f801c9219..37297c779 100644 --- a/tests/scripts/README.md +++ b/tests/scripts/README.md @@ -48,7 +48,7 @@ Linux / macOS (`.sh`): | `T3-Workloads.ps1` | Real workloads (pwsh, git, node, python, cmd) on top of the T3 primitives. A missing interpreter is reported as a skip, not a failure | `wxc-exec.exe`; `pwsh` / `git` / `node` / `python` each optional, gating their own cases | | `run_telemetry_consent_smoke_test.ps1` | Consent maintenance, presentation, policy, and exit-code smoke tests | Debug `wxc-exec.exe` built with `test-support` | | `run_telemetry_etw_smoke_test.ps1` | Isolated consent flow plus public-provider ETW capture | Debug `wxc-exec.exe` built with `test-support`; ETW tooling; Administrator, otherwise the test skips | -| `run_telemetry_consent_release_test.ps1` | Consent path in a **release** executor, where the debug store/policy overrides are compiled out. Mutates the real consent store and HKLM policy, so it requires `-AcceptRealMachineMutation` and an ephemeral machine | Release `wxc-exec.exe`; Administrator for the HKLM policy section, otherwise that section skips | +| `run_telemetry_consent_release_test.ps1` | Consent path in a **release** executor, where the debug store/policy overrides are compiled out. Mutates the real consent store and HKLM policy, so it requires `-AcceptRealMachineMutation` and an ephemeral machine | Release `wxc-exec.exe`; Administrator for the HKLM policy section, otherwise that section skips unless `-RequirePolicyCeiling` is passed | | `run_on_repeat.ps1` | Stress test (loops core tests) | `wxc-exec.exe` | Each `run_processcontainer__test.ps1` also runs standalone against a diff --git a/tests/scripts/run_telemetry_consent_release_test.ps1 b/tests/scripts/run_telemetry_consent_release_test.ps1 index 56d509f76..130521395 100644 --- a/tests/scripts/run_telemetry_consent_release_test.ps1 +++ b/tests/scripts/run_telemetry_consent_release_test.ps1 @@ -25,12 +25,18 @@ .PARAMETER AcceptRealMachineMutation Required acknowledgement. There is deliberately no CI auto-detection: self-hosted runners also set GITHUB_ACTIONS, and those machines persist. + +.PARAMETER RequirePolicyCeiling + Fails instead of skipping when the session is not elevated. CI passes this + so a runner that stops being elevated surfaces as a failure rather than + silently dropping the only coverage of the real HKLM policy key. #> [CmdletBinding()] param( [string]$BinDir, - [switch]$AcceptRealMachineMutation + [switch]$AcceptRealMachineMutation, + [switch]$RequirePolicyCeiling ) $ErrorActionPreference = 'Stop' @@ -356,6 +362,9 @@ function Test-PolicyCeiling { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) $isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if ($RequirePolicyCeiling -and -not $isAdmin) { + throw 'HKLM policy ceiling coverage was required but this session is not elevated.' +} $mxcDirPreexisted = Test-Path $mxcDir $consentBackup = if (Test-Path $consentFile) { [IO.File]::ReadAllBytes($consentFile) } else { $null } From c5201e912eee64bfb77ed2e858e51f817f546e72 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:54:41 -0700 Subject: [PATCH 5/9] Add script to build workflow --- .github/workflows/Build.Windows.Job.yml | 11 ++++ .../workflows/Temp.Telemetry.Release.Test.yml | 64 ------------------- 2 files changed, 11 insertions(+), 64 deletions(-) delete mode 100644 .github/workflows/Temp.Telemetry.Release.Test.yml diff --git a/.github/workflows/Build.Windows.Job.yml b/.github/workflows/Build.Windows.Job.yml index 9572c1091..d4d89372b 100644 --- a/.github/workflows/Build.Windows.Job.yml +++ b/.github/workflows/Build.Windows.Job.yml @@ -89,6 +89,17 @@ jobs: shell: pwsh run: tests\scripts\run_telemetry_etw_smoke_test.ps1 -BinDir src\target\debug + # Mutates the real consent store and HKLM policy, so it is confined to + # the ephemeral GitHub-hosted runners this job declares. + - name: Run release-path telemetry consent test + working-directory: ${{ github.workspace }} + shell: pwsh + run: | + tests\scripts\run_telemetry_consent_release_test.ps1 ` + -BinDir src\target\${{ matrix.target }}\release ` + -AcceptRealMachineMutation ` + -RequirePolicyCeiling + - name: Upload binaries uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/Temp.Telemetry.Release.Test.yml b/.github/workflows/Temp.Telemetry.Release.Test.yml deleted file mode 100644 index 426ed0bee..000000000 --- a/.github/workflows/Temp.Telemetry.Release.Test.yml +++ /dev/null @@ -1,64 +0,0 @@ -# TEMPORARY. Validates tests/scripts/run_telemetry_consent_release_test.ps1 -# against GitHub-hosted runners before the release-path test is wired into -# Build.Windows.Job.yml. Delete once that lands. See issue #691. -# -# The test mutates the real per-user consent store and the real HKLM policy -# key, so it must only ever run on an ephemeral GitHub-hosted runner. Do not -# add self-hosted labels to this workflow. -name: TEMP - Telemetry release-path test - -on: - push: - branches: - - user/emichlin/telemetry-smoke-arm64 - -permissions: - contents: read - -jobs: - release-consent: - name: ${{ matrix.arch }} - strategy: - fail-fast: false - matrix: - include: - - arch: x64 - runner: windows-2025 - target: x86_64-pc-windows-msvc - - arch: arm64 - runner: windows-11-arm - target: aarch64-pc-windows-msvc - runs-on: ${{ matrix.runner }} - defaults: - run: - working-directory: src - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Surface toolchain file at repo root - shell: bash - working-directory: ${{ github.workspace }} - run: cp src/rust-toolchain.toml rust-toolchain.toml - - - uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0 - with: - target: ${{ matrix.target }} - override: false - rustflags: '' - - - name: Point cargo at the MxcDependencies feed - uses: ./.github/actions/setup-cargo-feed - - # Consent is backend-independent and wxc defaults to no backend - # features, so the default release build is enough. - - name: Build release executor - run: cargo build --locked --release --target ${{ matrix.target }} -p wxc - - - name: Run release-path telemetry consent test - working-directory: ${{ github.workspace }} - shell: pwsh - run: | - tests\scripts\run_telemetry_consent_release_test.ps1 ` - -BinDir src\target\${{ matrix.target }}\release ` - -AcceptRealMachineMutation ` - -RequirePolicyCeiling From 7a091a25c094568717dac59c6ef20b1ece57ba83 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:08:44 -0700 Subject: [PATCH 6/9] lock files not handled properly --- tests/scripts/run_telemetry_consent_release_test.ps1 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/scripts/run_telemetry_consent_release_test.ps1 b/tests/scripts/run_telemetry_consent_release_test.ps1 index 130521395..b0a8a9375 100644 --- a/tests/scripts/run_telemetry_consent_release_test.ps1 +++ b/tests/scripts/run_telemetry_consent_release_test.ps1 @@ -63,6 +63,9 @@ $localAppData = [Environment]::GetFolderPath('LocalApplicationData') if (-not $localAppData) { throw 'Could not resolve the LocalApplicationData known folder.' } $mxcDir = Join-Path $localAppData 'mxc' $consentFile = Join-Path $mxcDir 'telemetry-consent.json' +# The executor creates this alongside the store and leaves it behind once the +# lock is released, so cleanup has to account for it too. +$lockFile = Join-Path $mxcDir 'telemetry-consent.lock' $policyKey = 'HKLM:\SOFTWARE\Policies\Mxc' $expectedBody = @' @@ -368,6 +371,7 @@ if ($RequirePolicyCeiling -and -not $isAdmin) { $mxcDirPreexisted = Test-Path $mxcDir $consentBackup = if (Test-Path $consentFile) { [IO.File]::ReadAllBytes($consentFile) } else { $null } +$lockFilePreexisted = Test-Path $lockFile $policyKeyPreexisted = Test-Path $policyKey $policyValueBackup = $null if ($policyKeyPreexisted) { @@ -409,6 +413,10 @@ finally { Remove-Item -Recurse -Force $mxcDir -ErrorAction SilentlyContinue } + if ($mxcDirPreexisted -and -not $lockFilePreexisted) { + Remove-Item -Force $lockFile -ErrorAction SilentlyContinue + } + if ($isAdmin) { if (-not $policyKeyPreexisted) { Remove-Item -Recurse -Force $policyKey -ErrorAction SilentlyContinue From ba244a8711f83b5bf2291b1903da27e49c84adf6 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:19:24 -0700 Subject: [PATCH 7/9] backup withdrawl file --- .../run_telemetry_consent_release_test.ps1 | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/scripts/run_telemetry_consent_release_test.ps1 b/tests/scripts/run_telemetry_consent_release_test.ps1 index b0a8a9375..70663478d 100644 --- a/tests/scripts/run_telemetry_consent_release_test.ps1 +++ b/tests/scripts/run_telemetry_consent_release_test.ps1 @@ -66,6 +66,9 @@ $consentFile = Join-Path $mxcDir 'telemetry-consent.json' # The executor creates this alongside the store and leaves it behind once the # lock is released, so cleanup has to account for it too. $lockFile = Join-Path $mxcDir 'telemetry-consent.lock' +# A pending marker means an interrupted withdrawal, which reads fail-closed. +# Every consent write clears it, so it has to be backed up and restored. +$withdrawalFile = Join-Path $mxcDir 'telemetry-consent.withdrawal-pending' $policyKey = 'HKLM:\SOFTWARE\Policies\Mxc' $expectedBody = @' @@ -372,6 +375,12 @@ if ($RequirePolicyCeiling -and -not $isAdmin) { $mxcDirPreexisted = Test-Path $mxcDir $consentBackup = if (Test-Path $consentFile) { [IO.File]::ReadAllBytes($consentFile) } else { $null } $lockFilePreexisted = Test-Path $lockFile +$withdrawalBackup = $null +$withdrawalBackupWritten = $null +if (Test-Path $withdrawalFile) { + $withdrawalBackup = [IO.File]::ReadAllBytes($withdrawalFile) + $withdrawalBackupWritten = [IO.File]::GetLastWriteTimeUtc($withdrawalFile) +} $policyKeyPreexisted = Test-Path $policyKey $policyValueBackup = $null if ($policyKeyPreexisted) { @@ -417,6 +426,16 @@ finally { Remove-Item -Force $lockFile -ErrorAction SilentlyContinue } + if ($null -ne $withdrawalBackup) { + New-Item -ItemType Directory -Path $mxcDir -Force | Out-Null + [IO.File]::WriteAllBytes($withdrawalFile, $withdrawalBackup) + # The marker goes stale on mtime, so let it keep aging from where it was. + [IO.File]::SetLastWriteTimeUtc($withdrawalFile, $withdrawalBackupWritten) + } + elseif ($mxcDirPreexisted) { + Remove-Item -Force $withdrawalFile -ErrorAction SilentlyContinue + } + if ($isAdmin) { if (-not $policyKeyPreexisted) { Remove-Item -Recurse -Force $policyKey -ErrorAction SilentlyContinue From bf47034809e58698698cd28bb88e5e9bc10514db Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:33:26 -0700 Subject: [PATCH 8/9] policy value kind is now restored --- .../run_telemetry_consent_release_test.ps1 | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/scripts/run_telemetry_consent_release_test.ps1 b/tests/scripts/run_telemetry_consent_release_test.ps1 index 70663478d..7c5aad21f 100644 --- a/tests/scripts/run_telemetry_consent_release_test.ps1 +++ b/tests/scripts/run_telemetry_consent_release_test.ps1 @@ -69,7 +69,8 @@ $lockFile = Join-Path $mxcDir 'telemetry-consent.lock' # A pending marker means an interrupted withdrawal, which reads fail-closed. # Every consent write clears it, so it has to be backed up and restored. $withdrawalFile = Join-Path $mxcDir 'telemetry-consent.withdrawal-pending' -$policyKey = 'HKLM:\SOFTWARE\Policies\Mxc' +$policySubKey = 'SOFTWARE\Policies\Mxc' +$policyKey = "HKLM:\$policySubKey" $expectedBody = @' Help improve MXC and other Microsoft product including Windows by sharing optional diagnostic data with Microsoft. @@ -383,8 +384,22 @@ if (Test-Path $withdrawalFile) { } $policyKeyPreexisted = Test-Path $policyKey $policyValueBackup = $null +# The value kind is part of the state: policy.rs fail-closes on a non-DWORD +# AllowTelemetry, so restoring a REG_SZ as a DWORD would change the machine. +$policyValueKind = $null if ($policyKeyPreexisted) { - $policyValueBackup = (Get-ItemProperty -Path $policyKey -Name AllowTelemetry -ErrorAction SilentlyContinue).AllowTelemetry + $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($policySubKey) + if ($key) { + try { + $policyValueBackup = $key.GetValue( + 'AllowTelemetry', $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -ne $policyValueBackup) { + $policyValueKind = $key.GetValueKind('AllowTelemetry') + } + } + finally { $key.Dispose() } + } } Write-Host "Release executor : $exe" @@ -440,11 +455,13 @@ finally { if (-not $policyKeyPreexisted) { Remove-Item -Recurse -Force $policyKey -ErrorAction SilentlyContinue } - elseif ($null -eq $policyValueBackup) { + elseif ($null -eq $policyValueKind) { Remove-ItemProperty -Path $policyKey -Name AllowTelemetry -ErrorAction SilentlyContinue } else { - Set-ItemProperty -Path $policyKey -Name AllowTelemetry -Value $policyValueBackup -Type DWord + $key = [Microsoft.Win32.Registry]::LocalMachine.CreateSubKey($policySubKey) + try { $key.SetValue('AllowTelemetry', $policyValueBackup, $policyValueKind) } + finally { $key.Dispose() } } } } From 0941b57fef4d0dcf9de8f036e99aa2e2c6ac06d6 Mon Sep 17 00:00:00 2001 From: Elliot <31219104+theelliotm@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:44:24 -0700 Subject: [PATCH 9/9] Fix process exit handling in telemetry consent test Ensure the process waits for exit after killing it. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/scripts/run_telemetry_consent_release_test.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/scripts/run_telemetry_consent_release_test.ps1 b/tests/scripts/run_telemetry_consent_release_test.ps1 index 7c5aad21f..f032f8ebf 100644 --- a/tests/scripts/run_telemetry_consent_release_test.ps1 +++ b/tests/scripts/run_telemetry_consent_release_test.ps1 @@ -165,6 +165,7 @@ function Invoke-ConsentRequest { $finalRead = $process.StandardOutput.ReadLineAsync() if (-not $finalRead.Wait(5000)) { $process.Kill() + $process.WaitForExit() throw 'Consent process waited for stdin EOF instead of accepting the decision line.' } $process.StandardInput.Close()