@a11ign/screenreader-fleet 0.0.0-reserved.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +94 -2
  3. package/dist/capture-client.d.mts +49 -0
  4. package/dist/capture-client.d.mts.map +1 -0
  5. package/dist/capture-client.mjs +352 -0
  6. package/dist/capture-client.mjs.map +1 -0
  7. package/dist/check-worker-code.d.mts +34 -0
  8. package/dist/check-worker-code.d.mts.map +1 -0
  9. package/dist/check-worker-code.mjs +173 -0
  10. package/dist/check-worker-code.mjs.map +1 -0
  11. package/dist/cli-flags.d.mts +71 -0
  12. package/dist/cli-flags.d.mts.map +1 -0
  13. package/dist/cli-flags.mjs +207 -0
  14. package/dist/cli-flags.mjs.map +1 -0
  15. package/dist/code-drift.d.mts +140 -0
  16. package/dist/code-drift.d.mts.map +1 -0
  17. package/dist/code-drift.mjs +284 -0
  18. package/dist/code-drift.mjs.map +1 -0
  19. package/dist/command-line-census.d.mts +33 -0
  20. package/dist/command-line-census.d.mts.map +1 -0
  21. package/dist/command-line-census.mjs +96 -0
  22. package/dist/command-line-census.mjs.map +1 -0
  23. package/dist/compare-workers.d.mts +3 -0
  24. package/dist/compare-workers.d.mts.map +1 -0
  25. package/dist/compare-workers.mjs +332 -0
  26. package/dist/compare-workers.mjs.map +1 -0
  27. package/dist/control-plane-isolation.d.mts +45 -0
  28. package/dist/control-plane-isolation.d.mts.map +1 -0
  29. package/dist/control-plane-isolation.mjs +67 -0
  30. package/dist/control-plane-isolation.mjs.map +1 -0
  31. package/dist/deploy-worker.d.mts +3 -0
  32. package/dist/deploy-worker.d.mts.map +1 -0
  33. package/dist/deploy-worker.mjs +333 -0
  34. package/dist/deploy-worker.mjs.map +1 -0
  35. package/dist/doctor.d.mts +216 -0
  36. package/dist/doctor.d.mts.map +1 -0
  37. package/dist/doctor.mjs +962 -0
  38. package/dist/doctor.mjs.map +1 -0
  39. package/dist/fleet-consistency.d.mts +235 -0
  40. package/dist/fleet-consistency.d.mts.map +1 -0
  41. package/dist/fleet-consistency.mjs +436 -0
  42. package/dist/fleet-consistency.mjs.map +1 -0
  43. package/dist/fleet-env.d.mts +228 -0
  44. package/dist/fleet-env.d.mts.map +1 -0
  45. package/dist/fleet-env.mjs +509 -0
  46. package/dist/fleet-env.mjs.map +1 -0
  47. package/dist/fleet-scripts.d.mts +11 -0
  48. package/dist/fleet-scripts.d.mts.map +1 -0
  49. package/dist/fleet-scripts.mjs +41 -0
  50. package/dist/fleet-scripts.mjs.map +1 -0
  51. package/dist/git-safe-env.d.mts +10 -0
  52. package/dist/git-safe-env.d.mts.map +1 -0
  53. package/dist/git-safe-env.mjs +44 -0
  54. package/dist/git-safe-env.mjs.map +1 -0
  55. package/dist/guest-run.d.mts +26 -0
  56. package/dist/guest-run.d.mts.map +1 -0
  57. package/dist/guest-run.mjs +164 -0
  58. package/dist/guest-run.mjs.map +1 -0
  59. package/dist/host-address.d.mts +33 -0
  60. package/dist/host-address.d.mts.map +1 -0
  61. package/dist/host-address.mjs +105 -0
  62. package/dist/host-address.mjs.map +1 -0
  63. package/dist/host-capacity.d.mts +64 -0
  64. package/dist/host-capacity.d.mts.map +1 -0
  65. package/dist/host-capacity.mjs +152 -0
  66. package/dist/host-capacity.mjs.map +1 -0
  67. package/dist/host-metrics.d.mts +116 -0
  68. package/dist/host-metrics.d.mts.map +1 -0
  69. package/dist/host-metrics.mjs +201 -0
  70. package/dist/host-metrics.mjs.map +1 -0
  71. package/dist/index.d.ts +23 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +25 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/local-vm.d.ts +125 -0
  76. package/dist/local-vm.d.ts.map +1 -0
  77. package/dist/local-vm.js +360 -0
  78. package/dist/local-vm.js.map +1 -0
  79. package/dist/measure-guard.d.mts +34 -0
  80. package/dist/measure-guard.d.mts.map +1 -0
  81. package/dist/measure-guard.mjs +73 -0
  82. package/dist/measure-guard.mjs.map +1 -0
  83. package/dist/normalise-fleet.d.mts +2 -0
  84. package/dist/normalise-fleet.d.mts.map +1 -0
  85. package/dist/normalise-fleet.mjs +76 -0
  86. package/dist/normalise-fleet.mjs.map +1 -0
  87. package/dist/npm-cli-executable.d.mts +42 -0
  88. package/dist/npm-cli-executable.d.mts.map +1 -0
  89. package/dist/npm-cli-executable.mjs +159 -0
  90. package/dist/npm-cli-executable.mjs.map +1 -0
  91. package/dist/probe-outcome.d.mts +89 -0
  92. package/dist/probe-outcome.d.mts.map +1 -0
  93. package/dist/probe-outcome.mjs +104 -0
  94. package/dist/probe-outcome.mjs.map +1 -0
  95. package/dist/protocol-guard.d.mts +34 -0
  96. package/dist/protocol-guard.d.mts.map +1 -0
  97. package/dist/protocol-guard.mjs +121 -0
  98. package/dist/protocol-guard.mjs.map +1 -0
  99. package/dist/source-walk.d.mts +12 -0
  100. package/dist/source-walk.d.mts.map +1 -0
  101. package/dist/source-walk.mjs +56 -0
  102. package/dist/source-walk.mjs.map +1 -0
  103. package/dist/transient-fault.d.mts +6 -0
  104. package/dist/transient-fault.d.mts.map +1 -0
  105. package/dist/transient-fault.mjs +86 -0
  106. package/dist/transient-fault.mjs.map +1 -0
  107. package/dist/utm-deprecated.d.mts +6 -0
  108. package/dist/utm-deprecated.d.mts.map +1 -0
  109. package/dist/utm-deprecated.mjs +23 -0
  110. package/dist/utm-deprecated.mjs.map +1 -0
  111. package/dist/worker-code-check.d.mts +29 -0
  112. package/dist/worker-code-check.d.mts.map +1 -0
  113. package/dist/worker-code-check.mjs +78 -0
  114. package/dist/worker-code-check.mjs.map +1 -0
  115. package/dist/worker-health.d.mts +56 -0
  116. package/dist/worker-health.d.mts.map +1 -0
  117. package/dist/worker-health.mjs +73 -0
  118. package/dist/worker-health.mjs.map +1 -0
  119. package/dist/worker-http.d.mts +103 -0
  120. package/dist/worker-http.d.mts.map +1 -0
  121. package/dist/worker-http.mjs +277 -0
  122. package/dist/worker-http.mjs.map +1 -0
  123. package/dist/worker-stats.d.mts +66 -0
  124. package/dist/worker-stats.d.mts.map +1 -0
  125. package/dist/worker-stats.mjs +143 -0
  126. package/dist/worker-stats.mjs.map +1 -0
  127. package/package.json +96 -4
  128. package/src/local-worker/autounattend.xml +280 -0
  129. package/src/local-worker/build-vm.sh +218 -0
  130. package/src/local-worker/clone-worker.sh +141 -0
  131. package/src/local-worker/create-utm-vm.sh +202 -0
  132. package/src/local-worker/fetch-windows-iso.sh +238 -0
  133. package/src/local-worker/first-boot.cmd +58 -0
  134. package/src/local-worker/worker-ctl.sh +442 -0
  135. package/src/provisioning/README.md +28 -0
  136. package/src/provisioning/apply-foreground-lock-timeout.ps1 +71 -0
  137. package/src/provisioning/bare-metal/README.md +213 -0
  138. package/src/provisioning/bare-metal/a11y-bootstrap.service +58 -0
  139. package/src/provisioning/bare-metal/autounattend.xml +428 -0
  140. package/src/provisioning/bare-metal/serve-bootstrap.sh +86 -0
  141. package/src/provisioning/bootstrap-control-plane.sh +463 -0
  142. package/src/provisioning/bootstrap-windows-worker.ps1 +649 -0
  143. package/src/provisioning/build-lean-worker-image.ps1 +275 -0
  144. package/src/provisioning/diagnose-nvda-worker.ps1 +174 -0
  145. package/src/provisioning/provision-nvda-worker.ps1 +827 -0
  146. package/src/provisioning/set-display-mode.ps1 +411 -0
  147. package/src/provisioning/stamp-provision-revision.ps1 +184 -0
@@ -0,0 +1,275 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Build a Windows 11 image with Defender and unused components removed, keeping everything a capture
4
+ guest needs. Modelled on nano11builder's technique; none of its choices.
5
+
6
+ .DESCRIPTION
7
+ Why this exists, in one number: Defender's MsMpEng is ~259 MB resident on a guest that commits
8
+ ~1,859 MB, and it is the ONLY remaining item that cannot be dealt with on a running system. Measured
9
+ on this fleet, as NT AUTHORITY\SYSTEM, all three routes fail:
10
+
11
+ Set-MpPreference -DisableRealtimeMonitoring accepted, then silently reverted
12
+ WinDefend\Start = 4 ERROR: Access is denied
13
+ Policies\...\DisableAntiSpyware = 1 writes fine, and Windows ignores it while TP is on
14
+
15
+ Tamper Protection is a RUNTIME guard, so offline is not a workaround, it is the supported way: with
16
+ nothing booted there is no driver defending the hive, and it is just a file.
17
+
18
+ IMPORTANT, and it changes what this script can do: `Get-WindowsPackage -Online` on this ARM64 image
19
+ returns NO Defender package. tiny11 removes `Windows-Defender-Client-Package~31bf3856ad364e35~` on
20
+ x64 22621; there is no equivalent here, so DISM package REMOVAL cannot take Defender out offline
21
+ either. The offline SYSTEM-hive service disable below is the mechanism that actually works. The
22
+ feature-package loop still tries, because a future x64 image will have the package and removing it is
23
+ strictly better than disabling it.
24
+
25
+ Everything else that could be trimmed already has been, from the running guest, by
26
+ packages/nvda-worker/src/windows-trim.mjs. That yielded ~60 MB, because this ARM64 image ships with exactly
27
+ three provisioned Appx packages (Edge, DevHome, CrossDevice) and none of nano11's targets.
28
+
29
+ .NOTES
30
+ WHERE THIS RUNS
31
+ It needs DISM, so it runs on Windows, elevated -- in practice on one of the capture guests, driven
32
+ through `utmctl exec`, which runs as SYSTEM. It cannot run on the Mac.
33
+
34
+ WHAT IT DELIBERATELY KEEPS, AND WHY THAT MATTERS MORE THAN WHAT IT REMOVES
35
+ nano11builder and tiny11builder both delete Microsoft Edge and the LanguageFeatures Speech and
36
+ TextToSpeech packages. Those are the browser we capture through and the `oneCore` synth NVDA is
37
+ configured to use. An image without them does not fail loudly -- it produces empty transcripts that
38
+ look exactly like the NVDA mute faults this project has already spent days chasing. So the keep-list
39
+ is asserted before the image is committed, and the build FAILS rather than shipping a silent guest.
40
+
41
+ ARCHITECTURE
42
+ nano11builder hardcodes `amd64~~10.0.22621.1265` into package names, which is why it is x64-only.
43
+ These guests are ARM64. Every package name here is queried from the mounted image instead.
44
+
45
+ NO /ResetBase
46
+ tiny11's core variant runs `/Cleanup-Image /StartComponentCleanup /ResetBase` and deletes WinSxS
47
+ down to an allow-list. That is most of its disk saving and NONE of its memory saving -- WinSxS is a
48
+ component store on disk, not resident memory -- and it leaves an image that can never take a driver,
49
+ feature or update again. Our constraint is RAM. Skipped by default; -ResetBase if you ever need disk.
50
+ #>
51
+ [CmdletBinding()]
52
+ param(
53
+ # Drive letter of a mounted Windows 11 ISO, e.g. 'E'.
54
+ [Parameter(Mandatory)][ValidatePattern('^[A-Za-z]$')][string] $SourceDrive,
55
+ [string] $WorkDir = 'C:\lean11',
56
+ [string] $OutputIso = 'C:\lean11.iso',
57
+ [int] $ImageIndex = 0, # 0 = pick the Pro edition automatically
58
+ [switch] $ResetBase, # see NOTES: disk only, and makes the image unserviceable
59
+ [switch] $KeepMounted # leave the image mounted for inspection instead of committing
60
+ )
61
+
62
+ $ErrorActionPreference = 'Stop'
63
+ function Step($m) { Write-Host "`n==> $m" -ForegroundColor Cyan }
64
+ function OK($m) { Write-Host " OK $m" -ForegroundColor Green }
65
+ function Warn($m) { Write-Host " WARN $m" -ForegroundColor Yellow }
66
+
67
+ # --- what must survive, and what may go -------------------------------------
68
+
69
+ # Asserted present after every removal. The build fails if any of these vanish, because a guest that
70
+ # cannot run Edge or speak is indistinguishable from the mute faults we already chase.
71
+ $MustKeepPatterns = @(
72
+ 'LanguageFeatures-Speech', # NVDA's oneCore synth
73
+ 'LanguageFeatures-TextToSpeech'
74
+ )
75
+ $MustKeepPaths = @(
76
+ 'Program Files (x86)\Microsoft\Edge\Application\msedge.exe'
77
+ )
78
+
79
+ # Feature packages safe to remove. Speech, TextToSpeech and anything Edge are absent BY DESIGN.
80
+ $RemovableFeaturePatterns = @(
81
+ 'Microsoft-Windows-InternetExplorer-Optional-Package',
82
+ 'Microsoft-Windows-MediaPlayer-Package',
83
+ 'Microsoft-Windows-WordPad-FoD-Package',
84
+ 'Microsoft-Windows-StepsRecorder-Package',
85
+ 'Microsoft-Windows-TabletPCMath-Package',
86
+ 'Microsoft-Windows-Wallpaper-Content-Extended-FoD-Package',
87
+ 'Microsoft-Windows-LanguageFeatures-Handwriting',
88
+ 'Microsoft-Windows-LanguageFeatures-OCR',
89
+ # The reason this script exists. Removable offline only.
90
+ 'Windows-Defender-Client-Package'
91
+ )
92
+
93
+ # Provisioned Appx. Kept in sync with packages/nvda-worker/src/windows-trim.mjs, which has the unit tests
94
+ # proving Edge and the speech stack can never appear in a removal set.
95
+ $RemovableAppxPrefixes = @(
96
+ 'Clipchamp.Clipchamp', 'Microsoft.BingNews', 'Microsoft.BingWeather', 'Microsoft.GamingApp',
97
+ 'Microsoft.GetHelp', 'Microsoft.Getstarted', 'Microsoft.MicrosoftOfficeHub',
98
+ 'Microsoft.MicrosoftSolitaireCollection', 'Microsoft.People', 'Microsoft.PowerAutomateDesktop',
99
+ 'Microsoft.Todos', 'Microsoft.WindowsAlarms', 'microsoft.windowscommunicationsapps',
100
+ 'Microsoft.WindowsFeedbackHub', 'Microsoft.WindowsMaps', 'Microsoft.WindowsSoundRecorder',
101
+ 'Microsoft.Xbox.TCUI', 'Microsoft.XboxGameOverlay', 'Microsoft.XboxGamingOverlay',
102
+ 'Microsoft.YourPhone', 'Microsoft.ZuneMusic', 'Microsoft.ZuneVideo',
103
+ 'MicrosoftCorporationII.MicrosoftFamily', 'MicrosoftCorporationII.QuickAssist',
104
+ 'MicrosoftTeams', 'MSTeams', 'Microsoft.Windows.Copilot', 'Microsoft.Copilot',
105
+ 'Microsoft.OutlookForWindows', 'Microsoft.549981C3F5F10',
106
+ 'Microsoft.Windows.DevHome', 'MicrosoftWindows.CrossDevice'
107
+ )
108
+
109
+ # Anything matching these is never removed, whatever the lists above say. Deliberately blunter than it
110
+ # needs to be: leaving a few MB of Xbox speech-to-text captioning behind is the right price for a rule
111
+ # that cannot take out NVDA's synth.
112
+ $NeverRemovePatterns = @(
113
+ 'edge', 'webview', 'speech', 'texttospeech', 'onecore', 'narrator', 'accessib',
114
+ 'uiautomation', 'dotnet', 'netfx', 'vclibs', 'ui.xaml', 'runtime', 'servicingstack'
115
+ )
116
+
117
+ function Test-NeverRemove([string] $name) {
118
+ foreach ($p in $NeverRemovePatterns) { if ($name -match [regex]::Escape($p)) { return $true } }
119
+ return $false
120
+ }
121
+
122
+ if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
123
+ ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
124
+ throw 'Must run elevated. DISM offline servicing needs it, as does every removal below.'
125
+ }
126
+
127
+ $mount = Join-Path $WorkDir 'mount'
128
+ $src = "${SourceDrive}:"
129
+ if (-not (Test-Path "$src\sources")) { throw "No \sources on ${src} -- is the ISO mounted?" }
130
+
131
+ # --- copy and mount ----------------------------------------------------------
132
+
133
+ Step "Copying $src to $WorkDir"
134
+ New-Item -ItemType Directory -Force -Path $WorkDir, $mount | Out-Null
135
+ Copy-Item -Path "$src\*" -Destination $WorkDir -Recurse -Force
136
+ $wim = Join-Path $WorkDir 'sources\install.wim'
137
+ if (-not (Test-Path $wim)) {
138
+ $esd = Join-Path $WorkDir 'sources\install.esd'
139
+ if (-not (Test-Path $esd)) { throw 'Neither install.wim nor install.esd found in sources.' }
140
+ Step 'Converting install.esd to install.wim'
141
+ $idx = if ($ImageIndex -gt 0) { $ImageIndex } else {
142
+ (Get-WindowsImage -ImagePath $esd | Where-Object ImageName -match 'Pro' |
143
+ Select-Object -First 1).ImageIndex
144
+ }
145
+ & dism /English /Export-Image /SourceImageFile:$esd /SourceIndex:$idx `
146
+ /DestinationImageFile:$wim /Compress:max /CheckIntegrity
147
+ Remove-Item $esd -Force
148
+ $ImageIndex = 1
149
+ }
150
+ Set-ItemProperty $wim -Name IsReadOnly -Value $false
151
+
152
+ if ($ImageIndex -le 0) {
153
+ $ImageIndex = (Get-WindowsImage -ImagePath $wim | Where-Object ImageName -match 'Pro' |
154
+ Select-Object -First 1).ImageIndex
155
+ if (-not $ImageIndex) { throw 'No Pro edition found; pass -ImageIndex explicitly.' }
156
+ }
157
+ OK "using image index $ImageIndex"
158
+
159
+ Step 'Mounting the image'
160
+ & dism /English /Mount-Image /ImageFile:$wim /Index:$ImageIndex /MountDir:$mount
161
+ if ($LASTEXITCODE -ne 0) { throw 'Mount-Image failed.' }
162
+
163
+ try {
164
+ # --- removals, all queried from the image, never hardcoded ------------------
165
+
166
+ Step 'Removing provisioned apps'
167
+ $appx = Get-AppxProvisionedPackage -Path $mount
168
+ foreach ($pkg in $appx) {
169
+ $name = $pkg.PackageName
170
+ if (Test-NeverRemove $name) { continue }
171
+ if (-not ($RemovableAppxPrefixes | Where-Object { $name -like "$_*" })) { continue }
172
+ try {
173
+ Remove-AppxProvisionedPackage -Path $mount -PackageName $name -ErrorAction Stop | Out-Null
174
+ OK "removed appx $($name.Split('_')[0])"
175
+ } catch { Warn "could not remove $name : $($_.Exception.Message)" }
176
+ }
177
+
178
+ Step 'Removing feature packages (including Defender, which is the point)'
179
+ $packages = Get-WindowsPackage -Path $mount
180
+ foreach ($pattern in $RemovableFeaturePatterns) {
181
+ foreach ($pkg in ($packages | Where-Object { $_.PackageName -like "$pattern*" })) {
182
+ if (Test-NeverRemove $pkg.PackageName) { Warn "kept $($pkg.PackageName) (keep-list)"; continue }
183
+ try {
184
+ Remove-WindowsPackage -Path $mount -PackageName $pkg.PackageName -ErrorAction Stop | Out-Null
185
+ OK "removed $($pkg.PackageName)"
186
+ } catch { Warn "could not remove $($pkg.PackageName): $($_.Exception.Message)" }
187
+ }
188
+ }
189
+
190
+ # Defender, disabled in the offline hives. Tamper Protection is a runtime guard and there is no
191
+ # runtime here, which is the whole reason this step is in an image build and not in windows-trim.mjs.
192
+ #
193
+ # ORDER AND HIVE BOTH MATTER, and getting either wrong makes the whole step a no-op.
194
+ # Tamper Protection is itself a registry value, and it lives in SOFTWARE, not SYSTEM:
195
+ # `Microsoft\Windows Defender\Features\TamperProtection`. Disabling the services in SYSTEM while
196
+ # leaving that flag armed means the guest boots with Tamper Protection active and reverts them --
197
+ # which is exactly what happens on a live system, and would make this build look like it did nothing.
198
+ # Clear the flag first, then the services.
199
+ Step 'Disarming Tamper Protection in the offline SOFTWARE hive'
200
+ & reg load HKLM\zSOFTWARE "$mount\Windows\System32\config\SOFTWARE" | Out-Null
201
+ try {
202
+ $features = 'HKLM:\zSOFTWARE\Microsoft\Windows Defender\Features'
203
+ New-Item -Path $features -Force | Out-Null
204
+ Set-ItemProperty $features -Name TamperProtection -Value 0 -Type DWord
205
+ Set-ItemProperty $features -Name TamperProtectionSource -Value 0 -Type DWord -ErrorAction SilentlyContinue
206
+ OK 'TamperProtection = 0'
207
+ $policy = 'HKLM:\zSOFTWARE\Policies\Microsoft\Windows Defender'
208
+ New-Item -Path $policy -Force | Out-Null
209
+ Set-ItemProperty $policy -Name DisableAntiSpyware -Value 1 -Type DWord
210
+ Set-ItemProperty $policy -Name DisableAntiVirus -Value 1 -Type DWord
211
+ New-Item -Path "$policy\Real-Time Protection" -Force | Out-Null
212
+ Set-ItemProperty "$policy\Real-Time Protection" -Name DisableRealtimeMonitoring -Value 1 -Type DWord
213
+ OK 'DisableAntiSpyware / DisableRealtimeMonitoring policies set'
214
+ } finally {
215
+ # The GC call is not superstition: PowerShell keeps hive handles open and `reg unload` fails with
216
+ # "Access is denied" while they live, leaving the image mounted with a loaded hive.
217
+ [gc]::Collect(); [gc]::WaitForPendingFinalizers()
218
+ & reg unload HKLM\zSOFTWARE | Out-Null
219
+ }
220
+
221
+ Step 'Disabling Defender services in the offline SYSTEM hive'
222
+ & reg load HKLM\zSYSTEM "$mount\Windows\System32\config\SYSTEM" | Out-Null
223
+ try {
224
+ foreach ($svc in 'WinDefend', 'WdNisSvc', 'WdNisDrv', 'WdFilter', 'WdBoot', 'Sense') {
225
+ $key = "HKLM:\zSYSTEM\ControlSet001\Services\$svc"
226
+ if (Test-Path $key) { Set-ItemProperty $key -Name Start -Value 4; OK "$svc disabled" }
227
+ }
228
+ } finally {
229
+ [gc]::Collect(); [gc]::WaitForPendingFinalizers()
230
+ & reg unload HKLM\zSYSTEM | Out-Null
231
+ }
232
+
233
+ # --- the assertions that make this safe to ship -----------------------------
234
+
235
+ Step 'Verifying the image can still capture'
236
+ $after = Get-WindowsPackage -Path $mount
237
+ foreach ($needed in $MustKeepPatterns) {
238
+ if (-not ($after | Where-Object { $_.PackageName -like "*$needed*" })) {
239
+ throw "FATAL: $needed is missing from the image. NVDA would be silent. Refusing to commit."
240
+ }
241
+ OK "$needed present"
242
+ }
243
+ foreach ($path in $MustKeepPaths) {
244
+ if (-not (Test-Path (Join-Path $mount $path))) {
245
+ throw "FATAL: $path is missing. There would be no browser to capture through. Refusing to commit."
246
+ }
247
+ OK "$path present"
248
+ }
249
+
250
+ if ($ResetBase) {
251
+ Warn 'ResetBase: disk only, and the image can never be serviced again.'
252
+ & dism /English /Image:$mount /Cleanup-Image /StartComponentCleanup /ResetBase
253
+ }
254
+ } catch {
255
+ Step 'Build failed -- discarding the mount so a broken image is never written'
256
+ & dism /English /Unmount-Image /MountDir:$mount /Discard | Out-Null
257
+ throw
258
+ }
259
+
260
+ if ($KeepMounted) { OK "left mounted at $mount for inspection"; return }
261
+
262
+ Step 'Committing and unmounting'
263
+ & dism /English /Unmount-Image /MountDir:$mount /Commit
264
+ if ($LASTEXITCODE -ne 0) { throw 'Unmount /Commit failed.' }
265
+
266
+ Step 'Building the ISO'
267
+ $oscdimg = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit' `
268
+ -Recurse -Filter oscdimg.exe -ErrorAction SilentlyContinue | Select-Object -First 1
269
+ if (-not $oscdimg) {
270
+ Warn "oscdimg.exe not found (install the Windows ADK). The serviced tree is at $WorkDir."
271
+ return
272
+ }
273
+ $boot = "$WorkDir\efi\microsoft\boot\efisys.bin"
274
+ & $oscdimg.FullName -m -o -u2 -udfver102 -bootdata:"1#pEF,e,b$boot" $WorkDir $OutputIso
275
+ OK "wrote $OutputIso"
@@ -0,0 +1,174 @@
1
+ # Diagnose a broken NVDA capture worker. Read-only: changes nothing.
2
+ #
3
+ # Checks every layer in the order that failures actually cascade, and prints a
4
+ # VERDICT per layer rather than raw dumps, so the first FAIL is the thing to fix.
5
+ # Exits non-zero if any check failed. Copy it over and run it with -File:
6
+ #
7
+ # scp packages/worker-fleet/src/provisioning/diagnose-nvda-worker.ps1 user@host:C:/Users/user/
8
+ # ssh user@host "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\user\diagnose-nvda-worker.ps1"
9
+ #
10
+ # Do NOT pipe this to `powershell -Command -`. That mode silently truncated this
11
+ # script mid-run (the summary and exit code never executed), and a leading `<# #>`
12
+ # block comment suppresses its output entirely. Reserve stdin piping for short
13
+ # ad-hoc snippets; use -File for anything real.
14
+ #
15
+ # Every check here corresponds to a real outage. The mapping from error text to
16
+ # cause is in docs/nvda-worker-runbook.md; this script applies it automatically.
17
+ #
18
+ # Config comes from the environment rather than param(), so the same file works in
19
+ # either invocation mode:
20
+ # A11Y_REPO_PATH (default %USERPROFILE%\a11y-witness)
21
+ # A11Y_PORT (default 8765)
22
+
23
+ $ErrorActionPreference = 'SilentlyContinue'
24
+
25
+ $RepoPath = if ($env:A11Y_REPO_PATH) { $env:A11Y_REPO_PATH } else { Join-Path $env:USERPROFILE 'a11y-witness' }
26
+ $Port = if ($env:A11Y_PORT) { [int] $env:A11Y_PORT } else { 8765 }
27
+ $fails = @()
28
+
29
+ function Section($t) { Write-Host "`n== $t ==" -ForegroundColor Cyan }
30
+ function Pass($m) { Write-Host " PASS $m" -ForegroundColor Green }
31
+ function Fail($m, $fix) {
32
+ Write-Host " FAIL $m" -ForegroundColor Red
33
+ Write-Host " fix: $fix" -ForegroundColor Yellow
34
+ $script:fails += $m
35
+ }
36
+ function Info($m) { Write-Host " info $m" -ForegroundColor DarkGray }
37
+
38
+ # ---------------------------------------------------------------------------
39
+ Section 'Layer 1: interactive desktop session'
40
+ # Without a logged-on console session NVDA runs but announces NOTHING, and every
41
+ # capture comes back empty with no error -- the most confusing failure mode there is.
42
+ $sessions = query session 2>$null
43
+ Info (($sessions | Out-String).Trim())
44
+ if ($sessions | Select-String '^\s*>?\s*console\s+\S+\s+\d+\s+Active') {
45
+ Pass 'console session Active'
46
+ } else {
47
+ Fail 'no Active console session' 'log in at the VM console (not SSH); enable auto-logon so reboots recover'
48
+ }
49
+
50
+ # A modal dialog freezes the whole interactive session invisibly: captures return 0
51
+ # phrases even on a known-good page, with no error anywhere.
52
+ $dialogs = Get-Process | Where-Object { $_.MainWindowTitle -and $_.SessionId -ne 0 } |
53
+ Select-Object Name, MainWindowTitle
54
+ if ($dialogs) { Info "windows on the desktop: $((($dialogs | ForEach-Object { "$($_.Name):$($_.MainWindowTitle)" }) -join ' | '))" }
55
+ if (Get-Process LogonUI) { Fail 'LogonUI is running: the desktop is LOCKED' 'unlock the console; disable the screensaver/lock' }
56
+ else { Pass 'desktop not locked' }
57
+
58
+ # ---------------------------------------------------------------------------
59
+ Section 'Layer 2: worker process and port'
60
+ $node = Get-Process node
61
+ if ($node) { Pass "node running (pid $($node.Id -join ','), session $($node.SessionId -join ','))" }
62
+ else { Fail 'no node process' "Start-ScheduledTask -TaskName a11ysrv" }
63
+
64
+ if (Get-NetTCPConnection -LocalPort $Port -State Listen) { Pass "listening on $Port" }
65
+ else { Fail "nothing listening on $Port" "Start-ScheduledTask -TaskName a11ysrv; then check $RepoPath\server.log" }
66
+
67
+ $task = Get-ScheduledTask -TaskName 'a11ysrv'
68
+ if ($task) {
69
+ $trigCount = ($task.Triggers | Measure-Object).Count
70
+ if ($task.Principal.LogonType -ne 'Interactive') {
71
+ Fail "task LogonType is $($task.Principal.LogonType), not Interactive" 're-register with -LogonType Interactive, or NVDA gets no desktop'
72
+ } else { Pass 'task LogonType Interactive' }
73
+ if ($trigCount -eq 0) {
74
+ Fail 'task has NO trigger: it will not restart after a reboot' 'add: Set-ScheduledTask -TaskName a11ysrv -Trigger (New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME)'
75
+ } else { Pass "task has $trigCount trigger(s)" }
76
+ } else { Fail 'scheduled task a11ysrv is not registered' 'run scripts/provision-nvda-worker.ps1' }
77
+
78
+ # ---------------------------------------------------------------------------
79
+ Section 'Layer 3: guidepup / NVDA version pairing'
80
+ $gpJson = Join-Path $RepoPath 'node_modules\@guidepup\guidepup\package.json'
81
+ if (Test-Path $gpJson) {
82
+ $gp = (Get-Content $gpJson -Raw | ConvertFrom-Json).version
83
+ Info "@guidepup/guidepup $gp"
84
+ if ([version]($gp -replace '-.*$') -lt [version]'0.29.0') {
85
+ Fail "guidepup $gp cannot drive NVDA 2026.x" 'bump to >=0.29.2; symptom is "NVDA not installed" thrown from NVDAClient.connect'
86
+ } else { Pass "guidepup $gp speaks NVDA 2026 core Remote Access" }
87
+ } else { Fail '@guidepup/guidepup not installed' 'corepack pnpm install --frozen-lockfile' }
88
+
89
+ # ---------------------------------------------------------------------------
90
+ Section 'Layer 4: NVDA install integrity'
91
+ # guidepup >=0.29 resolves the install from this cache path, NOT the old
92
+ # HKCU\Software\Guidepup\Nvda registry pointer.
93
+ $cacheRoot = if ($env:GUIDEPUP_SCREEN_READERS_PATH) { $env:GUIDEPUP_SCREEN_READERS_PATH } else { Join-Path $env:LOCALAPPDATA 'guidepup' }
94
+ Info "cache root: $cacheRoot"
95
+ $nvdaExe = Get-ChildItem (Join-Path $cacheRoot 'nvda') -Recurse -Filter 'nvda.exe' |
96
+ Sort-Object FullName -Descending | Select-Object -First 1
97
+
98
+ if (-not $nvdaExe) {
99
+ Fail "no nvda.exe under $cacheRoot" 'run from the repo: npx @guidepup/setup install nvda (symptom: "NVDA is not supported")'
100
+ } else {
101
+ $dir = Split-Path $nvdaExe.FullName
102
+ $count = (Get-ChildItem $dir -Recurse -File).Count
103
+ $mb = [Math]::Round(((Get-ChildItem $dir -Recurse -File | Measure-Object Length -Sum).Sum / 1MB), 1)
104
+ Info "$dir -- $count files, $mb MB"
105
+ # A stub install (temp cleanup deleted the payload) launches and dies instantly:
106
+ # "Timed out waiting for NVDA to be running", and no nvda.log is ever written.
107
+ if ($count -lt 500) {
108
+ Fail "install looks GUTTED ($count files, expected ~1700+)" 'delete that directory and re-run npx @guidepup/setup install nvda'
109
+ } else { Pass "install intact ($count files, $mb MB)" }
110
+ foreach ($f in @('library.zip', 'nvda_slave.exe', 'nvda.exe')) {
111
+ if (Test-Path (Join-Path $dir $f)) { Pass " $f present" }
112
+ else { Fail " $f MISSING" 'the install is incomplete; reinstall NVDA' }
113
+ }
114
+ if (Test-Path (Join-Path $dir 'userConfig\remoteAccess\localRelay\NvdaRemoteRelay.pem')) {
115
+ Pass ' Remote Access relay cert present (NVDA 2026 core)'
116
+ } elseif (Test-Path (Join-Path $dir 'userConfig\addons\remote\globalPlugins\remoteClient\server.pem')) {
117
+ Pass ' legacy NVDA Remote add-on cert present'
118
+ } else {
119
+ Fail ' no speech-channel certificate' 'reinstall NVDA; without it connect() throws "NVDA not installed"'
120
+ }
121
+ # The single most damaging silent misconfiguration: probes record the Speech
122
+ # Viewer window instead of the page's response, and capture-check still passes.
123
+ if (Get-ChildItem $dir -Recurse -Filter 'nvda.ini' | Select-String 'showSpeechViewerAtStartup = True') {
124
+ Fail ' Speech Viewer is ENABLED: interaction probes will record "NVDA Speech Viewer"' 'set showSpeechViewerAtStartup = False in the install userConfig\nvda.ini'
125
+ } else { Pass ' Speech Viewer disabled' }
126
+ }
127
+
128
+ # ---------------------------------------------------------------------------
129
+ Section 'Layer 5: focus and dialog hygiene'
130
+ $flt = (Get-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ForegroundLockTimeout).ForegroundLockTimeout
131
+ if ($flt -eq 0) { Pass 'ForegroundLockTimeout = 0' } else { Fail "ForegroundLockTimeout = $flt" 'npx @guidepup/setup setup' }
132
+
133
+ $ss = (Get-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveActive).ScreenSaveActive
134
+ if ($ss -eq '0') { Pass 'screensaver disabled' } else { Fail "screensaver enabled ($ss)" 'set ScreenSaveActive=0; it steals foreground mid-capture' }
135
+
136
+ $notify = (Get-NetFirewallProfile).NotifyOnListen | Sort-Object -Unique
137
+ if ($notify -contains 'True') { Fail "firewall NotifyOnListen is True ($($notify -join ','))" 'Set-NetFirewallProfile -Profile Domain,Private,Public -NotifyOnListen False -- the allow-app dialog is unclickable on a VM' }
138
+ else { Pass 'firewall NotifyOnListen False (no allow-app dialogs)' }
139
+
140
+ $edge = Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Edge'
141
+ if ($edge.HideFirstRunExperience -eq 1) { Pass 'Edge HideFirstRunExperience = 1' }
142
+ else { Fail 'Edge first-run experience not suppressed' 'set HKLM\SOFTWARE\Policies\Microsoft\Edge HideFirstRunExperience=1, BrowserSignin=0' }
143
+
144
+ # ---------------------------------------------------------------------------
145
+ Section 'Layer 6: last capture outcome'
146
+ $log = Join-Path $RepoPath 'server.log'
147
+ if (Test-Path $log) {
148
+ Get-Content $log -Tail 12 | ForEach-Object { Info $_ }
149
+ $body = Get-Content $log -Raw
150
+ # Map the exact error strings to their real causes -- each of these was, at least
151
+ # once, misread as something else.
152
+ if ($body -match 'Timed out waiting for NVDA to be running') {
153
+ Fail 'log: NVDA start timed out' 'NVDA install is gutted/stub (Layer 4), or no interactive desktop (Layer 1)'
154
+ }
155
+ if ($body -match 'NVDA not installed') {
156
+ Fail 'log: "NVDA not installed"' 'MISLEADING: usually thrown by NVDAClient.connect when the speech-channel cert is missing or guidepup is too old (Layers 3-4), NOT a missing install'
157
+ }
158
+ if ($body -match 'NVDA is not supported') {
159
+ Fail 'log: "NVDA is not supported"' 'guidepup resolved no install at its cache path (Layer 4): npx @guidepup/setup install nvda'
160
+ }
161
+ if ($body -match 'afterStart\.lastSpoken=""' -and $body -match '-> 0 phrases') {
162
+ Fail 'log: 0 phrases and NVDA silent right after start' 'no interactive desktop, or a modal dialog is blocking the session (Layer 1)'
163
+ }
164
+ } else { Info "no server.log at $log yet" }
165
+
166
+ # ---------------------------------------------------------------------------
167
+ Write-Host ''
168
+ if ($fails.Count -eq 0) {
169
+ Write-Host 'ALL CHECKS PASSED -- if capture still fails, run a real capture and inspect' -ForegroundColor Green
170
+ Write-Host 'diagnostics[].afterStart.lastSpoken plus interaction.formChanges in the response.' -ForegroundColor Green
171
+ } else {
172
+ Write-Host "$($fails.Count) CHECK(S) FAILED -- fix the FIRST one; later failures are usually downstream." -ForegroundColor Red
173
+ exit 1
174
+ }