Files
SilverMetal/windows/docs/superpowers/plans/2026-06-10-winpe-preconfig-collector.md
2026-06-10 08:37:15 +01:00

38 KiB

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

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)

#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)
#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
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

. (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 '<Name>jamie</Name>'
        $xml | Should -Match '<Group>Administrators</Group>'
    }
    It 'does NOT contain sm-bootstrap'        { $xml | Should -Not -Match 'sm-bootstrap' }
    It 'sets AutoLogon once as the user'      {
        $xml | Should -Match '<LogonCount>1</LogonCount>'
        $xml | Should -Match '<Username>jamie</Username>'
    }
    It 'sets the computer name'               { $xml | Should -Match '<ComputerName>SILVER-01</ComputerName>' }
    It 'keeps WillWipeDisk for disk 0'        { $xml | Should -Match '<WillWipeDisk>true</WillWipeDisk>' }
    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)

#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')) """

@"
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
  <settings pass="windowsPE">
    <component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <SetupUILanguage><UILanguage>$UiLanguage</UILanguage></SetupUILanguage>
      <InputLocale>$InputLocale</InputLocale><SystemLocale>$SystemLocale</SystemLocale>
      <UILanguage>$UiLanguage</UILanguage><UserLocale>$UserLocale</UserLocale>
    </component>
    <component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <DiskConfiguration>
        <WillShowUI>OnError</WillShowUI>
        <Disk wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
          <DiskID>0</DiskID><WillWipeDisk>true</WillWipeDisk>
          <CreatePartitions>
            <CreatePartition wcm:action="add"><Order>1</Order><Type>EFI</Type><Size>300</Size></CreatePartition>
            <CreatePartition wcm:action="add"><Order>2</Order><Type>MSR</Type><Size>16</Size></CreatePartition>
            <CreatePartition wcm:action="add"><Order>3</Order><Type>Primary</Type><Extend>true</Extend></CreatePartition>
          </CreatePartitions>
          <ModifyPartitions>
            <ModifyPartition wcm:action="add"><Order>1</Order><PartitionID>1</PartitionID><Label>System</Label><Format>FAT32</Format></ModifyPartition>
            <ModifyPartition wcm:action="add"><Order>2</Order><PartitionID>2</PartitionID></ModifyPartition>
            <ModifyPartition wcm:action="add"><Order>3</Order><PartitionID>3</PartitionID><Label>Windows</Label><Format>NTFS</Format><Letter>C</Letter></ModifyPartition>
          </ModifyPartitions>
        </Disk>
      </DiskConfiguration>
      <ImageInstall><OSImage>
        <InstallTo><DiskID>0</DiskID><PartitionID>3</PartitionID></InstallTo>
        <InstallFrom><MetaData wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Key>/IMAGE/INDEX</Key><Value>1</Value></MetaData></InstallFrom>
      </OSImage></ImageInstall>
      <UserData><AcceptEula>true</AcceptEula></UserData>
    </component>
  </settings>
  <settings pass="specialize">
    <component name="Microsoft-Windows-Deployment" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <RunSynchronous>
        <RunSynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
          <Order>1</Order>
          <Path>$([Security.SecurityElement]::Escape($writePre))</Path>
          <Description>Write SilverMetal preconfig</Description>
        </RunSynchronousCommand>
      </RunSynchronous>
    </component>
  </settings>
  <settings pass="oobeSystem">
    <component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <InputLocale>$InputLocale</InputLocale><SystemLocale>$SystemLocale</SystemLocale>
      <UILanguage>$UiLanguage</UILanguage><UILanguageFallback>$UiLanguage</UILanguageFallback><UserLocale>$UserLocale</UserLocale>
    </component>
    <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <OOBE><HideEULAPage>true</HideEULAPage><HideOEMRegistrationScreen>true</HideOEMRegistrationScreen><HideOnlineAccountScreens>true</HideOnlineAccountScreens><HideLocalAccountScreen>true</HideLocalAccountScreen><HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE><ProtectYourPC>3</ProtectYourPC></OOBE>
      <UserAccounts><LocalAccounts>
        <LocalAccount wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
          <Name>$un</Name><Group>Administrators</Group><DisplayName>$dn</DisplayName>
          <Password><Value>$pw</Value><PlainText>true</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

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)

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:

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:

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:

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
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:

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):

// 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 failuredotnet test ... -c Release fails to compile (ApplyRequest arity changed). Good (red).

  • Step 4: Implement the slimmed ApplyService

Rewrite ApplyService to:

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 passdotnet test windows/welcome/SilverOS.Welcome.sln -c Release. Fix remaining call sites until green.

  • Step 6: Commit

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:

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

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):

@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):
[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:

        # 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
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):

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:
    # 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

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).