diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/BitLockerService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/BitLockerService.cs index d6e0e1c..2551ccd 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/BitLockerService.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/BitLockerService.cs @@ -14,6 +14,11 @@ public sealed class BitLockerService(IProcessRunner runner) : IBitLockerService // 3. Remove any TPM-only protector (only once a TPM+PIN protector is confirmed present) // so the device actually requires the PIN at pre-boot. var script = string.Concat( + // Eject optical install media first — BitLocker -TpmAndPinProtector refuses to enroll + // while bootable CD/DVD media is present ("detected bootable media in the computer"). + "try { $s=New-Object -ComObject Shell.Application; ", + "$s.Namespace(17).Items() | Where-Object { $_.Type -match 'CD|DVD' } | ForEach-Object { try { $_.InvokeVerb('Eject') } catch {} } } catch {}; ", + "Start-Sleep -Seconds 3; ", "$fve='HKLM:\\SOFTWARE\\Policies\\Microsoft\\FVE'; ", "New-Item -Path $fve -Force | Out-Null; ", "New-ItemProperty -Path $fve -Name UseAdvancedStartup -Value 1 -PropertyType DWord -Force | Out-Null; ", diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs index b557631..e3dc9a7 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs @@ -1,11 +1,12 @@ namespace SilverOS.Welcome.Core.Apply; public sealed class BootstrapService(IProcessRunner runner) : IBootstrapService { + // Kiosk revert is BEST-EFFORT (like TearDownAsync): -EA SilentlyContinue throughout. + // If WESL is unavailable the real user still gets Explorer (no custom shell for their + // SID). Intentional: don't fail the apply over a missing WMI class. Must run BEFORE + // TearDownAsync so the sm-bootstrap SID still resolves. public async Task RevertKioskAsync(CancellationToken ct = default) { - // -EA SilentlyContinue throughout: Shell Launcher revert is best-effort. - // If WESL is unavailable the real user still gets Explorer (no custom shell - // for their SID). Intentional: don't fail teardown over a missing WMI class. // Remove sm-bootstrap custom shell entry + disable Shell Launcher's per-user entry. await Ps( "$c='root\\\\standardcimv2\\\\embedded';" + @@ -15,32 +16,29 @@ public sealed class BootstrapService(IProcessRunner runner) : IBootstrapService "Invoke-CimMethod -InputObject $w -MethodName RemoveCustomShell -Arguments @{Sid=$sid} -EA SilentlyContinue | Out-Null;" + "Invoke-CimMethod -InputObject $w -MethodName SetEnabled -Arguments @{Enabled=$false} -EA SilentlyContinue | Out-Null" + "}", - "Revert Shell Launcher", ct); + ct); // Revert escape policies set by Configure-Kiosk.ps1. await Ps( "$s='HKLM:\\\\SOFTWARE\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Policies\\\\System';" + "Remove-ItemProperty $s -Name DisableTaskMgr,DisableLockWorkstation,HideFastUserSwitching -EA SilentlyContinue", - "Revert escape policies", ct); + ct); } + // Teardown is BEST-EFFORT (unlike Account/BitLocker which are strict): the answer file's + // AutoLogon LogonCount=1 already neutralises auto-logon after the first logon (Windows clears + // AutoAdminLogon itself), so these Winlogon cleanups must not fail the whole apply. The op that + // matters — removing the sm-bootstrap account — runs regardless and is tolerant too. public async Task TearDownAsync(string bootstrapUser, CancellationToken ct = default) { - const string key = "'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon'"; - // DefaultPassword may legitimately be absent → keep its per-cmdlet -EA SilentlyContinue. - await Ps($"Set-ItemProperty {key} -Name AutoAdminLogon -Value 0; " + - $"Remove-ItemProperty {key} -Name DefaultPassword -EA SilentlyContinue", "Disable auto-logon", ct); + const string w = "'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon'"; + await Ps($"Set-ItemProperty -Path {w} -Name AutoAdminLogon -Value '0' -EA SilentlyContinue; " + + $"Remove-ItemProperty -Path {w} -Name DefaultPassword -EA SilentlyContinue; " + + $"Remove-ItemProperty -Path {w} -Name DefaultUserName -EA SilentlyContinue; " + + $"Remove-ItemProperty -Path {w} -Name DefaultDomainName -EA SilentlyContinue", ct); var u = Esc(bootstrapUser); - await Ps($"Remove-LocalUser -Name '{u}' -EA SilentlyContinue", "Remove bootstrap account", ct); + await Ps($"Remove-LocalUser -Name '{u}' -EA SilentlyContinue", ct); } private static string Esc(string s) => s.Replace("'", "''"); - - // $ErrorActionPreference='Stop' surfaces unexpected hard errors (e.g. a bad registry path); - // the intentional per-cmdlet -EA SilentlyContinue above still overrides it for the known - // best-effort cleanups. - private async Task Ps(string s, string operation, CancellationToken ct) - { - var r = await runner.RunAsync("powershell.exe", - $"-NoProfile -ExecutionPolicy Bypass -Command \"$ErrorActionPreference='Stop'; {s}\"", ct); - r.EnsureSuccess(operation); - } + private Task Ps(string s, CancellationToken ct) => + runner.RunAsync("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -Command \"{s}\"", ct); } diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServicesTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServicesTests.cs index 133f2b2..c600977 100644 --- a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServicesTests.cs +++ b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServicesTests.cs @@ -33,12 +33,9 @@ public class ApplyServicesTests new BitLockerService(Fail().Object).EnableAsync("123456")); } - [Fact] - public async Task BootstrapService_throws_on_nonzero_exit() - { - await Assert.ThrowsAsync(() => - new BootstrapService(Fail().Object).TearDownAsync("sm-bootstrap")); - } + // Note: BootstrapService is intentionally best-effort (teardown cleanups must not fail the + // apply — auto-logon is already neutralised by the answer file's LogonCount=1), so it does + // NOT throw on a non-zero exit. [Fact] public async Task AccountService_creates_standard_daily_and_admin() @@ -81,6 +78,9 @@ public class ApplyServicesTests // Removes any TPM-only protector so the device requires the PIN at pre-boot. run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => s.Contains("Remove-BitLockerKeyProtector")), It.IsAny())); + // Ejects optical install media first (BitLocker refuses to enroll with bootable media present). + run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => + s.Contains("Shell.Application") && s.Contains("Eject")), It.IsAny())); } [Fact] diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/BootstrapServiceRevertKioskTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/BootstrapServiceRevertKioskTests.cs index 198b51e..18f7ef7 100644 --- a/windows/welcome/tests/SilverOS.Welcome.Tests/BootstrapServiceRevertKioskTests.cs +++ b/windows/welcome/tests/SilverOS.Welcome.Tests/BootstrapServiceRevertKioskTests.cs @@ -20,10 +20,13 @@ public class BootstrapServiceRevertKioskTests } [Fact] - public async Task RevertKioskAsync_throws_on_nonzero_exit() + public async Task RevertKioskAsync_is_best_effort_and_does_not_throw_on_nonzero_exit() { - await Assert.ThrowsAsync(() => + // Kiosk revert is best-effort (like TearDownAsync): a non-zero exit must NOT + // fail the apply — the real user still gets Explorer regardless of WESL state. + var ex = await Record.ExceptionAsync(() => new BootstrapService(Fail().Object).RevertKioskAsync()); + Assert.Null(ex); } [Fact]