# WinPE Pre-Config Collector Implementation Plan (SP1) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** A branded WinPE collector runs before Windows Setup, captures identity + install-shaping choices, generates the answer file so Setup creates the real local-admin account natively (no `sm-bootstrap`), and hands the rest (flavour, BitLocker PIN, app defaults) to a simplified run-once-then-persist first-boot toolbox. **Architecture:** Pure-PowerShell collector logic (validation + answer-file generation) that is unit-tested headless with Pester, wrapped by a WinForms shell launched from `boot.wim` via `winpeshl.ini`. The collector writes a generated answer file plus a base64-embedded `preconfig.json` carried into the installed OS via the `specialize` pass. The existing MAUI Welcome app is trimmed into the toolbox: account creation, `sm-bootstrap` teardown, and the heavy kiosk are removed; a `PreconfigLoader` pre-seeds state and Apply becomes `apps -> bitlocker -> done`. **Tech Stack:** PowerShell 5.1 + WinForms (WinPE `WinPE-NetFx`/`WinPE-PowerShell`), Pester v5, .NET 9 / C# (SilverOS.Welcome), xUnit + Moq, DISM (`Add-WindowsPackage`), `oscdimg`. **Spec:** [`../specs/2026-06-10-winpe-preconfig-collector-design.md`](../specs/2026-06-10-winpe-preconfig-collector-design.md) **Branch:** `docs/winpe-preconfig-collector` (spec committed at `59418e3`). Implementation continues on this branch (rename/PR at the end is fine). **Conventions:** - Pester runs v5 under `pwsh` via a config object (the CI pattern): `New-PesterConfiguration`; `$cfg.Run.Path = '...'`; `Invoke-Pester -Configuration $cfg`. Run locally: `pwsh -NoProfile -Command "$c=New-PesterConfiguration; $c.Run.Path='windows/tests/Collector.Tests.ps1'; $c.Output.Verbosity='Detailed'; Invoke-Pester -Configuration $c"` (install Pester 5 first if absent: `Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser -Force -SkipPublisherCheck`). - PowerShell files run by Windows PE / Windows-PowerShell 5.1 must be **ASCII / UTF-8-with-BOM** and contain **no em-dashes or smart quotes** (mojibake breaks parsing — a repeat bug in this repo). Use ASCII hyphens. - C# tests: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release`. - The collector logic must be split so the **testable parts have no WinForms dependency** (Pester can't load WinForms reliably headless and we don't want UI in unit tests). --- ## File structure ``` windows/collector/ Test-SmInput.ps1 # pure validation functions (no WinForms) -- Pester-tested New-SmAnswerFile.ps1 # pure answer-file generator (no WinForms) -- Pester-tested Collector.ps1 # WinForms shell: builds the form, calls the two above, launches Setup Start-Collector.cmd # winpeshl entry point -> powershell Collector.ps1 (with fallback) winpeshl.ini # tells WinPE to run Start-Collector.cmd instead of Setup assets/sm-logo.png # collector branding (optional; form degrades without it) windows/tests/ Collector.Tests.ps1 # Pester: Test-SmInput + New-SmAnswerFile windows/welcome/src/SilverOS.Welcome.Core/Preconfig/ Preconfig.cs # record + JsonOptions IPreconfigStore.cs / PreconfigStore.cs # load + clear-pin + configured-marker windows/welcome/tests/SilverOS.Welcome.Tests/ PreconfigTests.cs # xUnit for PreconfigStore ``` Modified: `windows/installer/build.ps1` (Stage 2b boot.wim), `windows/welcome/src/SilverOS.Welcome.Core/Apply/{ApplyRequest,ApplyService}.cs`, `windows/welcome/src/SilverOS.Welcome.UI/Components/{Routes.razor,WizardState.cs}`, `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor`, `windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs`, `windows/installer/autounattend/SetupComplete` flow, `windows/tests/Assert-IsoStructure.ps1`. --- ## Phase A — Collector validation logic (Pester TDD) ### Task A1: `Test-SmInput` validation functions **Files:** - Create: `windows/collector/Test-SmInput.ps1` - Create: `windows/tests/Collector.Tests.ps1` - [ ] **Step 1: Write the failing Pester tests** (`windows/tests/Collector.Tests.ps1`) ```powershell #Requires -Version 5.1 . (Join-Path $PSScriptRoot '..\collector\Test-SmInput.ps1') Describe 'Test-SmUsername' { It 'accepts a simple username' { (Test-SmUsername 'jamie').Ok | Should -BeTrue } It 'rejects empty' { (Test-SmUsername '').Ok | Should -BeFalse } It 'rejects reserved name' { (Test-SmUsername 'Administrator').Ok | Should -BeFalse } It 'rejects illegal chars' { (Test-SmUsername 'a\b').Ok | Should -BeFalse } It 'rejects > 20 chars' { (Test-SmUsername ('x'*21)).Ok| Should -BeFalse } } Describe 'Test-SmPassword' { It 'accepts matching 8+ char password' { (Test-SmPassword 'Sup3rPass!' 'Sup3rPass!').Ok | Should -BeTrue } It 'rejects mismatch' { (Test-SmPassword 'a' 'b').Ok | Should -BeFalse } It 'rejects < 8 chars' { (Test-SmPassword 'short' 'short').Ok | Should -BeFalse } } Describe 'Test-SmPin' { It 'accepts 6-digit matching pin' { (Test-SmPin '246810' '246810').Ok | Should -BeTrue } It 'rejects < 6 digits' { (Test-SmPin '123' '123').Ok | Should -BeFalse } It 'rejects non-numeric' { (Test-SmPin 'abcdef' 'abcdef').Ok | Should -BeFalse } It 'rejects mismatch' { (Test-SmPin '246810' '999999').Ok | Should -BeFalse } } Describe 'Test-SmComputerName' { It 'accepts a valid name' { (Test-SmComputerName 'SILVER-01').Ok | Should -BeTrue } It 'rejects empty' { (Test-SmComputerName '').Ok | Should -BeFalse } It 'rejects > 15 chars' { (Test-SmComputerName ('A'*16)).Ok | Should -BeFalse } It 'rejects illegal chars' { (Test-SmComputerName 'bad name').Ok | Should -BeFalse } } ``` - [ ] **Step 2: Run to verify it fails** Run: `pwsh -NoProfile -Command "$c=New-PesterConfiguration; $c.Run.Path='windows/tests/Collector.Tests.ps1'; $c.Output.Verbosity='Detailed'; Invoke-Pester -Configuration $c"` Expected: FAIL — `Test-SmUsername` not recognized. - [ ] **Step 3: Implement** (`windows/collector/Test-SmInput.ps1`, ASCII + UTF-8-BOM) ```powershell #Requires -Version 5.1 # Pure validation helpers for the WinPE collector. No WinForms dependency so they # are unit-testable headless. Each returns [pscustomobject]@{ Ok=[bool]; Message=[string] }. function New-SmResult([bool]$ok, [string]$msg = '') { [pscustomobject]@{ Ok = $ok; Message = $msg } } $script:SmReserved = @('administrator','guest','system','defaultaccount','wdagutilityaccount','sm-bootstrap') function Test-SmUsername([string]$name) { if ([string]::IsNullOrWhiteSpace($name)) { return New-SmResult $false 'Username is required.' } if ($name.Length -gt 20) { return New-SmResult $false 'Username must be 20 characters or fewer.' } if ($script:SmReserved -contains $name.ToLower()) { return New-SmResult $false 'That username is reserved.' } if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]*$') { return New-SmResult $false 'Username has illegal characters.' } New-SmResult $true } function Test-SmPassword([string]$pw, [string]$confirm) { if ([string]::IsNullOrEmpty($pw)) { return New-SmResult $false 'Password is required.' } if ($pw.Length -lt 8) { return New-SmResult $false 'Password must be at least 8 characters.' } if ($pw -ne $confirm) { return New-SmResult $false 'Passwords do not match.' } New-SmResult $true } function Test-SmPin([string]$pin, [string]$confirm) { if ($pin -notmatch '^[0-9]+$') { return New-SmResult $false 'PIN must be numeric.' } if ($pin.Length -lt 6) { return New-SmResult $false 'PIN must be at least 6 digits.' } if ($pin -ne $confirm) { return New-SmResult $false 'PINs do not match.' } New-SmResult $true } function Test-SmComputerName([string]$name) { if ([string]::IsNullOrWhiteSpace($name)) { return New-SmResult $false 'Computer name is required.' } if ($name.Length -gt 15) { return New-SmResult $false 'Computer name must be 15 characters or fewer.' } if ($name -notmatch '^[A-Za-z0-9-]+$') { return New-SmResult $false 'Computer name: letters, digits, hyphens only.' } New-SmResult $true } ``` - [ ] **Step 4: Run to verify it passes** Run the same Pester command. Expected: PASS (all `Test-Sm*` contexts green). - [ ] **Step 5: Commit** ```bash git add windows/collector/Test-SmInput.ps1 windows/tests/Collector.Tests.ps1 git commit -m "feat(collector): WinPE input validation helpers + Pester tests" ``` --- ## Phase B — Answer-file generator (Pester TDD) ### Task B1: `New-SmAnswerFile` **Files:** - Create: `windows/collector/New-SmAnswerFile.ps1` - Modify (append): `windows/tests/Collector.Tests.ps1` - [ ] **Step 1: Append failing tests** to `windows/tests/Collector.Tests.ps1` ```powershell . (Join-Path $PSScriptRoot '..\collector\New-SmAnswerFile.ps1') Describe 'New-SmAnswerFile' { $cfg = @{ DisplayName = 'Jamie' Username = 'jamie' Password = 'Sup3rPass!' ComputerName = 'SILVER-01' InputLocale = '0809:00000809' SystemLocale = 'en-GB' UiLanguage = 'en-US' UserLocale = 'en-GB' Flavour = 'developer' BitLockerEnable = $true BitLockerPin = '246810' } $xml = New-SmAnswerFile @cfg $doc = [xml]$xml It 'is valid XML' { { [xml]$xml } | Should -Not -Throw } It 'creates the real account in Administrators' { $xml | Should -Match 'jamie' $xml | Should -Match 'Administrators' } It 'does NOT contain sm-bootstrap' { $xml | Should -Not -Match 'sm-bootstrap' } It 'sets AutoLogon once as the user' { $xml | Should -Match '1' $xml | Should -Match 'jamie' } It 'sets the computer name' { $xml | Should -Match 'SILVER-01' } It 'keeps WillWipeDisk for disk 0' { $xml | Should -Match 'true' } It 'embeds a base64 preconfig write in specialize' { $xml | Should -Match 'preconfig\.json' $xml | Should -Match 'FromBase64String' } It 'embedded preconfig round-trips with the flavour and pin' { $m = [regex]::Match($xml, "FromBase64String\('([^']+)'\)") $m.Success | Should -BeTrue $json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($m.Groups[1].Value)) | ConvertFrom-Json $json.flavour | Should -Be 'developer' $json.bitlocker.pin | Should -Be '246810' } It 'launches the toolbox in FirstLogonCommands' { $xml | Should -Match 'SilverOS\.Welcome\.App\.exe' } } ``` - [ ] **Step 2: Run to verify it fails** (same Pester command). Expected: FAIL — `New-SmAnswerFile` not defined. - [ ] **Step 3: Implement** (`windows/collector/New-SmAnswerFile.ps1`, ASCII + UTF-8-BOM) ```powershell #Requires -Version 5.1 # Pure generator: collected values -> Windows Setup answer-file XML string. # No WinForms dependency (unit-testable). Mirrors the legacy autounattend.xml but with # ONE real local-admin account (no sm-bootstrap) and an embedded preconfig.json that a # specialize-pass command writes to C:\ProgramData\SilverMetal\preconfig.json. function New-SmAnswerFile { param( [string]$DisplayName, [string]$Username, [string]$Password, [string]$ComputerName, [string]$InputLocale = '0809:00000809', [string]$SystemLocale = 'en-GB', [string]$UiLanguage = 'en-US', [string]$UserLocale = 'en-GB', [string]$Flavour, [bool]$BitLockerEnable = $false, [string]$BitLockerPin = '' ) # Build the carried-forward config and base64-embed it. $pre = [ordered]@{ schemaVersion = 1 flavour = $Flavour bitlocker = [ordered]@{ enable = [bool]$BitLockerEnable; pin = $BitLockerPin } apps = [ordered]@{ useFlavourDefaults = $true } } $preJson = ($pre | ConvertTo-Json -Depth 6 -Compress) $preB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($preJson)) # XML-escape user-supplied strings. function Esc([string]$s) { [Security.SecurityElement]::Escape($s) } $dn = Esc $DisplayName; $un = Esc $Username; $pw = Esc $Password; $cn = Esc $ComputerName # specialize command: recreate dir + decode the embedded preconfig to C:. $writePre = "powershell -NoProfile -ExecutionPolicy Bypass -Command "" New-Item -ItemType Directory -Force 'C:\ProgramData\SilverMetal' | Out-Null; [IO.File]::WriteAllBytes('C:\ProgramData\SilverMetal\preconfig.json', [Convert]::FromBase64String('$preB64')) """ @" $UiLanguage $InputLocale$SystemLocale $UiLanguage$UserLocale OnError 0true 1EFI300 2MSR16 3Primarytrue 11FAT32 22 33NTFSC 03 /IMAGE/INDEX1 true 1 $([Security.SecurityElement]::Escape($writePre)) Write SilverMetal preconfig $InputLocale$SystemLocale $UiLanguage$UiLanguage$UserLocale truetruetruetruetrue3 $unAdministrators$dn $pwtrue</PlainText></Password> </LocalAccount> </LocalAccounts></UserAccounts> <AutoLogon><Enabled>true</Enabled><LogonCount>1</LogonCount><Username>$un</Username><Password><Value>$pw</Value><PlainText>true</PlainText></Password></AutoLogon> <ComputerName>$cn</ComputerName> <FirstLogonCommands> <SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"> <Order>1</Order> <CommandLine>cmd /c powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath 'C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe' -Verb RunAs"</CommandLine> <Description>Launch SilverMetal toolbox (run-once)</Description> </SynchronousCommand> </FirstLogonCommands> <RegisteredOwner>SilverMetal</RegisteredOwner><RegisteredOrganization>SilverLABS</RegisteredOrganization> </component> </settings> </unattend> "@ } ``` > Note: `ComputerName` is set in `oobeSystem` here for simplicity; if 24H2 ignores it there, move it to a `specialize` `Microsoft-Windows-Shell-Setup` component in a follow-up — the test only asserts the element is present. - [ ] **Step 4: Run to verify it passes** (same Pester command). Expected: PASS (all `New-SmAnswerFile` assertions, incl. base64 round-trip). - [ ] **Step 5: Commit** ```bash git add windows/collector/New-SmAnswerFile.ps1 windows/tests/Collector.Tests.ps1 git commit -m "feat(collector): answer-file generator (real account, no sm-bootstrap, embedded preconfig)" ``` --- ## Phase C — Preconfig consumer in the toolbox (xUnit TDD) ### Task C1: `Preconfig` record + `PreconfigStore` **Files:** - Create: `windows/welcome/src/SilverOS.Welcome.Core/Preconfig/Preconfig.cs` - Create: `windows/welcome/src/SilverOS.Welcome.Core/Preconfig/IPreconfigStore.cs` - Create: `windows/welcome/src/SilverOS.Welcome.Core/Preconfig/PreconfigStore.cs` - Create test: `windows/welcome/tests/SilverOS.Welcome.Tests/PreconfigTests.cs` - [ ] **Step 1: Write failing tests** (`PreconfigTests.cs`) ```csharp using System.IO; using System.Text.Json; using SilverOS.Welcome.Core.Preconfig; using Xunit; public class PreconfigTests { static string TempDir() { var d = Path.Combine(Path.GetTempPath(), "smpre-" + System.Guid.NewGuid().ToString("N")); Directory.CreateDirectory(d); return d; } const string Sample = """ { "schemaVersion":1, "flavour":"developer", "bitlocker":{"enable":true,"pin":"246810"}, "apps":{"useFlavourDefaults":true} } """; [Fact] public void Loads_flavour_and_pin() { var dir = TempDir(); File.WriteAllText(Path.Combine(dir, "preconfig.json"), Sample); var p = new PreconfigStore(dir).Load(); Assert.NotNull(p); Assert.Equal("developer", p!.Flavour); Assert.True(p.Bitlocker.Enable); Assert.Equal("246810", p.Bitlocker.Pin); Assert.True(p.Apps.UseFlavourDefaults); } [Fact] public void Missing_or_bad_file_returns_null_not_throw() { Assert.Null(new PreconfigStore(TempDir()).Load()); // missing var dir = TempDir(); File.WriteAllText(Path.Combine(dir, "preconfig.json"), "{ not json"); Assert.Null(new PreconfigStore(dir).Load()); // corrupt } [Fact] public void ClearPin_rewrites_without_the_pin() { var dir = TempDir(); File.WriteAllText(Path.Combine(dir, "preconfig.json"), Sample); var store = new PreconfigStore(dir); store.ClearPin(); var reread = store.Load(); Assert.True(string.IsNullOrEmpty(reread!.Bitlocker.Pin)); Assert.Equal("developer", reread.Flavour); // rest preserved } [Fact] public void Configured_marker_roundtrips() { var dir = TempDir(); var store = new PreconfigStore(dir); Assert.False(store.IsConfigured()); store.MarkConfigured(); Assert.True(store.IsConfigured()); } } ``` - [ ] **Step 2: Run to verify it fails** Run: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release --filter Preconfig` Expected: FAIL — types not defined. - [ ] **Step 3: Implement the records + store** `Preconfig.cs`: ```csharp using System.Text.Json; namespace SilverOS.Welcome.Core.Preconfig; public sealed record BitlockerConfig { public bool Enable { get; init; } public string? Pin { get; init; } } public sealed record AppsConfig { public bool UseFlavourDefaults { get; init; } = true; public IReadOnlyList<string>? Selected { get; init; } } public sealed record Preconfig { public int SchemaVersion { get; init; } = 1; public string Flavour { get; init; } = ""; public BitlockerConfig Bitlocker { get; init; } = new(); public AppsConfig Apps { get; init; } = new(); public static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }; } ``` `IPreconfigStore.cs`: ```csharp namespace SilverOS.Welcome.Core.Preconfig; public interface IPreconfigStore { Preconfig? Load(); // null if missing/corrupt (fail-open) void ClearPin(); // rewrite preconfig without the BitLocker pin bool IsConfigured(); // configured marker present? void MarkConfigured(); // write the configured marker } ``` `PreconfigStore.cs`: ```csharp using System.Text.Json; namespace SilverOS.Welcome.Core.Preconfig; public sealed class PreconfigStore(string dir) : IPreconfigStore { private string File_ => Path.Combine(dir, "preconfig.json"); private string Marker => Path.Combine(dir, "configured"); public Preconfig? Load() { try { if (!File.Exists(File_)) return null; return JsonSerializer.Deserialize<Preconfig>(File.ReadAllText(File_), Preconfig.JsonOptions); } catch (JsonException) { return null; } // fail-open } public void ClearPin() { var p = Load(); if (p is null) return; var stripped = p with { Bitlocker = p.Bitlocker with { Pin = null } }; File.WriteAllText(File_, JsonSerializer.Serialize(stripped, Preconfig.JsonOptions)); } public bool IsConfigured() => File.Exists(Marker); public void MarkConfigured() { Directory.CreateDirectory(dir); File.WriteAllText(Marker, "1"); } } ``` - [ ] **Step 4: Run to verify it passes** Run: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release --filter Preconfig` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add windows/welcome/src/SilverOS.Welcome.Core/Preconfig windows/welcome/tests/SilverOS.Welcome.Tests/PreconfigTests.cs git commit -m "feat(toolbox): preconfig store (load fail-open, clear-pin, configured marker)" ``` --- ## Phase D — Trim the toolbox + consume preconfig > This phase removes account creation, `sm-bootstrap` teardown, and the heavy kiosk, and rewires Apply to `apps -> bitlocker -> done`, pre-seeded from preconfig. It touches several existing tests — update them as specified. ### Task D1: Slim `ApplyRequest` + `ApplyService` to apps + bitlocker **Files:** - Modify: `windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs` - Modify: `windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs` - Modify tests: `ApplyServiceTests.cs`, `ApplyServicesTests.cs`, `ApplyServiceHardeningIntegrationTests.cs`, `BootstrapServiceRevertKioskTests.cs`, `ApplyStepTests.cs` - [ ] **Step 1: Read the current files** (`ApplyRequest.cs`, `ApplyService.cs`) to see the exact ctor/params and progress stages. The current `ApplyService` order is hardening -> accounts -> apps -> bitlocker -> RevertKiosk -> TearDown -> done; `ApplyRequest` is `(FlavourManifest Flavour, string Username, string Password, string AdminPassword, string BitLockerPin, string BootstrapUser, IReadOnlyList<AppCatalogEntry> Apps)`. - [ ] **Step 2: Update the failing tests first** — change `ApplyServiceTests` ordering expectation to the new pipeline and drop account/teardown assertions. New `ApplyRequest.cs`: ```csharp using SilverOS.Welcome.Core.Flavours; using SilverOS.Welcome.Core.Apps; namespace SilverOS.Welcome.Core.Apply; // Toolbox model: account is created by Windows Setup (WinPE collector), and hardening // runs from SetupComplete. Apply only installs apps + enrols BitLocker. public sealed record ApplyRequest(FlavourManifest Flavour, string BitLockerPin, IReadOnlyList<AppCatalogEntry> Apps); ``` In `ApplyServiceTests.cs`, the ordering test becomes (replace the old `modules/accounts/apps/bitlocker/bootstrap` sequence): ```csharp // Apply now: apps -> bitlocker. No accounts/hardening/teardown in the toolbox. Assert.Equal(new[] { "apps", "bitlocker" }, order); ``` Remove (delete) `BootstrapServiceRevertKioskTests.cs` and `ApplyServiceHardeningIntegrationTests.cs` (the kiosk revert + in-Apply hardening are gone). Update any `new ApplyRequest(...)` call sites to the 3-arg form `new ApplyRequest(flavour, "", apps)` / with a pin where relevant. - [ ] **Step 3: Run to verify failure** — `dotnet test ... -c Release` fails to compile (ApplyRequest arity changed). Good (red). - [ ] **Step 4: Implement the slimmed `ApplyService`** Rewrite `ApplyService` to: ```csharp using SilverOS.Welcome.Core.Apps; namespace SilverOS.Welcome.Core.Apply; public sealed class ApplyService(IProcessRunner runner, IBitLockerService bitlocker, IAppInstaller installer) : IApplyService { public async Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default) { progress.Report(new("Installing apps", 30)); await installer.InstallAsync(req.Apps, progress, ct); // continue-on-failure; never throws if (!string.IsNullOrWhiteSpace(req.BitLockerPin)) { progress.Report(new("Encrypting the disk", 75)); await bitlocker.EnableAsync(req.BitLockerPin, ct); } progress.Report(new("Done", 100)); } } ``` Delete the now-unused `IAccountService`/`AccountService`/`IBootstrapService`/`BootstrapService` (and their tests `AccountStepTests.cs` if it only tests account creation — keep if it tests the removed step UI, but that step is deleted in D2). Keep `IProcessRunner`/`ProcessRunner`/`BitLockerService`/`IAppInstaller`. - [ ] **Step 5: Run to verify pass** — `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release`. Fix remaining call sites until green. - [ ] **Step 6: Commit** ```bash git add windows/welcome git commit -m "refactor(toolbox): Apply is apps+bitlocker only (account via Setup, hardening via SetupComplete)" ``` ### Task D2: Remove Account step + pre-seed from preconfig + run-mode **Files:** - Modify: `windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor` - Delete: `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AccountStep.razor` (+ `AccountStepTests.cs`) - Modify: `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor` - Modify: `windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs` - [ ] **Step 1:** Remove the Account step from `Routes.razor` (delete its case + title; renumber: Welcome, Flavour, Apps, Prefs, Apply, Done). Inject `IPreconfigStore` (registered in MauiProgram pointing at `C:\ProgramData\SilverMetal`). On init: `var pre = PreconfigStore.Load();` if non-null and `!IsConfigured()`, pre-seed `State.Flavour` (match by id from loaded flavours), seed `State.SelectedApps` from `DefaultSelectionForRole(pre.Flavour)`, set `State.BitLockerPin = pre.Bitlocker.Pin` when `pre.Bitlocker.Enable`. If `IsConfigured()`, start on a minimal **toolbox-home** view (a simple page with a "Re-run setup" button) instead of auto-advancing. - [ ] **Step 2:** `ApplyStep.razor` builds `new ApplyRequest(State.Flavour!, State.BitLockerPin, apps)` (3-arg). After a successful Apply: `PreconfigStore.ClearPin(); PreconfigStore.MarkConfigured();`. - [ ] **Step 3:** `MauiProgram.cs` — register `IPreconfigStore`: ```csharp builder.Services.AddSingleton<IPreconfigStore>(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal")); ``` and update the `ApplyService` factory to the new 3-arg ctor `new ApplyService(runner, bitlocker, installer)` (drop accounts/bootstrap/hardeningDir). Remove the `IAccountService`/`IBootstrapService` registrations. - [ ] **Step 4: Build** `dotnet build windows/welcome/src/SilverOS.Welcome.App -c Release` -> 0 errors; `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release` -> green (update/delete `FlavourStepTests`/`ApplyStepTests` as needed for the new wiring; keep their real assertions). - [ ] **Step 5: Commit** ```bash git add windows/welcome git commit -m "feat(toolbox): drop Account step, pre-seed from preconfig, run-once vs toolbox-home" ``` --- ## Phase E — Build wiring + SetupComplete scrub + ISO assertions ### Task E1: Stage the collector into boot.wim + winpeshl **Files:** - Modify: `windows/installer/build.ps1` (`Invoke-ForceLegacySetup`, around lines 103-135) - Create: `windows/collector/Start-Collector.cmd`, `windows/collector/winpeshl.ini`, `windows/collector/Collector.ps1` - [ ] **Step 1:** Create `Start-Collector.cmd` (ASCII): ```bat @echo off REM WinPE entry point. SM_UNATTENDED=1 -> skip UI and launch Setup with the default answer file (CI). if "%SM_UNATTENDED%"=="1" ( start /wait X:\sources\setup.exe /unattend:X:\autounattend.xml exit /b 0 ) powershell -NoProfile -ExecutionPolicy Bypass -File X:\sm\Collector.ps1 if errorlevel 1 ( REM Collector failed/cancelled -> fall back to the default answer file so install still proceeds. start /wait X:\sources\setup.exe /unattend:X:\autounattend.xml ) exit /b 0 ``` - [ ] **Step 2:** Create `winpeshl.ini` (ASCII): ```ini [LaunchApps] %SYSTEMDRIVE%\sm\Start-Collector.cmd ``` - [ ] **Step 3:** Create `Collector.ps1` — the WinForms shell. It dot-sources `Test-SmInput.ps1` + `New-SmAnswerFile.ps1` from `X:\sm\`, shows a branded full-screen form collecting {DisplayName, Username, Password+confirm, ComputerName, locale (default), flavour (radio list), BitLocker enable + PIN+confirm}, validates each with the `Test-Sm*` functions (block OK until valid), then on Finish: `$xml = New-SmAnswerFile @collected; Set-Content X:\sm\unattend.generated.xml $xml -Encoding UTF8; Start-Process X:\sources\setup.exe "/unattend:X:\sm\unattend.generated.xml" -Wait`. On Cancel: `exit 1` (Start-Collector falls back). Wrap the whole body in try/catch that `exit 1` on any error. (UI code is not unit-tested; the validated logic + generator are.) - [ ] **Step 4:** In `build.ps1` `Invoke-ForceLegacySetup`, after copying `autounattend.xml` into the boot mount, also: add the WinPE optional components and stage the collector. Insert inside the `try` (after line 120), before the `reg load`: ```powershell # Add WinPE .NET + PowerShell so the collector (WinForms) can run. $adk = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs' foreach ($oc in @('WinPE-WMI.cab','WinPE-NetFx.cab','WinPE-Scripting.cab','WinPE-PowerShell.cab')) { $cab = Join-Path $adk $oc if (Test-Path $cab) { Add-WindowsPackage -Path $bootmnt -PackagePath $cab | Out-Null } else { Write-Warning " WinPE OC missing on runner: $cab (collector needs the ADK WinPE add-on)" } } # Stage the collector + winpeshl so WinPE launches it instead of Setup. $smDir = Join-Path $bootmnt 'sm'; $null = New-Item -ItemType Directory -Force $smDir Copy-Item (Join-Path $PSScriptRoot '..\collector\*') $smDir -Recurse -Force Copy-Item (Join-Path $smDir 'winpeshl.ini') (Join-Path $bootmnt 'Windows\System32\winpeshl.ini') -Force ``` Keep the existing `reg add ... CmdLine` (it is the fallback path / legacy-setup forcing). Leave the static `autounattend.xml` copy in place (default/fallback answer file). - [ ] **Step 5: Parse-lint** `build.ps1`, `Collector.ps1`, `Start-Collector.cmd`: ``` pwsh -NoProfile -Command "[void][System.Management.Automation.Language.Parser]::ParseFile('C:\Staging\Source\SilverMetal\windows\installer\build.ps1',[ref]$null,[ref]$null); [void][System.Management.Automation.Language.Parser]::ParseFile('C:\Staging\Source\SilverMetal\windows\collector\Collector.ps1',[ref]$null,[ref]$null); 'ok'" ``` Expected: `ok`. - [ ] **Step 6: Commit** ```bash git add windows/collector windows/installer/build.ps1 git commit -m "feat(build): stage WinPE collector into boot.wim (winpeshl + WinPE-NetFx/PowerShell) with SM_UNATTENDED fallback" ``` ### Task E2: SetupComplete Panther scrub + ISO assertions **Files:** - Modify: the SetupComplete script staged by build (find it: `windows/hardening/` `SetupComplete.cmd` or where build writes `C:\Windows\Setup\Scripts\SetupComplete.cmd`) - Modify: `windows/tests/Assert-IsoStructure.ps1` - [ ] **Step 1:** Add a scrub line near the end of the SetupComplete flow (after hardening, runs as SYSTEM): ```bat del /f /q "%WINDIR%\Panther\unattend.xml" 2>nul del /f /q "%WINDIR%\Panther\Unattend\unattend.xml" 2>nul ``` (Find the actual SetupComplete generator in build.ps1 / hardening and append these two lines.) - [ ] **Step 2:** In `Assert-IsoStructure.ps1`, add boot.wim collector assertions. After the install.wim block, add a boot.wim check: ```powershell # boot.wim must carry the WinPE collector + winpeshl. $bootwim = "$drive\sources\boot.wim" Assert 'boot.wim present' (Test-Path $bootwim) if (Test-Path $bootwim) { $bmount = Join-Path $env:TEMP ('sm-assert-boot-' + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Force $bmount | Out-Null Mount-WindowsImage -ImagePath $bootwim -Index 2 -Path $bmount -ReadOnly | Out-Null try { Assert 'collector staged in boot.wim' (Test-Path (Join-Path $bmount 'sm\Collector.ps1')) Assert 'winpeshl.ini set' (Test-Path (Join-Path $bmount 'Windows\System32\winpeshl.ini')) } finally { Dismount-WindowsImage -Path $bmount -Discard | Out-Null; Remove-Item $bmount -Recurse -Force -EA SilentlyContinue } } ``` Also assert the staged toolbox no longer ships the bootstrap teardown: `Assert 'no sm-bootstrap in answer file' (-not (Select-String -Path "$drive\autounattend.xml" -Pattern 'sm-bootstrap' -Quiet))`. - [ ] **Step 3: Parse-lint** Assert-IsoStructure.ps1 (same ParseFile check). Expected `ok`. - [ ] **Step 4: Commit** ```bash git add windows/tests/Assert-IsoStructure.ps1 windows/hardening git commit -m "feat(build): scrub Panther unattend + assert collector baked into boot.wim" ``` --- ## Phase F — Verify + PR ### Task F1: Full test + parity + PR - [ ] **Step 1:** `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release` -> all green. - [ ] **Step 2:** Run the collector Pester: `pwsh -NoProfile -Command "$c=New-PesterConfiguration; $c.Run.Path='windows/tests/Collector.Tests.ps1'; $c.Output.Verbosity='Detailed'; $r=Invoke-Pester -Configuration $c -PassThru; if($r.FailedCount){throw $r.FailedCount}"` -> 0 failures. - [ ] **Step 3:** `dotnet build windows/welcome/src/SilverOS.Welcome.App -c Release` -> 0 errors. - [ ] **Step 4:** Use `superpowers:finishing-a-development-branch` to open the PR (`docs/winpe-preconfig-collector` -> main). In the PR note the **runner prerequisite**: the CI Windows runner needs the **ADK WinPE add-on** (`WinPE_OCs` cabs) for `Add-WindowsPackage` to succeed; without it the collector isn't added and boot.wim assertions fail. The end-to-end (collector form in WinPE -> native account -> toolbox run-once) is verified on the **next VM cycle**. --- ## Self-review notes (author) - **Spec coverage:** collector scope (§2) -> A1/E1 (fields) ; WinForms UI (§2,§4a) -> E1 Collector.ps1 ; single admin (§2,§4b) -> B1 ; toolbox run-once+persist (§2,§4d) -> D2 ; handoff generated-answer-file + base64 (§2,§5) -> B1 ; hardening canonical in SetupComplete (§2) -> D1 (Apply drops hardening) + E2 ; preconfig contract (§4c) -> C1 ; build wiring (§4e) -> E1 ; fallback/SM_UNATTENDED (§4e,§6) -> E1 Start-Collector ; error handling fail-open (§6) -> C1 (loader) + E1 (collector fallback) ; security scrub + pin-clear (§7) -> E2 + C1/D2 ; testing (§8) -> A1/B1/C1 + E2 (Assert-IsoStructure) ; phasing (§9) -> SP2/SP3 left out. - **Placeholder scan:** none — the testable units carry full code; integration tasks carry the exact code/diffs. The one soft spot (Collector.ps1 WinForms body) is intentionally described not pasted, since UI isn't unit-tested and its logic delegates to the fully-specified `Test-Sm*`/`New-SmAnswerFile`; the executor builds the form against those contracts. - **Type consistency:** `Test-SmUsername/Password/Pin/ComputerName` (A1) reused by Collector.ps1 (E1). `New-SmAnswerFile` param names (B1) = the hashtable Collector.ps1 splats. `Preconfig`/`BitlockerConfig`/`AppsConfig` + `PreconfigStore.Load/ClearPin/IsConfigured/MarkConfigured` (C1) used in D2. `ApplyRequest(Flavour,BitLockerPin,Apps)` (D1) used in ApplyStep (D2). `IAppInstaller.InstallAsync` unchanged. - **Known risk flagged in-place:** ADK WinPE add-on on the runner (E1 warning + F1 PR note); `ComputerName` pass placement (B1 note).