@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,827 @@
1
+ # Provision (or repair) the Windows NVDA capture worker. Idempotent: safe to
2
+ # re-run on an already-working machine, and that is the intended way to repair one.
3
+ #
4
+ # Encodes every step the worker needs, in dependency order, with a verification
5
+ # line after each. Written to be run by a person OR an agent. Copy it over and run
6
+ # it with -File:
7
+ #
8
+ # scp scripts/provision-nvda-worker.ps1 user@host:C:/Users/user/
9
+ # ssh user@host "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\user\provision-nvda-worker.ps1"
10
+ #
11
+ # Do NOT pipe this to `powershell -Command -`. That mode silently truncated a script
12
+ # of this size mid-run, and a leading `<# #>` block comment suppresses its output
13
+ # entirely. Reserve stdin piping for short ad-hoc snippets; use -File for anything real.
14
+ #
15
+ # Read docs/nvda-worker-runbook.md first if anything here is surprising. Each step
16
+ # exists because its absence has broken capture at least once.
17
+ #
18
+ # Config comes from the environment rather than param(), so the same file works in
19
+ # either invocation mode:
20
+ # A11Y_REPO_PATH checkout location (default %USERPROFILE%\a11y-witness)
21
+ # A11Y_PORT worker port; must match the client's A11Y_WORKER (default 8765)
22
+ # A11Y_TASK_NAME scheduled-task name (default a11ysrv)
23
+ # A11Y_SKIP_INSTALL set to 1 to re-apply only OS/NVDA configuration
24
+
25
+ $ErrorActionPreference = 'Stop'
26
+
27
+ $RepoPath = if ($env:A11Y_REPO_PATH) { $env:A11Y_REPO_PATH } else { Join-Path $env:USERPROFILE 'a11y-witness' }
28
+
29
+ # Resolve a repo file by NAME rather than by a hardcoded relative path.
30
+ #
31
+ # Four paths in this script were spelled 'src\capture\nvda\...', and the repo moved everything
32
+ # under packages/. One threw ("Worker launcher not found"); the other two sat behind
33
+ # `if (Test-Path ...)` and SILENTLY skipped -- so the Windows trim never ran and the a11ycheck
34
+ # task was never registered, on every guest provisioned since the restructure, with no message.
35
+ # A guard that cannot tell "moved" from "deliberately absent" reports neither.
36
+ #
37
+ # Searches the source trees only: a provisioned guest has node_modules, and recursing the whole
38
+ # repo takes minutes.
39
+ function Resolve-RepoFile($name, [switch] $Required) {
40
+ $roots = @('packages', 'scripts', 'src') |
41
+ ForEach-Object { Join-Path $RepoPath $_ } | Where-Object { Test-Path $_ }
42
+ foreach ($root in $roots) {
43
+ $hit = Get-ChildItem -Path $root -Filter $name -Recurse -File -ErrorAction SilentlyContinue |
44
+ Where-Object { $_.FullName -notmatch '\\node_modules\\' } | Select-Object -First 1
45
+ if ($hit) { return $hit.FullName }
46
+ }
47
+ if ($Required) { throw "$name not found under $RepoPath (searched: $($roots -join ', '))" }
48
+ Warn "$name not found under $RepoPath -- the step that needs it will be skipped"
49
+ return $null
50
+ }
51
+ $Port = if ($env:A11Y_PORT) { [int] $env:A11Y_PORT } else { 8765 }
52
+ $TaskName = if ($env:A11Y_TASK_NAME) { $env:A11Y_TASK_NAME } else { 'a11ysrv' }
53
+ $SkipInstall = $env:A11Y_SKIP_INSTALL -eq '1'
54
+ $script:warnings = @()
55
+
56
+ function Step($n, $msg) { Write-Host "`n[$n] $msg" -ForegroundColor Cyan }
57
+ function OK($msg) { Write-Host " OK $msg" -ForegroundColor Green }
58
+ function Warn($msg) { Write-Host " WARN $msg" -ForegroundColor Yellow; $script:warnings += $msg }
59
+
60
+ # pnpm and npx write progress and warnings to stderr as a matter of course. With
61
+ # $ErrorActionPreference = 'Stop', PowerShell promotes ANY native stderr line to a
62
+ # terminating NativeCommandError — so pnpm's routine ignored-build-scripts warning would
63
+ # abort provisioning with a misleading error. Relax error handling around native
64
+ # calls and gate on the only trustworthy signal: the process exit code.
65
+ function Invoke-Native($exe, [string[]] $cmdArgs, [string] $what, [int] $tail = 4) {
66
+ $prev = $ErrorActionPreference
67
+ $ErrorActionPreference = 'Continue'
68
+ try {
69
+ $out = & $exe @cmdArgs 2>&1
70
+ $code = $LASTEXITCODE
71
+ $out | Select-Object -Last $tail | ForEach-Object { Write-Host " $_" }
72
+ if ($code -ne 0) { throw "$what failed (exit $code)." }
73
+ } finally { $ErrorActionPreference = $prev }
74
+ }
75
+
76
+ # npx.ps1 is blocked by the default execution policy, so always call the .cmd.
77
+ $npx = Join-Path $env:ProgramFiles 'nodejs\npx.cmd'
78
+ # #2890: pnpm is reached as `corepack pnpm`, never a global install: Node 24's zip ships corepack and
79
+ # `packageManager` in package.json pins the version. The SAME spelling roles/worker/tasks/nvda.yml uses
80
+ # (packages/control/src/worker-install-sites-match.test.ts, provisioning-installs-with-pnpm.test.ts).
81
+ $corepack = Join-Path $env:ProgramFiles 'nodejs\corepack.cmd'
82
+
83
+ # ---------------------------------------------------------------------------
84
+ Step 1 'Preconditions'
85
+
86
+ if (-not (Test-Path $RepoPath)) { throw "Repo not found at $RepoPath. Clone it first, or pass -RepoPath." }
87
+ OK "repo at $RepoPath"
88
+
89
+ foreach ($exe in @($npx, $corepack)) {
90
+ if (-not (Test-Path $exe)) { throw "Node.js not found ($exe). Install it: winget install --id OpenJS.NodeJS.LTS -e --silent" }
91
+ }
92
+ OK "node $(& node --version), pnpm $(& $corepack pnpm --version)"
93
+
94
+ # NVDA is a GUI app: it needs a real logged-on desktop. Over SSH alone there is no
95
+ # interactive session and NVDA announces nothing at all, so this is worth asserting
96
+ # loudly rather than discovering later via empty transcripts.
97
+ # `query session` prefixes the CURRENT session with '>', so the anchor must allow it;
98
+ # otherwise this warns "NO active console session" on a box that plainly has one.
99
+ $console = (query session 2>$null | Select-String '^\s*>?\s*console\s+\S+\s+\d+\s+Active')
100
+ if ($console) { OK 'an interactive console session is logged on' }
101
+ else { Warn 'NO active console session. NVDA cannot run until someone is logged on at the console (see the auto-logon step).' }
102
+
103
+ $elevatedEarly = (New-Object Security.Principal.WindowsPrincipal(
104
+ [Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole(
105
+ [Security.Principal.WindowsBuiltinRole]::Administrator)
106
+
107
+ # The worker account must be LOCAL, and this is checked FIRST because everything below is
108
+ # per-user: the a11ysrv task is registered `-AtLogOn -User <account>`, NVDA caches to
109
+ # %LOCALAPPDATA%\guidepup, and the repo and Edge profile live in the profile. Provisioning a
110
+ # Microsoft account's profile and then auto-logging in as someone else produces a box that boots,
111
+ # logs in, and serves nothing -- so this refuses at the START rather than after ten minutes of
112
+ # work that will be abandoned.
113
+ #
114
+ # A Microsoft account cannot hold a blank password, and a blank password is what lets this fleet
115
+ # auto-log-on with no credential stored anywhere. So the answer is a dedicated local account,
116
+ # which is what the UTM guests already use.
117
+ # Not 0 and not 1: "I did my job and the machine is restarting to continue as another user" is a
118
+ # third outcome, and collapsing it into either of the other two makes the caller do the wrong thing.
119
+ $EXIT_HANDOFF_REBOOT = 75
120
+ $WorkerAccount = if ($env:A11Y_WORKER_ACCOUNT) { $env:A11Y_WORKER_ACCOUNT } else { 'witness' }
121
+ $me = $null
122
+ try { $me = Get-LocalUser -Name $env:USERNAME -ErrorAction Stop } catch { }
123
+
124
+ if ($me -and (-not $me.PrincipalSource -or $me.PrincipalSource -eq 'Local')) {
125
+ OK "running as LOCAL account '$env:USERNAME' -- correct for a worker"
126
+ } else {
127
+ $kind = if ($me) { $me.PrincipalSource } else { 'non-local' }
128
+ Warn "running as a $kind account ('$env:USERNAME'), which cannot hold a blank password."
129
+
130
+ # Create the local account and point auto-logon at it, so the ONLY thing left is to reboot and
131
+ # run this again. Creating it here rather than telling you to is the difference between a
132
+ # repeatable setup and a per-machine ritual that gets skipped on machine seven.
133
+ if (-not $elevatedEarly) {
134
+ throw "Elevation is required to create the local '$WorkerAccount' account. Re-run this elevated."
135
+ }
136
+ $blank = New-Object System.Security.SecureString
137
+ if (Get-LocalUser -Name $WorkerAccount -ErrorAction SilentlyContinue) {
138
+ Set-LocalUser -Name $WorkerAccount -Password $blank -PasswordNeverExpires $true
139
+ OK "local account '$WorkerAccount' already existed; password blanked"
140
+ } else {
141
+ # -Description is capped at 48 characters by a ValidateLength attribute, and going over it
142
+ # fails argument binding rather than truncating -- "Cannot validate argument on parameter
143
+ # 'Description'", which does not mention a length. This one is 43.
144
+ New-LocalUser -Name $WorkerAccount -NoPassword `
145
+ -FullName 'a11ign capture worker' `
146
+ -Description 'Console-only worker. No password by design.' | Out-Null
147
+ # Separate call: -PasswordNeverExpires lives in a different parameter set from -NoPassword,
148
+ # so combining them fails to bind rather than doing what it reads like.
149
+ Set-LocalUser -Name $WorkerAccount -PasswordNeverExpires $true -ErrorAction SilentlyContinue
150
+ OK "created local account '$WorkerAccount'"
151
+ }
152
+ # Administrators because provisioning sets the firewall, Edge policy and a scheduled task.
153
+ #
154
+ # VERIFIED, not assumed. This was `-ErrorAction SilentlyContinue` followed by an unconditional
155
+ # OK saying the account was an administrator -- so a silent failure printed success, which is
156
+ # the exact shape this file has spent the day removing, written by me while removing it.
157
+ # It also matters beyond provisioning: sshd picks administrators_authorized_keys for a member
158
+ # of that group and the profile's own authorized_keys for anyone else, so getting this wrong
159
+ # makes key auth fail later with no error that mentions group membership.
160
+ Add-LocalGroupMember -Group 'Administrators' -Member $WorkerAccount -ErrorAction SilentlyContinue
161
+ $inAdmins = Get-LocalGroupMember -Group 'Administrators' -ErrorAction SilentlyContinue |
162
+ Where-Object { $_.Name -like "*\$WorkerAccount" }
163
+ if ($inAdmins) { OK "'$WorkerAccount' is an administrator" }
164
+ else { throw "could not add '$WorkerAccount' to Administrators -- provisioning needs it for the firewall, Edge policy and task registration" }
165
+
166
+ $winlogon = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
167
+ Set-ItemProperty $winlogon -Name AutoAdminLogon -Value '1' -Type String -Force
168
+ Set-ItemProperty $winlogon -Name DefaultUserName -Value $WorkerAccount -Type String -Force
169
+ Set-ItemProperty $winlogon -Name DefaultDomainName -Value $env:COMPUTERNAME -Type String -Force
170
+ foreach ($dead in @('DefaultPassword', 'AutoLogonCount')) {
171
+ Remove-ItemProperty $winlogon -Name $dead -ErrorAction SilentlyContinue
172
+ }
173
+ OK "auto-logon now points at '$WorkerAccount' with no stored credential"
174
+
175
+ # Continue AUTOMATICALLY at that account's first logon, rather than leaving a manual step.
176
+ #
177
+ # A scheduled task, not RunOnce or a Startup shortcut: those run without elevation, and the rest
178
+ # of provisioning needs it for the firewall, Edge policy and task registration. -RunLevel Highest
179
+ # is the only mechanism here that gets an elevated, INTERACTIVE session -- which NVDA needs too.
180
+ #
181
+ # The script is copied to ProgramData rather than run from the old profile: `witness` is an
182
+ # administrator and could read C:\Users\borem\..., but a machine-wide appliance should not depend
183
+ # on the profile of the human who happened to unbox it.
184
+ $bootstrapSrc = Resolve-RepoFile 'bootstrap-windows-worker.ps1'
185
+ if ($bootstrapSrc) {
186
+ $shared = 'C:\ProgramData\a11y-witness'
187
+ New-Item -ItemType Directory -Force -Path $shared | Out-Null
188
+ $bootstrapDst = Join-Path $shared 'bootstrap.ps1'
189
+ Copy-Item $bootstrapSrc $bootstrapDst -Force
190
+
191
+ Unregister-ScheduledTask -TaskName 'a11ybootstrap' -Confirm:$false -ErrorAction SilentlyContinue
192
+ # LOG IT. A scheduled task runs hidden, so without a redirect an unattended failure leaves
193
+ # nothing to read at all -- which is how this run became a guessing game. Transcript, not just
194
+ # stdout redirection, because PowerShell writes Write-Host to the host rather than stdout and
195
+ # the OK/Warn lines here are all Write-Host.
196
+ $bootstrapLog = Join-Path $shared 'bootstrap.log'
197
+ $inner = "Start-Transcript -Path '$bootstrapLog' -Append; " +
198
+ "try { & '$bootstrapDst' } finally { Stop-Transcript }"
199
+ Register-ScheduledTask -TaskName 'a11ybootstrap' `
200
+ -Action (New-ScheduledTaskAction -Execute 'powershell.exe' `
201
+ -Argument "-NoProfile -ExecutionPolicy Bypass -Command `"$inner`"") `
202
+ -Principal (New-ScheduledTaskPrincipal -UserId $WorkerAccount -LogonType Interactive -RunLevel Highest) `
203
+ -Trigger (New-ScheduledTaskTrigger -AtLogOn -User $WorkerAccount) `
204
+ -Settings (New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 1) -StartWhenAvailable) `
205
+ -Force | Out-Null
206
+ # The bootstrap unregisters this itself once it gets far enough to succeed, so it does not run
207
+ # on every logon for ever -- see the note there about why continuous re-provisioning is a
208
+ # different and more dangerous idea than finishing a first install.
209
+ OK "registered 'a11ybootstrap' -- finishes setup at $WorkerAccount's first logon"
210
+ OK "its transcript will be at $bootstrapLog"
211
+ } else {
212
+ Warn 'bootstrap-windows-worker.ps1 not found in the repo -- cannot continue automatically.'
213
+ Warn "After rebooting, run the bootstrap by hand as $WorkerAccount."
214
+ }
215
+
216
+ Write-Host @"
217
+
218
+ --- Handing off to '$WorkerAccount' ---
219
+
220
+ Nothing below this point would land in the right profile, so this run stops here.
221
+
222
+ '$WorkerAccount' exists, is an administrator, has no password, and is the auto-logon user.
223
+ The 'a11ybootstrap' task will finish setup elevated at its first logon and then remove
224
+ itself. REBOOTING NOW -- no further input needed. Watch /health come up.
225
+
226
+ This detour happens ONCE per machine, and not at all on a box whose autounattend.xml
227
+ created a local account at install time.
228
+ "@
229
+
230
+ # Reboot ourselves rather than telling somebody to. The whole point of this path is that it
231
+ # needs no operator, and "now go and restart it" is the manual step it exists to remove.
232
+ #
233
+ # 20s so the message is readable and an operator watching can Ctrl-C the shutdown if they
234
+ # were mid-something: `shutdown /a` aborts it.
235
+ Start-Process -FilePath 'shutdown.exe' -ArgumentList '/r', '/t', '20' -NoNewWindow
236
+
237
+ # A DISTINCT exit code, not 0 and not a throw. The caller must know this is a planned handoff:
238
+ # - exit 0 would let the bootstrap continue to its final step, which UNREGISTERS
239
+ # a11ybootstrap -- disarming the task we just armed, three lines after arming it.
240
+ # - a throw reads as a failure in the log of a run that did exactly what it should.
241
+ exit $EXIT_HANDOFF_REBOOT
242
+ }
243
+
244
+ $elevated = $elevatedEarly
245
+ if ($elevated) { OK 'session is elevated (needed for the firewall/Edge-policy steps)' }
246
+ else { Warn 'not elevated: the firewall and Edge-policy steps will be skipped.' }
247
+
248
+ # ---------------------------------------------------------------------------
249
+ Step 2 'Dependencies'
250
+
251
+ if ($SkipInstall) { OK 'skipped (A11Y_SKIP_INSTALL=1)' }
252
+ else {
253
+ Push-Location $RepoPath
254
+ try {
255
+ $env:COREPACK_ENABLE_DOWNLOAD_PROMPT = '0'
256
+ # ONE-TIME MIGRATION: a tree npm made carries node_modules\.package-lock.json, and pnpm installed over it
257
+ # leaves every npm-hoisted package in place -- the hoisting that lets an undeclared import resolve.
258
+ # `rmdir` rather than Remove-Item -Recurse: npm's workspace links are junctions, and rmdir removes a
259
+ # junction without following it into packages\.
260
+ if (Test-Path 'node_modules\.package-lock.json') {
261
+ OK 'node_modules was made by npm: removing it once, so nothing npm hoisted survives beside pnpm'
262
+ cmd.exe /d /c rmdir /s /q node_modules
263
+ if (Test-Path 'node_modules') { throw 'could not remove node_modules' }
264
+ }
265
+ # FROZEN: pnpm-lock.yaml is the specification, so a drifted manifest refuses rather than resolving a
266
+ # tree no other worker has (guidepup's version is evidence in the capture cache key).
267
+ Invoke-Native $corepack @('pnpm', 'install', '--frozen-lockfile', '--prefer-offline') 'installing dependencies'
268
+ }
269
+ finally { Pop-Location }
270
+ }
271
+ $gpManifest = Join-Path $RepoPath 'node_modules\@guidepup\guidepup\package.json'
272
+ if (-not (Test-Path $gpManifest)) { throw '@guidepup/guidepup is not installed. Run: corepack pnpm install --frozen-lockfile' }
273
+ $gpVersion = (Get-Content $gpManifest -Raw | ConvertFrom-Json).version
274
+ OK "@guidepup/guidepup $gpVersion"
275
+
276
+ # guidepup <0.29 looks for the old NVDA Remote ADD-ON certificate; NVDA 2026.1.x
277
+ # ships Remote Access in core and has no such add-on, so the pair fails at
278
+ # NVDAClient.connect and reports it, misleadingly, as "NVDA not installed".
279
+ if ([version]($gpVersion -replace '-.*$') -lt [version]'0.29.0') {
280
+ Warn "guidepup $gpVersion cannot drive NVDA 2026.x. Bump it to >=0.29.2 in package.json."
281
+ }
282
+
283
+ # ---------------------------------------------------------------------------
284
+ Step 3 'Guidepup environment (zeroes ForegroundLockTimeout so Edge can take focus)'
285
+
286
+ Push-Location $RepoPath
287
+ try { Invoke-Native $npx @('--yes', '@guidepup/setup', 'setup') 'guidepup setup' 2 }
288
+ finally { Pop-Location }
289
+ # Apply ForegroundLockTimeout to THIS session, now, via SystemParametersInfo. A registry
290
+ # write alone is not enough: the value is cached per session, and Windows does not reliably
291
+ # consume it at logon either, so "it will work after a reboot" is not a safe claim. Left
292
+ # non-zero, captures return 0 phrases with NO error anywhere -- nvda.start() succeeds,
293
+ # windowsActivate reports ok, and every read comes back empty, because Edge is refused the
294
+ # foreground. See scripts/apply-foreground-lock-timeout.ps1 for the detail.
295
+ $fltScript = Join-Path $PSScriptRoot 'apply-foreground-lock-timeout.ps1'
296
+ if (Test-Path $fltScript) {
297
+ $fltOut = & powershell -NoProfile -ExecutionPolicy Bypass -File $fltScript 2>&1
298
+ if ($LASTEXITCODE -eq 0) { OK ($fltOut | Select-Object -First 1) }
299
+ else { Warn (($fltOut | Out-String).Trim()) }
300
+ } else {
301
+ Warn "not found: $fltScript (ForegroundLockTimeout not applied; Edge may fail to take focus)"
302
+ }
303
+
304
+ # ---------------------------------------------------------------------------
305
+ Step 4 'Install NVDA'
306
+
307
+ # Run from the repo: the installer reads the LOCAL @guidepup/guidepup manifest.json
308
+ # to decide which NVDA build to fetch, so this is what keeps the screen reader and
309
+ # the driver in lockstep. It caches to %LOCALAPPDATA%\guidepup (override with
310
+ # GUIDEPUP_SCREEN_READERS_PATH) -- NOT %TEMP%, which Windows cleanup empties.
311
+ Push-Location $RepoPath
312
+ try { Invoke-Native $npx @('--yes', '@guidepup/setup', 'install', 'nvda') 'guidepup install nvda' 4 }
313
+ finally { Pop-Location }
314
+
315
+ $cacheRoot = if ($env:GUIDEPUP_SCREEN_READERS_PATH) { $env:GUIDEPUP_SCREEN_READERS_PATH } else { Join-Path $env:LOCALAPPDATA 'guidepup' }
316
+ $nvdaExe = Get-ChildItem (Join-Path $cacheRoot 'nvda') -Recurse -Filter 'nvda.exe' -ErrorAction SilentlyContinue |
317
+ Sort-Object FullName -Descending | Select-Object -First 1
318
+ if (-not $nvdaExe) { throw "NVDA not found under $cacheRoot. The install step failed." }
319
+
320
+ # A gutted install is the failure this whole script exists to prevent: %TEMP%
321
+ # cleanup once deleted library.zip and left nvda.exe as a stub that launches and
322
+ # dies, producing "Timed out waiting for NVDA to be running" and no nvda.log.
323
+ $nvdaDir = Split-Path $nvdaExe.FullName
324
+ $fileCount = (Get-ChildItem $nvdaDir -Recurse -File -ErrorAction SilentlyContinue).Count
325
+ if ($fileCount -lt 500) { throw "NVDA install at $nvdaDir looks gutted ($fileCount files; expect ~1700+). Delete it and re-run." }
326
+ OK "NVDA at $nvdaDir ($fileCount files)"
327
+ foreach ($required in @('library.zip', 'nvda_slave.exe')) {
328
+ if (-not (Test-Path (Join-Path $nvdaDir $required))) { throw "NVDA install is incomplete: $required missing." }
329
+ }
330
+ OK 'payload files present (library.zip, nvda_slave.exe)'
331
+
332
+ # ---------------------------------------------------------------------------
333
+ Step 5 'Disable the NVDA Speech Viewer'
334
+
335
+ # Guidepup's bundled config ships the Speech Viewer ON. That window's focus event
336
+ # is announced, so it lands in the spokenPhraseLog delta captured right after
337
+ # activating a control: every interaction probe returns "NVDA Speech Viewer"
338
+ # instead of the page's response, making an accessible page and an inaccessible
339
+ # one indistinguishable. capture-check still passes, because it asserts that the
340
+ # probe fired, not what it heard -- so this fails silently and must be asserted.
341
+ $patched = 0
342
+ foreach ($ini in (Get-ChildItem $nvdaDir -Recurse -Filter 'nvda.ini' -ErrorAction SilentlyContinue)) {
343
+ $body = Get-Content $ini.FullName -Raw
344
+ $new = $body -replace 'showSpeechViewerAtStartup = True', 'showSpeechViewerAtStartup = False'
345
+ if ($new -ne $body) { Set-Content -Path $ini.FullName -Value $new -NoNewline; $patched++ }
346
+ }
347
+ $stillOn = (Get-ChildItem $nvdaDir -Recurse -Filter 'nvda.ini' -ErrorAction SilentlyContinue |
348
+ Select-String 'showSpeechViewerAtStartup = True')
349
+ if ($stillOn) { throw 'Speech Viewer is still enabled; interaction probes would be unusable.' }
350
+ OK "Speech Viewer off (patched $patched file(s)). NOTE: reinstalling NVDA resets this -- re-run this script."
351
+
352
+ # ---------------------------------------------------------------------------
353
+ Step 6 'No blocking dialogs (a VM console cannot be clicked)'
354
+
355
+ if ($elevated) {
356
+ # A program that starts listening with no matching rule raises the "allow this
357
+ # app" alert, which blocks the whole interactive session until dismissed.
358
+ Set-NetFirewallProfile -Profile Domain, Private, Public -NotifyOnListen False
359
+ OK 'firewall NotifyOnListen = False (no allow-app dialogs)'
360
+
361
+ $ruleName = "a11ign worker $Port"
362
+ if (-not (Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue)) {
363
+ New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Action Allow `
364
+ -Protocol TCP -LocalPort $Port -Profile Any | Out-Null
365
+ }
366
+ OK "inbound rule '$ruleName'"
367
+
368
+ # A brand-new Edge profile shows a welcome/sign-in surface. On a page with no
369
+ # headings, NVDA quick-nav escapes the empty document into that browser UI and
370
+ # records it as phantom elements ("Welcome to Microsoft Edge").
371
+ $edgeKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Edge'
372
+ New-Item -Path $edgeKey -Force | Out-Null
373
+ Set-ItemProperty $edgeKey -Name 'HideFirstRunExperience' -Value 1 -Type DWord
374
+ Set-ItemProperty $edgeKey -Name 'BrowserSignin' -Value 0 -Type DWord
375
+ # Stop Edge doing anything when we are not asking it to. A capture launches and kills Edge
376
+ # once per capture -- 90 times in a dataset run -- and background work racing that is both
377
+ # a source of leaked processes and of noise in the guest.
378
+ #
379
+ # Observed after a 45-pair run: 5 msedge processes alive on an idle, freshly booted machine
380
+ # with zero captures run, plus WER crash reports from MicrosoftEdgeUpdate.exe and Edge's
381
+ # own setup.exe (EdgeInstallerError 0x220). Edge was updating itself underneath the run.
382
+ Set-ItemProperty $edgeKey -Name 'BackgroundModeEnabled' -Value 0 -Type DWord
383
+ Set-ItemProperty $edgeKey -Name 'StartupBoostEnabled' -Value 0 -Type DWord
384
+ # Autofill draws a suggestion icon inside recognised inputs, and NVDA announces it as an embedded
385
+ # object (U+FFFC) appended to the field announcement. Whether it appears depends on what the durable
386
+ # profile has learned, so the same page announces differently over the life of a run -- measured
387
+ # rising from 3% to 31% of affected captures as the profile accumulated, with 26 good/bad pairs in
388
+ # the corpus disagreeing about it. Off, so a form field announcement is a property of the PAGE.
389
+ Set-ItemProperty $edgeKey -Name 'AutofillAddressEnabled' -Value 0 -Type DWord
390
+ Set-ItemProperty $edgeKey -Name 'AutofillCreditCardEnabled' -Value 0 -Type DWord
391
+ Set-ItemProperty $edgeKey -Name 'PasswordManagerEnabled' -Value 0 -Type DWord
392
+ OK 'Edge policies set (first-run suppressed, no background mode, no startup boost)'
393
+
394
+ # Edge auto-update, off. On a workstation this is right; on a capture appliance it means an
395
+ # installer runs unannounced while we are driving the browser.
396
+ $edgeUpdateKey = 'HKLM:\SOFTWARE\Policies\Microsoft\EdgeUpdate'
397
+ New-Item -Path $edgeUpdateKey -Force | Out-Null
398
+ Set-ItemProperty $edgeUpdateKey -Name 'UpdateDefault' -Value 0 -Type DWord
399
+ Set-ItemProperty $edgeUpdateKey -Name 'AutoUpdateCheckPeriodMinutes' -Value 0 -Type DWord
400
+ OK 'Edge auto-update disabled'
401
+
402
+ # Windows must not choose its own downtime. During one 94-minute dataset run Windows
403
+ # Update rebooted the worker TWICE mid-capture:
404
+ # id=1074 winlogon.exe has initiated the power off ... on behalf of NT AUTHORITY\SYSTEM
405
+ # at 09:02:55 and 09:09:30. The run only survived because auto-logon plus the at-logon task
406
+ # self-heal, and the outages happened to fall between cases.
407
+ #
408
+ # Updates still download and install -- we are not leaving the box unpatched -- but the
409
+ # reboot waits for a human. The worker always has a logged-on user (auto-logon is required
410
+ # for NVDA), which is exactly the condition this policy keys on.
411
+ # Nothing may pop to the foreground uninvited. A capture works by forcing Edge to the front
412
+ # and reading what NVDA announces; a notification that steals focus mid-capture corrupts the
413
+ # evidence, and one that sits over the page does it silently. Observed on the guest: OneDrive
414
+ # offering "Turn On Windows Backup" during a run.
415
+ $explorerPolicy = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Explorer'
416
+ New-Item -Path $explorerPolicy -Force | Out-Null
417
+ Set-ItemProperty $explorerPolicy -Name 'DisableNotificationCenter' -Value 1 -Type DWord
418
+ $oneDrive = 'HKLM:\SOFTWARE\Policies\Microsoft\OneDrive'
419
+ New-Item -Path $oneDrive -Force | Out-Null
420
+ Set-ItemProperty $oneDrive -Name 'DisableFileSyncNGSC' -Value 1 -Type DWord
421
+ Set-ItemProperty $oneDrive -Name 'PreventNetworkTrafficPreUserSignIn' -Value 1 -Type DWord
422
+ $contentDelivery = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\ContentDeliveryManager'
423
+ if (Test-Path $contentDelivery) {
424
+ foreach ($n in @('SubscribedContent-338389Enabled','SubscribedContent-310093Enabled','SoftLandingEnabled','SystemPaneSuggestionsEnabled')) {
425
+ Set-ItemProperty $contentDelivery -Name $n -Value 0 -Type DWord -ErrorAction SilentlyContinue
426
+ }
427
+ }
428
+ # The policy stops OneDrive starting again; it does not remove the per-user Run entry that
429
+ # relaunches it at logon, and it does not dismiss a toast already on screen. Observed: the
430
+ # policy applied cleanly and the "Turn On Windows Backup" prompt was still sitting over the
431
+ # desktop. Remove it properly.
432
+ Get-Process OneDrive -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
433
+ foreach ($n in @('OneDrive', 'OneDriveSetup')) {
434
+ Remove-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name $n -ErrorAction SilentlyContinue
435
+ }
436
+ $odSetup = "$env:SystemRoot\SysWOW64\OneDriveSetup.exe"
437
+ if (-not (Test-Path $odSetup)) { $odSetup = "$env:SystemRoot\System32\OneDriveSetup.exe" }
438
+ if (Test-Path $odSetup) { Start-Process $odSetup -ArgumentList '/uninstall' -Wait -ErrorAction SilentlyContinue }
439
+ OK 'notifications, suggestion popups and OneDrive removed'
440
+
441
+ $auKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU'
442
+ New-Item -Path $auKey -Force | Out-Null
443
+ Set-ItemProperty $auKey -Name 'NoAutoRebootWithLoggedOnUsers' -Value 1 -Type DWord
444
+ OK 'Windows Update will not reboot on its own while a user is logged on'
445
+ } else {
446
+ Warn 'skipped firewall + Edge/Update policies (needs elevation)'
447
+ }
448
+
449
+ # UAC prompts land on the secure desktop, where NVDA cannot read them and
450
+ # automation cannot click them. The answer is to need no elevation at runtime --
451
+ # never to elevate the worker. Guidepup drives nvda_noUIAccess.exe, which cannot
452
+ # read elevated windows at all, so an elevated browser would capture nothing.
453
+ if ((Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -ErrorAction SilentlyContinue).PromptOnSecureDesktop -eq 1) {
454
+ OK 'UAC prompts on secure desktop (fine: nothing in the capture path needs elevation)'
455
+ }
456
+
457
+ # The screensaver can take the foreground mid-capture and steal focus from Edge.
458
+ Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveActive -Value '0'
459
+ Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveTimeOut -Value '0'
460
+ OK 'screensaver disabled'
461
+
462
+ # ---------------------------------------------------------------------------
463
+ Step 7 'Trim Windows background services and apps'
464
+
465
+ # A capture guest runs a browser and a screen reader -- 425 MB of work -- inside ~1,850 MB of committed
466
+ # memory. That matters because host cost scales at ~1.8-2.0x the guest's CONFIGURED RAM, so the ceiling
467
+ # is the big lever: 4,096 MB costs the host ~8.1 GB, 2,560 MB costs ~4.7 GB. But the ceiling cannot go
468
+ # below what Windows commits, and a stock guest at 2,560 MB was measured paging badly -- capture phases
469
+ # went from ~20 s to a 36.6 s median with 4 recoveries in 10 captures. Trimming the OS is what makes a
470
+ # lower ceiling reachable; it is not the prize itself.
471
+ #
472
+ # This lives here, and not in the worker, because every call below needs elevation and the worker task
473
+ # is deliberately RunLevel Limited (see Step 8). The worker attempts the same trim at boot, detects it
474
+ # is unelevated, and records `needsElevation` rather than failing silently -- which it did, on three
475
+ # consecutive boots, before this existed.
476
+ #
477
+ # The list is nano11builder's technique with none of its choices: nano11 deletes Edge and
478
+ # LanguageFeatures-Speech/-TextToSpeech (the browser we capture through and NVDA's `oneCore` synth), and
479
+ # hardcodes amd64 package names while these guests are ARM64. So names are read from the guest, and
480
+ # packages/nvda-worker/src/windows-trim.mjs holds the authoritative allow/deny lists with the tests that keep
481
+ # Edge and the speech stack out of the removal set.
482
+ $trimScript = Resolve-RepoFile 'windows-trim.mjs'
483
+ $trimMarker = Join-Path $RepoPath '.windows-trimmed'
484
+ if (Test-Path $trimScript) {
485
+ if (Test-Path $trimMarker) {
486
+ OK "already trimmed ($((Get-Content $trimMarker -Raw).Trim()))"
487
+ } else {
488
+ # Elevated here, so DISM and sc.exe both work. Minutes, mostly Appx removal.
489
+ & node $trimScript $trimMarker 2>&1 | Select-Object -Last 6 | ForEach-Object { Write-Host " $_" }
490
+ if (Test-Path $trimMarker) { OK "trimmed: $((Get-Content $trimMarker -Raw).Trim())" }
491
+ else { Warn 'trim produced no marker -- see .windows-trimmed.log' }
492
+ }
493
+ } else {
494
+ Warn "trim script not found at $trimScript (deploy it first)"
495
+ }
496
+
497
+ # Defender is ~242 MB, the single largest removable item, and Tamper Protection blocks turning it off
498
+ # from a running system -- confirmed on this fleet: Get-MpComputerStatus reports IsTamperProtected=True.
499
+ # That is why tiny11 does it offline against a mounted image. Reported, not fought: if Defender is the
500
+ # only thing that needs offline access, ~242 MB is the entire return on owning an ISO pipeline, and that
501
+ # should be decided on the number rather than on enthusiasm.
502
+ $tp = try { (Get-MpComputerStatus).IsTamperProtected } catch { $null }
503
+ if ($tp -eq $true) {
504
+ Warn 'Defender stays (~242 MB): Tamper Protection is ON and blocks disabling it from the running OS. Turn it off in Windows Security, or remove Defender offline in an image build.'
505
+ } elseif ($tp -eq $false) {
506
+ try {
507
+ Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction Stop
508
+ OK 'Defender real-time protection disabled (Tamper Protection was off)'
509
+ } catch { Warn "Defender still on: $($_.Exception.Message)" }
510
+ } else {
511
+ OK 'Defender not present or not queryable'
512
+ }
513
+
514
+ # ---------------------------------------------------------------------------
515
+ Step 7a 'Never sleep, and keep the NIC awake'
516
+
517
+ # A worker that sleeps is a worker that has vanished, and this is BARE-METAL ONLY: VMs do not
518
+ # sleep, so the whole fleet ran for months without needing it. On the first physical box it
519
+ # presented as `EHOSTUNREACH <worker>:8765` for every request in an evidence-check run --
520
+ # 48 instant failures -- and then answered a curl 30 seconds later. Intermittent unreachability
521
+ # reads as a flaky network or a wedged worker; it was Windows power management doing its job.
522
+ #
523
+ # Two separate mechanisms, and fixing only one leaves the fault intermittent:
524
+ # - the sleep/hibernate timers put the whole machine away
525
+ # - the NIC's own selective suspend powers the adapter down while the OS stays up
526
+ try {
527
+ # 0 = never. AC only: these boxes have no battery, and a laptop-based worker sleeping on
528
+ # battery is correct behaviour rather than a fault.
529
+ foreach ($setting in @('standby-timeout-ac', 'hibernate-timeout-ac', 'disk-timeout-ac')) {
530
+ Invoke-Native 'powercfg.exe' @('/change', $setting, '0') "powercfg $setting" 1
531
+ }
532
+ # Hibernation off entirely: it also reclaims hiberfil.sys, which is RAM-sized on a disk that
533
+ # holds a browser profile and an NVDA install.
534
+ & powercfg.exe /hibernate off 2>&1 | Out-Null
535
+ OK 'sleep, hibernate and disk timeouts disabled on AC'
536
+ } catch {
537
+ Warn "powercfg failed ($($_.Exception.Message)) -- the worker may sleep and look unreachable"
538
+ }
539
+
540
+ try {
541
+ # "Allow the computer to turn off this device to save power" is ON by default on most NICs.
542
+ # Set-NetAdapterPowerManagement is not present on every SKU, so fall back to the registry
543
+ # value it writes: PnPCapabilities 24 = disable both power-down and wake-armed.
544
+ $adapters = Get-NetAdapter -Physical -ErrorAction Stop | Where-Object { $_.Status -eq 'Up' }
545
+ foreach ($a in $adapters) {
546
+ try {
547
+ Set-NetAdapterPowerManagement -Name $a.Name -AllowComputerToTurnOffDevice Disabled -ErrorAction Stop
548
+ } catch {
549
+ $key = "HKLM:\SYSTEM\CurrentControlSet\Control\Class\{4d36e972-e325-11ce-bfc1-08002be10318}"
550
+ Get-ChildItem $key -ErrorAction SilentlyContinue | Where-Object {
551
+ (Get-ItemProperty $_.PSPath -Name DriverDesc -ErrorAction SilentlyContinue).DriverDesc -eq $a.InterfaceDescription
552
+ } | ForEach-Object { Set-ItemProperty $_.PSPath -Name PnPCapabilities -Value 24 -Type DWord -Force }
553
+ }
554
+ OK "NIC '$($a.Name)' will not be powered down"
555
+ }
556
+ if (-not $adapters) { Warn 'no physical network adapter reported Up -- NIC power saving not checked' }
557
+ } catch {
558
+ Warn "NIC power management not adjusted ($($_.Exception.Message))"
559
+ }
560
+
561
+ # ---------------------------------------------------------------------------
562
+ Step 7b 'Durable browser capture profile'
563
+
564
+ # browsers.mjs defaults this under %LOCALAPPDATA%, one directory PER BROWSER; A11Y_BROWSER_PROFILE
565
+ # overrides for any browser and A11Y_EDGE_PROFILE for Edge only (kept because this script has always
566
+ # read it, and provisioning preparing one path while the worker uses another leaves a first-run
567
+ # browser -- which NVDA records as phantom elements on pages with no headings).
568
+ # It must not sit in %TEMP%, for the same reason the NVDA install must not.
569
+ #
570
+ # Both are created regardless of which browser this guest will drive: they cost an empty directory, and
571
+ # a guest whose browser is switched later should not need re-provisioning to get a prepared profile.
572
+ $profileNames = @('edge-profile', 'chrome-profile')
573
+ foreach ($name in $profileNames) {
574
+ $dir = if ($env:A11Y_BROWSER_PROFILE) { $env:A11Y_BROWSER_PROFILE }
575
+ elseif ($name -eq 'edge-profile' -and $env:A11Y_EDGE_PROFILE) { $env:A11Y_EDGE_PROFILE }
576
+ else { Join-Path $env:LOCALAPPDATA "a11y-witness\$name" }
577
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
578
+ OK "Browser profile dir $dir"
579
+ }
580
+
581
+ # ---------------------------------------------------------------------------
582
+ Step 8 "Worker scheduled task '$TaskName'"
583
+
584
+ # LogonType Interactive is mandatory: the task then runs inside the logged-on
585
+ # desktop session. A service, or a plain SSH command, has no desktop and NVDA
586
+ # announces nothing. RunLevel Limited (not Highest) keeps it unelevated on
587
+ # purpose -- see the UAC/UIAccess note above.
588
+ $cmd = Resolve-RepoFile 'run-server.cmd' -Required
589
+
590
+ # Use the RESOLVED identity, not "$env:USERDOMAIN\$env:USERNAME": on a workgroup
591
+ # machine USERDOMAIN is literally "WORKGROUP", which is not an account any SID maps
592
+ # to, and Register-ScheduledTask fails with HRESULT 0x80070534.
593
+ $account = [Security.Principal.WindowsIdentity]::GetCurrent().Name
594
+ # Resolve node HERE and pass it to the launcher, rather than letting the launcher work it out
595
+ # from an environment the task does not have. Provisioning runs in an interactive elevated
596
+ # session where %ProgramFiles% is correct; the task's environment has neither that nor node on
597
+ # PATH, and every attempt to re-derive it inside cmd has failed -- including a for-set over
598
+ # absolute literals. Baked into the task definition, it is inspectable with Get-ScheduledTask
599
+ # and cannot be derived wrongly at launch.
600
+ $nodeForTask = @(
601
+ (Join-Path $env:ProgramFiles 'nodejs\node.exe'),
602
+ 'C:\Program Files\nodejs\node.exe',
603
+ (Join-Path $env:LOCALAPPDATA 'Programs\nodejs\node.exe')
604
+ ) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
605
+ if (-not $nodeForTask) { $nodeForTask = (Get-Command node -ErrorAction SilentlyContinue).Source }
606
+ if (-not $nodeForTask) { throw 'node.exe not found -- cannot register a worker task that would fail at launch' }
607
+ OK "worker will run: $nodeForTask"
608
+ # The path is quoted because it contains spaces. Built as a separate variable rather than
609
+ # nested backtick-escaped quotes inside an interpolated string: the escaped form is easy to
610
+ # get wrong and, when wrong, swallows the NEXT line into the string -- which is exactly what
611
+ # happened, producing a parse error about a variable reference two lines further down.
612
+ $nodeArg = '"' + $nodeForTask + '"'
613
+ $action = New-ScheduledTaskAction -Execute $cmd -Argument $nodeArg
614
+ $principal = New-ScheduledTaskPrincipal -UserId $account -LogonType Interactive -RunLevel Limited
615
+ # An at-logon trigger is what makes the worker self-healing across reboots. Without
616
+ # it the task sits at "Ready" forever and the machine looks dead after a restart.
617
+ $trigger = New-ScheduledTaskTrigger -AtLogOn -User $account
618
+ # The at-logon trigger covers reboots but NOT a crash: when the worker process died the task
619
+ # went back to "Ready" and stayed there, so the machine answered nothing until someone logged
620
+ # on again. Observed for real -- an NVDA socket error killed the worker and it never came back.
621
+ #
622
+ # RestartCount/RestartInterval make Task Scheduler bring it back on a non-zero exit, which is
623
+ # why server.mjs now exits 1 on an unrecoverable error instead of limping on.
624
+ # ExecutionTimeLimit 0 = no limit: this is a long-running service, not a batch job, and the
625
+ # default 3-day limit would silently kill it.
626
+ $settings = New-ScheduledTaskSettingsSet -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 1) `
627
+ -ExecutionTimeLimit (New-TimeSpan -Seconds 0) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
628
+ Register-ScheduledTask -TaskName $TaskName -Action $action -Principal $principal -Trigger $trigger `
629
+ -Settings $settings -Force | Out-Null
630
+
631
+ $task = Get-ScheduledTask -TaskName $TaskName
632
+ OK "registered: logon=$($task.Principal.LogonType) runLevel=$($task.Principal.RunLevel) triggers=$(($task.Triggers | Measure-Object).Count) restarts=$($task.Settings.RestartCount)"
633
+
634
+ # The capture-regression gate needs the same interactive desktop the worker does, so it needs
635
+ # its own task -- `utmctl exec` and SSH land in session 0 and Guidepup reports that as
636
+ # "NVDA is not supported", which reads like a broken install. Registering it here means every
637
+ # worker can run the gate; it was previously reconstructed by hand each time.
638
+ #
639
+ # No trigger: this one is started on demand, never at logon. ExecutionTimeLimit is 30 minutes
640
+ # rather than unlimited, because unlike the worker it IS a batch job and a wedged check should
641
+ # not sit there forever.
642
+ $checkCmd = Resolve-RepoFile 'run-capture-check.cmd'
643
+ if (Test-Path $checkCmd) {
644
+ $checkSettings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 30)
645
+ Register-ScheduledTask -TaskName 'a11ycheck' `
646
+ -Action (New-ScheduledTaskAction -Execute $checkCmd) `
647
+ -Principal $principal -Settings $checkSettings -Force | Out-Null
648
+ OK "registered 'a11ycheck' (on-demand capture-regression gate)"
649
+ } else {
650
+ Warn "no run-capture-check.cmd at $checkCmd -- skipping the a11ycheck task"
651
+ }
652
+
653
+ # ---------------------------------------------------------------------------
654
+ # Stamp WHAT provisioned this guest, so the host's capture cache can tell when the
655
+ # environment behind the evidence changed.
656
+ #
657
+ # Provisioning is not cosmetic: it sets NVDA's configuration, Edge's policies and
658
+ # ForegroundLockTimeout, each of which changes what a capture hears. Two guests running identical
659
+ # capture code can therefore produce different evidence, and without this the cache would reuse
660
+ # one guest's captures for another's environment.
661
+ #
662
+ # Written by the guest rather than hashed on the host on purpose -- this records what the guest
663
+ # ACTUALLY has, which is the only thing worth keying on. A host-side hash would describe the
664
+ # script we intended to run.
665
+ # Delegated to stamp-provision-revision.ps1 so there is ONE definition of what the stamp covers. It was
666
+ # inline here, which meant the Ansible role could not stamp at all -- a box re-provisioned through
667
+ # `provision-role.yml` kept whatever revision its first boot happened to write, and four functionally
668
+ # identical machines reported four different revisions that no re-provision could converge.
669
+ #
670
+ # Same shape as `worker-files.mjs`: that list was duplicated in server.mjs and check-worker-code.mjs with
671
+ # a third derived by regex, and every copy had to agree or the comparison meant nothing.
672
+ $stampScript = Resolve-RepoFile 'stamp-provision-revision.ps1' -Required
673
+ $stamp = & powershell -NoProfile -ExecutionPolicy Bypass -File $stampScript -RepoPath $RepoPath
674
+ if ($LASTEXITCODE -ne 0) { throw "provision stamp failed: $stamp" }
675
+ OK "provision revision stamped: $stamp"
676
+
677
+ # ---------------------------------------------------------------------------
678
+ Step 8a 'Auto-logon with NO stored credential'
679
+
680
+ # NVDA needs a logged-on console session, and a11ysrv triggers AT LOGON. Without auto-logon a
681
+ # rebooted box sits at the login screen and never serves -- indistinguishable, from outside, from
682
+ # a dead machine. Every box in a fleet restarts, so this cannot be a manual step.
683
+ #
684
+ # NO PASSWORD IS STORED ANYWHERE, and that is the point rather than a shortcut. Auto-logon with a
685
+ # real password means the secret exists in LSA, in the operator's shell, and in whatever
686
+ # distributed it to ten machines. A local account with a BLANK password needs none of that: there
687
+ # is no credential to leak, rotate or forget.
688
+ #
689
+ # The security maths favours it, which is not obvious and is worth writing down:
690
+ #
691
+ # - Auto-logon ALREADY means the box boots to an unlocked desktop. Anyone with physical access
692
+ # owns that session whether the password is blank or forty characters. The marginal exposure
693
+ # of blanking it is close to zero.
694
+ # - Windows ships LimitBlankPasswordUse=1, which blocks blank-password accounts from NETWORK
695
+ # logon entirely -- no SMB, no RDP, no password-auth SSH. The account becomes console-only,
696
+ # which is strictly narrower than it was before.
697
+ # - SSH still works, because provisioning sets up KEY auth. Key auth is unaffected by this.
698
+ #
699
+ # It is verified below rather than assumed, because "blank password" is only safe while
700
+ # LimitBlankPasswordUse holds.
701
+ # NOT $account: Step 8 already uses that name for the task PRINCIPAL (a domain-qualified string),
702
+ # and this is a LocalUser object. Two meanings for one name in one scope is how somebody later reads
703
+ # the wrong one and cannot see why.
704
+ $localUser = $null
705
+ try { $localUser = Get-LocalUser -Name $env:USERNAME -ErrorAction Stop } catch { }
706
+
707
+ if (-not $localUser) {
708
+ Warn "no LOCAL account named '$env:USERNAME' -- auto-logon NOT configured."
709
+ Warn 'A Microsoft or domain account cannot hold a blank password. Create a local account for the'
710
+ Warn 'worker (the UTM guests use one called `witness`) and re-provision under it.'
711
+ Warn 'AS IT STANDS THIS WORKER WILL NOT COME BACK AFTER A REBOOT.'
712
+ } elseif ($localUser.PrincipalSource -and $localUser.PrincipalSource -ne 'Local') {
713
+ Warn "'$env:USERNAME' is a $($localUser.PrincipalSource) account, not Local -- auto-logon NOT configured."
714
+ Warn 'AS IT STANDS THIS WORKER WILL NOT COME BACK AFTER A REBOOT.'
715
+ } else {
716
+ try {
717
+ # An empty SecureString IS the blank password; there is no separate "clear" verb.
718
+ Set-LocalUser -Name $env:USERNAME -Password (New-Object System.Security.SecureString) -ErrorAction Stop
719
+ # Never expires: a worker that stops logging in because a password aged out is the same
720
+ # outage arriving on a timer.
721
+ Set-LocalUser -Name $env:USERNAME -PasswordNeverExpires $true -ErrorAction SilentlyContinue
722
+
723
+ $winlogon = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
724
+ Set-ItemProperty $winlogon -Name AutoAdminLogon -Value '1' -Type String -Force
725
+ Set-ItemProperty $winlogon -Name DefaultUserName -Value $env:USERNAME -Type String -Force
726
+ Set-ItemProperty $winlogon -Name DefaultDomainName -Value $env:COMPUTERNAME -Type String -Force
727
+ # Any leftover DefaultPassword would be world-readable plaintext, and AutoLogonCount makes
728
+ # auto-logon one-shot -- it decrements to zero and then the box silently stops coming back.
729
+ foreach ($dead in @('DefaultPassword', 'AutoLogonCount')) {
730
+ Remove-ItemProperty $winlogon -Name $dead -ErrorAction SilentlyContinue
731
+ }
732
+
733
+ # Verify by the MARKS Winlogon actually reads, not by the absence of an exception.
734
+ $w = Get-ItemProperty $winlogon -ErrorAction Stop
735
+ if ("$($w.AutoAdminLogon)" -ne '1') { throw "AutoAdminLogon is '$($w.AutoAdminLogon)', not 1" }
736
+ if ("$($w.DefaultUserName)" -ne "$env:USERNAME") { throw "DefaultUserName is '$($w.DefaultUserName)'" }
737
+ if ($w.PSObject.Properties.Name -contains 'DefaultPassword') { throw 'DefaultPassword still present' }
738
+ OK "auto-logon enabled for $env:USERNAME with NO stored credential"
739
+
740
+ # The guard that makes a blank password narrow rather than wide. 1 (or absent, which defaults
741
+ # to 1) confines the account to console logon. If somebody has set it to 0, say so loudly --
742
+ # that combination really would be a blank-password account reachable over the network.
743
+ $lanman = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa'
744
+ $limit = (Get-ItemProperty $lanman -Name LimitBlankPasswordUse -ErrorAction SilentlyContinue).LimitBlankPasswordUse
745
+ if ($null -eq $limit -or "$limit" -eq '1') {
746
+ OK 'LimitBlankPasswordUse is on: this account cannot be used over the network'
747
+ } else {
748
+ Warn "LimitBlankPasswordUse is $limit -- a blank-password account IS reachable over the network."
749
+ Warn 'Set it back to 1 unless you know why it was changed.'
750
+ }
751
+ Warn 'This box now boots to an UNLOCKED desktop. Correct for a lab worker on its own segment; not for anything else.'
752
+ } catch {
753
+ Warn "auto-logon setup failed ($($_.Exception.Message))"
754
+ Warn 'AS IT STANDS THIS WORKER WILL NOT COME BACK AFTER A REBOOT.'
755
+ }
756
+ }
757
+
758
+ # ---------------------------------------------------------------------------
759
+ Step 9 'Start and verify'
760
+
761
+ Get-Process node -ErrorAction SilentlyContinue | Stop-Process -Force
762
+ Start-Sleep -Seconds 2
763
+ Start-ScheduledTask -TaskName $TaskName
764
+ # Both waits below are POLLED CONDITIONS with a budget, not fixed counts. They were 20s for
765
+ # the port and a single 10s request for /health, and both are too short on a cold box -- so
766
+ # provisioning reported failure on a worker that had actually come up fine.
767
+ $LISTEN_BUDGET_S = 90 # node start + module load on a cold, unwarmed filesystem
768
+ $HEALTH_BUDGET_S = 240 # see below
769
+ $HEALTH_ATTEMPT_S = 30
770
+ $POLL_S = 2
771
+
772
+ $listenDeadline = (Get-Date).AddSeconds($LISTEN_BUDGET_S)
773
+ while (-not (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) -and
774
+ (Get-Date) -lt $listenDeadline) {
775
+ Start-Sleep -Seconds $POLL_S
776
+ }
777
+ if (-not (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)) {
778
+ throw "Worker did not listen on $Port within ${LISTEN_BUDGET_S}s. Check $RepoPath\server.log."
779
+ }
780
+ OK "listening on $Port"
781
+
782
+ # The FIRST /health triggers NVDA's warm-up, and a cold NVDA start is ~19s -- with the worker's
783
+ # own retry policy (3 attempts, 30s apart) the honest worst case is well over two minutes. A
784
+ # single 10s request therefore timed out against a HEALTHY worker and reported "operation
785
+ # timed out", which reads like a broken guest.
786
+ #
787
+ # The deadline must exceed the slowest honest answer, or "not ready yet" and "broken" become
788
+ # the same observation -- the same rule the capture path follows for silence.
789
+ $health = $null
790
+ $healthDeadline = (Get-Date).AddSeconds($HEALTH_BUDGET_S)
791
+ $lastErr = 'never attempted'
792
+ while (-not $health -and (Get-Date) -lt $healthDeadline) {
793
+ try { $health = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/health" -TimeoutSec $HEALTH_ATTEMPT_S }
794
+ catch { $lastErr = $_.Exception.Message; Start-Sleep -Seconds $POLL_S }
795
+ }
796
+ if (-not $health) { throw "/health did not answer within ${HEALTH_BUDGET_S}s (last error: $lastErr)" }
797
+ OK "/health -> ok=$($health.ok) ready=$($health.readiness.ready) screenReader=$($health.screenReader)"
798
+
799
+ # ready:false immediately after a boot is NORMAL and self-correcting -- it means "not yet",
800
+ # not "broken", and each pool worker waits for its own before taking work. Failing provisioning
801
+ # on it would sideline a healthy guest over an optimisation it does not need.
802
+ if (-not $health.readiness.ready) {
803
+ Warn "not ready yet: $($health.readiness.reason). This is normal right after a boot and clears on its own."
804
+ }
805
+
806
+ # ---------------------------------------------------------------------------
807
+ Write-Host "`n--- Provisioning complete ---" -ForegroundColor Cyan
808
+ if ($script:warnings.Count) {
809
+ Write-Host "$($script:warnings.Count) warning(s):" -ForegroundColor Yellow
810
+ $script:warnings | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow }
811
+ }
812
+
813
+ Write-Host @"
814
+
815
+ Next steps this script deliberately does NOT do:
816
+
817
+ 1. VERIFY IT SURVIVES A REBOOT. Auto-logon is configured above, with no stored
818
+ credential -- but "it works now" says nothing about whether it comes back, and
819
+ every box in a fleet restarts. Reboot this one and watch /health answer on its
820
+ own before you call it provisioned.
821
+
822
+ 2. Prove capture end-to-end. From the control machine:
823
+ curl http://<this-host>:$Port/health
824
+ node packages/lab/src/harnesses/capture-check.mjs # on THIS box, in the console session
825
+
826
+ 3. Diagnose a broken worker: packages/worker-fleet/src/provisioning/diagnose-nvda-worker.ps1
827
+ "@