@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,428 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!--
3
+ Unattended install for a BARE-METAL x64 capture worker, served from the PXE box.
4
+
5
+ The sibling at ../../local-worker/autounattend.xml is the proven recipe, and this is that recipe
6
+ adapted for real hardware on a real LAN. Four differences, each of which matters:
7
+
8
+ 1. NO PLAINTEXT PASSWORD. The arm64 file embeds `witness`/`witness` and justifies it as "a disposable
9
+ local VM behind QEMU user-mode networking, reachable solely through forwarded ports". None of that
10
+ is true of a mini PC on your LAN, so the justification does not travel with the file. The account
11
+ is created with a BLANK password instead, which is the same design the rest of the fleet already
12
+ depends on: `LimitBlankPasswordUse=1` (a Windows default) confines a blank-password account to
13
+ CONSOLE logon, so it cannot be reached over SMB, RDP or password SSH at all. That makes the account
14
+ strictly narrower than a password would, not wider — and nothing secret is ever committed here.
15
+ Provisioning re-asserts and VERIFIES that invariant afterwards; see provision-nvda-worker.ps1.
16
+
17
+ 2. NO VIRTIO DRIVERS. Those paths point at UTM's support ISO (`E:\Drivers\...`) and exist because a
18
+ QEMU guest has no inbox driver for its own disk or NIC. Real hardware does. Leaving them in would
19
+ fail the windowsPE pass looking for a drive that is not there.
20
+
21
+ 3. ComputerName is `*`, not `A11Y-WORKER`. Twelve machines answering to one NetBIOS name is a name
22
+ conflict on a real network; the arm64 file could hardcode it because there is one VM behind NAT.
23
+ Workers are identified by address in ansible/inventory.yml, so a generated name costs nothing.
24
+
25
+ 4. The TPM/SecureBoot/CPU bypasses are KEPT, and here they are load-bearing rather than a VM
26
+ workaround: the 6th and 7th generation boxes in this fleet (EliteDesk 800 G2/G3) are not on
27
+ Windows 11's supported-CPU list at all, and would refuse to install. They are harmless on the
28
+ 8th-gen machines that would pass anyway.
29
+
30
+ ============================ THIS WIPES DISK 0, WITHOUT ASKING ============================
31
+ `WillWipeDisk` on DiskID 0 is what makes the install hands-off, and it is why this file must only
32
+ ever be served to a machine you intend to become a worker. Point your PXE server's boot entry at
33
+ the machines you have set aside, not at a default-for-everything entry.
34
+ ==========================================================================================
35
+
36
+ UAC stays at its DEFAULT, deliberately, exactly as the arm64 file explains: with UAC off every
37
+ process runs at high integrity, and the capture pipeline depends on NVDA (nvda_noUIAccess.exe) and
38
+ the browser sitting at the SAME integrity level. Provisioning gets elevation from a RunLevel Highest
39
+ scheduled task, which elevates with no prompt — and a prompt would be fatal, since UAC dialogs render
40
+ on the secure desktop where no automation can reach them.
41
+ -->
42
+ <unattend xmlns="urn:schemas-microsoft-com:unattend" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
43
+
44
+ <settings pass="windowsPE">
45
+ <!-- The locale MUST be one the INSTALL MEDIA actually carries, and this is the defect that made
46
+ every unattended install fall back to a fully interactive one.
47
+
48
+ Our media is Win11_23H2_EnglishInternational_x64v2, whose volume label is
49
+ CCCOMA_X64FRE_EN-GB_DV9: it ships en-GB resources and no en-US ones. Asking for en-US
50
+ therefore fails at the point Setup tries to load them, and Setup's response is not an error
51
+ but a FALLBACK to the language-selection page. From the console that is indistinguishable
52
+ from the answer file never being read, which is how it was misdiagnosed repeatedly: the
53
+ edition name, the boot mode, iVentoy's injection and the payload server were each blamed and
54
+ each was fine.
55
+
56
+ The proof is in Setup's own log, which reports success right up to this point:
57
+
58
+ Found usable unattend file for pass [windowsPE] at [C:\autounattend.xml]
59
+ Successfully deserialized and validated unattend file
60
+ Product key is valid ... User accepted the EULA in the unattend file
61
+ SetSetupKeyboardLayout: Failed to set the key board layout to [en-US] <- here
62
+ CLanguages::v_InitLanguage - Failed to load en-US resources with error 2
63
+
64
+ Error 2 is FILE NOT FOUND. Check the media before changing these: the volume label encodes
65
+ the language, and `Callback_Locale_*` in X:\Windows\Panther\setupact.log names the failure. -->
66
+ <component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
67
+ <SetupUILanguage>
68
+ <UILanguage>en-GB</UILanguage>
69
+ </SetupUILanguage>
70
+ <InputLocale>en-GB</InputLocale>
71
+ <SystemLocale>en-GB</SystemLocale>
72
+ <UILanguage>en-GB</UILanguage>
73
+ <UserLocale>en-GB</UserLocale>
74
+ </component>
75
+
76
+ <component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
77
+ <Diagnostics>
78
+ <OptIn>false</OptIn>
79
+ </Diagnostics>
80
+
81
+ <!-- UEFI/GPT layout: ESP, MSR, then Windows across the remainder. -->
82
+ <DiskConfiguration>
83
+ <WillShowUI>OnError</WillShowUI>
84
+ <Disk wcm:action="add">
85
+ <DiskID>0</DiskID>
86
+ <WillWipeDisk>true</WillWipeDisk>
87
+ <CreatePartitions>
88
+ <CreatePartition wcm:action="add">
89
+ <Order>1</Order>
90
+ <Type>EFI</Type>
91
+ <Size>300</Size>
92
+ </CreatePartition>
93
+ <CreatePartition wcm:action="add">
94
+ <Order>2</Order>
95
+ <Type>MSR</Type>
96
+ <Size>16</Size>
97
+ </CreatePartition>
98
+ <CreatePartition wcm:action="add">
99
+ <Order>3</Order>
100
+ <Type>Primary</Type>
101
+ <Extend>true</Extend>
102
+ </CreatePartition>
103
+ </CreatePartitions>
104
+ <ModifyPartitions>
105
+ <ModifyPartition wcm:action="add">
106
+ <Order>1</Order>
107
+ <PartitionID>1</PartitionID>
108
+ <Format>FAT32</Format>
109
+ <Label>System</Label>
110
+ </ModifyPartition>
111
+ <ModifyPartition wcm:action="add">
112
+ <Order>2</Order>
113
+ <PartitionID>2</PartitionID>
114
+ </ModifyPartition>
115
+ <ModifyPartition wcm:action="add">
116
+ <Order>3</Order>
117
+ <PartitionID>3</PartitionID>
118
+ <Format>NTFS</Format>
119
+ <Label>Windows</Label>
120
+ <Letter>C</Letter>
121
+ </ModifyPartition>
122
+ </ModifyPartitions>
123
+ </Disk>
124
+ </DiskConfiguration>
125
+
126
+ <!-- /IMAGE/NAME must match an image NAME in the media's own install.wim EXACTLY, and the
127
+ value is a property of the ISO rather than of Windows. Read it, never assume it:
128
+
129
+ mount -o loop,ro <iso> /mnt
130
+ tail -c 200000 /mnt/sources/install.wim | tr -d '\0' | grep -oE '<NAME>[^<]*</NAME>'
131
+
132
+ This said "Windows 11 Professional". On Win11_23H2_EnglishInternational_x64v2 both NAME
133
+ and DESCRIPTION are "Windows 11 Pro", and "Professional" is Windows 7-era naming that
134
+ appears nowhere in that WIM, so it named no image at all.
135
+
136
+ HONESTY ABOUT WHAT THIS DID AND DID NOT CAUSE: it was found while chasing an interactive
137
+ install, and written up at the time as the cause of it. It was not — the locale mismatch
138
+ above was, proven afterwards from Setup's own log. This value was genuinely wrong and had
139
+ to be fixed, but it was never demonstrated to produce a symptom, because the locale bug
140
+ stopped Setup before the image was ever selected. Treat it as a latent defect corrected,
141
+ not as a diagnosis.
142
+
143
+ NOTE FOR THE NEXT EDITOR: an XML comment may not contain two consecutive hyphens. Using
144
+ them as a dash here broke the file a third time in this repo's history, and a malformed
145
+ answer file fails in exactly the same silent way as a wrong edition name. Validate with
146
+ `python3 -c "import xml.dom.minidom as m; m.parse('autounattend.xml')"` before pushing.
147
+
148
+ The generic key below is Microsoft's public KMS key for Pro, so this file always meant
149
+ Pro; only the spelling was wrong.
150
+
151
+ Keep this block byte-for-byte simple: substituting /IMAGE/INDEX here, or adding a
152
+ comment inside <InstallFrom>, made Setup reject the whole answer file the same way. -->
153
+ <ImageInstall>
154
+ <OSImage>
155
+ <InstallFrom>
156
+ <MetaData wcm:action="add">
157
+ <Key>/IMAGE/NAME</Key>
158
+ <Value>Windows 11 Pro</Value>
159
+ </MetaData>
160
+ </InstallFrom>
161
+ <InstallTo>
162
+ <DiskID>0</DiskID>
163
+ <PartitionID>3</PartitionID>
164
+ </InstallTo>
165
+ <WillShowUI>OnError</WillShowUI>
166
+ </OSImage>
167
+ </ImageInstall>
168
+
169
+ <!-- Not a VM workaround here: the 6th/7th-gen boxes in this fleet are not on Windows 11's
170
+ supported-CPU list and Setup refuses them outright without these. Harmless on 8th-gen. -->
171
+ <RunSynchronous>
172
+ <RunSynchronousCommand wcm:action="add">
173
+ <Order>1</Order>
174
+ <Path>reg add HKLM\System\Setup\LabConfig /v BypassCPUCheck /t REG_DWORD /d 0x00000001 /f</Path>
175
+ </RunSynchronousCommand>
176
+ <RunSynchronousCommand wcm:action="add">
177
+ <Order>2</Order>
178
+ <Path>reg add HKLM\System\Setup\LabConfig /v BypassRAMCheck /t REG_DWORD /d 0x00000001 /f</Path>
179
+ </RunSynchronousCommand>
180
+ <RunSynchronousCommand wcm:action="add">
181
+ <Order>3</Order>
182
+ <Path>reg add HKLM\System\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 0x00000001 /f</Path>
183
+ </RunSynchronousCommand>
184
+ <RunSynchronousCommand wcm:action="add">
185
+ <Order>4</Order>
186
+ <Path>reg add HKLM\System\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 0x00000001 /f</Path>
187
+ </RunSynchronousCommand>
188
+ <RunSynchronousCommand wcm:action="add">
189
+ <Order>5</Order>
190
+ <Path>reg add HKLM\System\Setup\LabConfig /v BypassDiskCheck /t REG_DWORD /d 0x00000001 /f</Path>
191
+ </RunSynchronousCommand>
192
+ <!-- BypassStorageCheck is the name Setup actually reads; BypassDiskCheck was inherited from the
193
+ arm64 file and appears in no Microsoft documentation. Both are kept: an ignored registry
194
+ value costs nothing, and a missing one costs the install. -->
195
+ <RunSynchronousCommand wcm:action="add">
196
+ <Order>6</Order>
197
+ <Path>reg add HKLM\System\Setup\LabConfig /v BypassStorageCheck /t REG_DWORD /d 0x00000001 /f</Path>
198
+ </RunSynchronousCommand>
199
+ <!-- The install-time bypasses above do not cover the UPGRADE path, which is what a later feature
200
+ update takes. Without this a box installs happily and then refuses 25H2 with "this PC doesn't
201
+ meet the minimum requirements", months later, on a machine nobody is watching. -->
202
+ <RunSynchronousCommand wcm:action="add">
203
+ <Order>7</Order>
204
+ <Path>reg add HKLM\System\Setup\MoSetup /v AllowUpgradesWithUnsupportedTPMOrCPU /t REG_DWORD /d 0x00000001 /f</Path>
205
+ </RunSynchronousCommand>
206
+ </RunSynchronous>
207
+
208
+ <UserData>
209
+ <AcceptEula>true</AcceptEula>
210
+ <FullName>a11ign</FullName>
211
+ <Organization>a11ign</Organization>
212
+ <!-- NO product key, deliberately, and the element is present-but-empty rather than absent.
213
+ `/IMAGE/NAME` above already selects the edition, which is the only job a key was doing
214
+ here: we do not want activation, and an unactivated Windows is fully functional for
215
+ capture.
216
+
217
+ It used to carry the generic Windows 11 Pro KMS client key
218
+ (W269N-WFGWX-YVC9B-4J6C9-T83GX). That worked while the media shipped a single
219
+ install.wim, and started failing with "the unattended answer file contains an invalid
220
+ product key" once install.wim was SPLIT into install.swm parts to fit the ISO's file-size
221
+ limit. Setup validates the key against the edition it resolved from the split image, and
222
+ the two stopped agreeing. Naming the edition and omitting the key removes the
223
+ disagreement rather than trying to satisfy both halves of it.
224
+
225
+ The `Key` element must be PRESENT and EMPTY, exactly like the blank password below.
226
+ Omitting it entirely fails differently and just as fatally: Setup stops with "Windows
227
+ cannot read the <ProductKey> setting from the unattend answer file". Empty means "no key,
228
+ do not ask"; absent means "malformed". `WillShowUI=Never` is what suppresses the "enter
229
+ your product key" page, which on a headless fleet is a hang nobody is there to clear. -->
230
+ <ProductKey>
231
+ <Key></Key>
232
+ <WillShowUI>Never</WillShowUI>
233
+ </ProductKey>
234
+ </UserData>
235
+ </component>
236
+ </settings>
237
+
238
+ <settings pass="specialize">
239
+ <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
240
+ <!-- `*` generates a unique name. Twelve machines called A11Y-WORKER is a NetBIOS conflict on a
241
+ real network; the workers are addressed by IP from ansible/inventory.yml regardless. -->
242
+ <ComputerName>*</ComputerName>
243
+ <TimeZone>GMT Standard Time</TimeZone>
244
+ </component>
245
+ <!-- The windowsPE LabConfig keys live in the INSTALLER's registry and do not survive into the
246
+ installed system. Written again here so the running OS carries them, which is what a feature
247
+ update and any in-place repair will consult. Without this the box installs on unsupported
248
+ hardware and then quietly stops being able to update itself.
249
+
250
+ BypassNRO removes the "you must connect to the internet and sign in with a Microsoft account"
251
+ wall that 24H2 reintroduced at OOBE. The LocalAccount + HideOnlineAccountScreens below usually
252
+ carry it, but usually is not a property you want on a fleet you cannot see the screen of. -->
253
+ <component name="Microsoft-Windows-Deployment" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
254
+ <RunSynchronous>
255
+ <RunSynchronousCommand wcm:action="add">
256
+ <Order>1</Order>
257
+ <Description>Persist the hardware bypasses into the installed OS</Description>
258
+ <Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 0x00000001 /f</Path>
259
+ </RunSynchronousCommand>
260
+ <RunSynchronousCommand wcm:action="add">
261
+ <Order>2</Order>
262
+ <Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 0x00000001 /f</Path>
263
+ </RunSynchronousCommand>
264
+ <RunSynchronousCommand wcm:action="add">
265
+ <Order>3</Order>
266
+ <Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassCPUCheck /t REG_DWORD /d 0x00000001 /f</Path>
267
+ </RunSynchronousCommand>
268
+ <RunSynchronousCommand wcm:action="add">
269
+ <Order>4</Order>
270
+ <Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassRAMCheck /t REG_DWORD /d 0x00000001 /f</Path>
271
+ </RunSynchronousCommand>
272
+ <RunSynchronousCommand wcm:action="add">
273
+ <Order>5</Order>
274
+ <Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassStorageCheck /t REG_DWORD /d 0x00000001 /f</Path>
275
+ </RunSynchronousCommand>
276
+ <RunSynchronousCommand wcm:action="add">
277
+ <Order>6</Order>
278
+ <Description>Allow feature updates on unsupported TPM/CPU</Description>
279
+ <Path>reg add HKLM\SYSTEM\Setup\MoSetup /v AllowUpgradesWithUnsupportedTPMOrCPU /t REG_DWORD /d 0x00000001 /f</Path>
280
+ </RunSynchronousCommand>
281
+ <RunSynchronousCommand wcm:action="add">
282
+ <Order>7</Order>
283
+ <Description>No Microsoft account / network wall at OOBE</Description>
284
+ <Path>reg add HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE /v BypassNRO /t REG_DWORD /d 0x00000001 /f</Path>
285
+ </RunSynchronousCommand>
286
+ <!-- THE BLANK PASSWORD MUST NOT BE ABLE TO EXPIRE, AND THIS IS THE ONLY PASS THAT CAN SAY SO.
287
+ #1933, measured 2026-09-22 while the chairman built the five new boxes from USB media: the
288
+ first one came up at "Your password has expired and must be changed" and would not log on.
289
+ An empty new password was accepted, so the account survived; nothing in the tree had stopped
290
+ it being asked.
291
+
292
+ Windows' default max password age is 42 days and it applies to the blank `witness` password
293
+ like any other. `account.yml` and `provision-nvda-worker.ps1` BOTH set
294
+ `PasswordNeverExpires`, but both run over SSH, which needs the box up, which needs
295
+ auto-logon, which is the thing the expiry breaks. Every existing remedy sits on the far side
296
+ of the door it locks.
297
+
298
+ `FirstLogonCommands` cannot fix it either, and that is the trap worth naming: an expired
299
+ password makes AUTO-LOGON ITSELF fail, so the box stops at an interactive prompt and the
300
+ first-logon commands never run at all.
301
+
302
+ So it is set here, in `specialize`, which runs during setup before any logon exists. As
303
+ POLICY rather than per-account, because at this point the account does not exist yet:
304
+ `oobeSystem` creates it below and inherits whatever policy is in force.
305
+
306
+ PXE boxes mostly dodged this because provisioning follows the install within minutes and
307
+ closes the window. A USB build is done by hand, over hours or days, from media that may have
308
+ been written weeks earlier, so the window is wide enough to land in. -->
309
+ <RunSynchronousCommand wcm:action="add">
310
+ <Order>8</Order>
311
+ <Description>A blank password that can expire is a box that cannot auto-logon</Description>
312
+ <Path>net accounts /maxpwage:unlimited</Path>
313
+ </RunSynchronousCommand>
314
+ <!-- Belt and braces, and NOT a restatement of the line above. `maxpwage` stops the clock for
315
+ accounts created from here on; this clears the expiry WARNING path that a Windows security
316
+ baseline or an OEM image may already have stamped into the image being installed. The first
317
+ machine showed the symptom on day zero, which `maxpwage` alone does not explain, so the two
318
+ are kept separate rather than merged into one command that would hide which one mattered. -->
319
+ <RunSynchronousCommand wcm:action="add">
320
+ <Order>9</Order>
321
+ <Description>No password expiry warning at logon</Description>
322
+ <Path>reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v PasswordExpiryWarning /t REG_DWORD /d 0x00000000 /f</Path>
323
+ </RunSynchronousCommand>
324
+ </RunSynchronous>
325
+ </component>
326
+ <component name="Microsoft-Windows-Security-SPP-UX" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
327
+ <SkipAutoActivation>true</SkipAutoActivation>
328
+ </component>
329
+ <component name="Microsoft-Windows-SQMApi" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
330
+ <CEIPEnabled>0</CEIPEnabled>
331
+ </component>
332
+ </settings>
333
+
334
+ <settings pass="oobeSystem">
335
+ <component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
336
+ <InputLocale>en-GB</InputLocale>
337
+ <SystemLocale>en-GB</SystemLocale>
338
+ <UILanguage>en-GB</UILanguage>
339
+ <UserLocale>en-GB</UserLocale>
340
+ </component>
341
+
342
+ <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
343
+ <UserAccounts>
344
+ <LocalAccounts>
345
+ <LocalAccount wcm:action="add">
346
+ <Name>witness</Name>
347
+ <DisplayName>witness</DisplayName>
348
+ <Group>Administrators</Group>
349
+ <!-- BLANK, and empty rather than absent: Setup wants the element. See the header — a
350
+ blank-password account is confined to console logon by LimitBlankPasswordUse, which
351
+ is what makes credential-free auto-logon safe on a networked box. -->
352
+ <Password>
353
+ <Value></Value>
354
+ <PlainText>true</PlainText>
355
+ </Password>
356
+ </LocalAccount>
357
+ </LocalAccounts>
358
+ </UserAccounts>
359
+
360
+ <!-- The whole reason this file exists. NVDA is a GUI application: it needs a real logged-on
361
+ desktop session. No auto-logon means no session after a reboot, which means captures
362
+ silently return nothing at all — and on a headless fleet nobody is there to log in. -->
363
+ <AutoLogon>
364
+ <Enabled>true</Enabled>
365
+ <Username>witness</Username>
366
+ <Password>
367
+ <Value></Value>
368
+ <PlainText>true</PlainText>
369
+ </Password>
370
+ <LogonCount>2147483647</LogonCount>
371
+ </AutoLogon>
372
+
373
+ <OOBE>
374
+ <HideEULAPage>true</HideEULAPage>
375
+ <HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
376
+ <HideOnlineAccountScreens>true</HideOnlineAccountScreens>
377
+ <HideLocalAccountScreen>true</HideLocalAccountScreen>
378
+ <HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
379
+ <ProtectYourPC>3</ProtectYourPC>
380
+ </OOBE>
381
+
382
+ <FirstLogonCommands>
383
+ <SynchronousCommand wcm:action="add">
384
+ <Order>1</Order>
385
+ <Description>Disable hibernation</Description>
386
+ <CommandLine>cmd /c POWERCFG -H OFF</CommandLine>
387
+ </SynchronousCommand>
388
+ <!-- Never leave the console locked: a locked desktop makes NVDA silent. Belt and braces
389
+ alongside provisioning, which also turns the screensaver off. -->
390
+ <SynchronousCommand wcm:action="add">
391
+ <Order>2</Order>
392
+ <Description>Disable screensaver (it steals foreground mid-capture)</Description>
393
+ <CommandLine>reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f</CommandLine>
394
+ </SynchronousCommand>
395
+ <!-- Fetch the payload over HTTP rather than looking for it on the install media.
396
+ ==================== SET THE ADDRESS BELOW BEFORE USE ====================
397
+ `$h` below is 203.0.113.10 — an RFC 5737 DOCUMENTATION address, not this fleet's real
398
+ control-plane host (#86: a real address sat here in tracked source; RFC 5737 is reserved so
399
+ it can never resolve to anything, the same replacement #83 used elsewhere in this repo).
400
+ It WILL fail to fetch, on purpose: a box that silently boots against a stale or wrong host
401
+ is a worse failure than one that visibly hangs retrying an address that cannot answer.
402
+ Replace it with your control plane's real address before generating install media.
403
+
404
+ iVentoy injects files to X:, which is the WinPE RAM disk and is GONE by the time this runs,
405
+ so scanning drive letters (which the UTM path does, from a support ISO that stays attached)
406
+ finds nothing here. Fetching also means one small file to serve rather than a repacked ISO,
407
+ and this project already has scar tissue from El Torito boot catalogues.
408
+
409
+ The box PXE-booted from this host moments ago, so it can certainly reach it.
410
+ `serve-bootstrap.sh` on the control plane serves exactly these two files. -->
411
+ <SynchronousCommand wcm:action="add">
412
+ <Order>3</Order>
413
+ <Description>Fetch the bootstrap payload from the control plane</Description>
414
+ <CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "$h='http://203.0.113.10:8099'; $d='C:\a11y'; New-Item -ItemType Directory -Force $d | Out-Null; foreach($f in 'first-boot.cmd','bootstrap-windows-worker.ps1','operator-key.pub'){ for($i=0;$i -lt 60;$i++){ try{ Invoke-WebRequest -UseBasicParsing -Uri ($h+'/'+$f) -OutFile ($d+'\'+$f); break }catch{ Start-Sleep -Seconds 5 } } }"</CommandLine>
415
+ </SynchronousCommand>
416
+ <!-- Then hand off. first-boot.cmd waits for DHCP, stages the operator key, and runs the bootstrap
417
+ elevated via a RunLevel Highest scheduled task, because a UAC prompt here would render on the secure
418
+ desktop where no automation can reach it. After this the box is reachable by Ansible and needs
419
+ no console visit at all. -->
420
+ <SynchronousCommand wcm:action="add">
421
+ <Order>4</Order>
422
+ <Description>Run the worker bootstrap elevated, without a UAC prompt</Description>
423
+ <CommandLine>cmd /c if exist C:\a11y\first-boot.cmd start /wait C:\a11y\first-boot.cmd</CommandLine>
424
+ </SynchronousCommand>
425
+ </FirstLogonCommands>
426
+ </component>
427
+ </settings>
428
+ </unattend>
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bash
2
+ # Serve the three files a PXE-installing worker fetches at first logon.
3
+ #
4
+ # ./serve-bootstrap.sh ~/.ssh/a11y-witness_ed25519.pub
5
+ # ./serve-bootstrap.sh ~/.ssh/a11y-witness_ed25519.pub 8099
6
+ #
7
+ # Run it on the machine whose address is in autounattend.xml — normally the iVentoy host, because the
8
+ # box PXE-booted from there moments earlier and can certainly reach it.
9
+ #
10
+ # ## Why a fetch rather than files on the media
11
+ #
12
+ # iVentoy's file injection decompresses into **X:**, which is the WinPE RAM disk and is GONE by the time
13
+ # FirstLogonCommands runs. The UTM path can scan drive letters because its support ISO stays attached;
14
+ # nothing stays attached here. Rebuilding the install ISO to carry the files is the other option, and
15
+ # this project has already spent a day on El Torito boot catalogues and `0xc0000225` to earn the opinion
16
+ # that it is not worth it for three small files.
17
+ #
18
+ # ## The key is served, never committed
19
+ #
20
+ # A public key is not a secret, but it IS specific to whoever runs this fleet, and a checked-in one
21
+ # grants access to anyone with the repo. It is passed in here and served for the few minutes an install
22
+ # takes.
23
+ #
24
+ # ## Run it by hand, or as a service
25
+ #
26
+ # This serves an SSH public key and two scripts to anyone on the LAN who asks. That is a small exposure and
27
+ # a real one, so run by hand it should be Ctrl-C'd once the box is up.
28
+ #
29
+ # It used to say it was "deliberately NOT a service", and the fleet outgrew that. Started by hand it is a
30
+ # step somebody has to remember, and forgetting produces the worst failure this path has: Windows installs
31
+ # fine, the fetches retry for ~15 minutes, and the box sits at a desktop with no worker on it — which reads
32
+ # as a bad image. `a11y-bootstrap.service` beside this file runs it with Restart=always instead.
33
+ #
34
+ # If you do run it as a service, note the staging below: the payload is snapshotted at START, so a restart
35
+ # is what picks up a changed first-boot.cmd or bootstrap-windows-worker.ps1.
36
+ set -euo pipefail
37
+
38
+ KEY="${1:-}"
39
+ PORT="${2:-8099}"
40
+
41
+ if [ -z "$KEY" ] || [ ! -f "$KEY" ]; then
42
+ echo "usage: $0 <path-to-public-key> [port]" >&2
43
+ echo " e.g. $0 ~/.ssh/a11y-witness_ed25519.pub" >&2
44
+ exit 2
45
+ fi
46
+
47
+ # Refuse a PRIVATE key with the loudest message available. Handing one to every machine on the LAN is
48
+ # not a mistake anyone recovers from quietly, and the two filenames differ by four characters.
49
+ if grep -q "PRIVATE KEY" "$KEY"; then
50
+ echo "REFUSING: $KEY is a PRIVATE key. Serve the .pub, and rotate that key now." >&2
51
+ exit 1
52
+ fi
53
+ if ! grep -qE '^(ssh-|ecdsa-)' "$KEY"; then
54
+ echo "REFUSING: $KEY does not look like an SSH public key (expected ssh-... or ecdsa-...)." >&2
55
+ exit 1
56
+ fi
57
+
58
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
59
+ STAGE="$(mktemp -d)"
60
+ trap 'rm -rf "$STAGE"' EXIT
61
+
62
+ cp "$HERE/../../local-worker/first-boot.cmd" "$STAGE/first-boot.cmd"
63
+ cp "$HERE/../bootstrap-windows-worker.ps1" "$STAGE/bootstrap-windows-worker.ps1"
64
+ cp "$KEY" "$STAGE/operator-key.pub"
65
+
66
+ # CRLF for the .cmd. A LF-only batch file run by cmd.exe fails in ways that name the wrong line, which is
67
+ # a debugging session nobody needs at 2am beside a mini PC.
68
+ perl -pi -e 's/\r?\n/\r\n/' "$STAGE/first-boot.cmd" 2>/dev/null || true
69
+
70
+ cat <<EOF
71
+
72
+ Serving on port $PORT:
73
+ first-boot.cmd
74
+ bootstrap-windows-worker.ps1
75
+ operator-key.pub ($(ssh-keygen -lf "$KEY" 2>/dev/null | awk '{print $2, $3}'))
76
+
77
+ autounattend.xml must point at THIS machine. Check the address in it matches one of:
78
+ $(command -v ip >/dev/null 2>&1 && ip -4 -o addr show scope global | awk '{print " http://" substr($4, 1, index($4, "/")-1) ":'"$PORT"'"}' \
79
+ || ifconfig 2>/dev/null | awk '/inet /{if ($2 != "127.0.0.1") print " http://" $2 ":'"$PORT"'"}')
80
+
81
+ Ctrl-C once the worker is up — this is not a service.
82
+
83
+ EOF
84
+
85
+ cd "$STAGE"
86
+ python3 -m http.server "$PORT"